Reapply "Merge remote-tracking branch 'upstream/main'"

This reverts commit a3e5eaf91727b68d8711cb4b32835450a47d8d65.
This commit is contained in:
RedCocoon 2024-11-05 01:38:44 +08:00
parent 19ba51f5e5
commit c7c1010077
109 changed files with 1858 additions and 1489 deletions

View File

@ -2642,9 +2642,15 @@ LEVEL = Info
;; override the azure blob base path if storage type is azureblob ;; override the azure blob base path if storage type is azureblob
;AZURE_BLOB_BASE_PATH = lfs/ ;AZURE_BLOB_BASE_PATH = lfs/
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; settings for Gitea's LFS client (eg: mirroring an upstream lfs endpoint)
;;
;[lfs_client] ;[lfs_client]
;; When mirroring an upstream lfs endpoint, limit the number of pointers in each batch request to this number ;; Limit the number of pointers in each batch request to this number
;BATCH_SIZE = 20 ;BATCH_SIZE = 20
;; Limit the number of concurrent upload/download operations within a batch
;BATCH_OPERATION_CONCURRENCY = 3
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

2
go.mod
View File

@ -124,6 +124,7 @@ require (
golang.org/x/image v0.21.0 golang.org/x/image v0.21.0
golang.org/x/net v0.30.0 golang.org/x/net v0.30.0
golang.org/x/oauth2 v0.23.0 golang.org/x/oauth2 v0.23.0
golang.org/x/sync v0.8.0
golang.org/x/sys v0.26.0 golang.org/x/sys v0.26.0
golang.org/x/text v0.19.0 golang.org/x/text v0.19.0
golang.org/x/tools v0.26.0 golang.org/x/tools v0.26.0
@ -316,7 +317,6 @@ require (
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/mod v0.21.0 // indirect golang.org/x/mod v0.21.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/time v0.7.0 // indirect golang.org/x/time v0.7.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect

View File

@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
) )
type Repository interface { type Repository interface {
@ -59,15 +60,11 @@ func repositoryFromContext(ctx context.Context, repo Repository) *git.Repository
return nil return nil
} }
type nopCloser func()
func (nopCloser) Close() error { return nil }
// RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it // RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it
func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) { func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) {
gitRepo := repositoryFromContext(ctx, repo) gitRepo := repositoryFromContext(ctx, repo)
if gitRepo != nil { if gitRepo != nil {
return gitRepo, nopCloser(nil), nil return gitRepo, util.NopCloser{}, nil
} }
gitRepo, err := OpenRepository(ctx, repo) gitRepo, err := OpenRepository(ctx, repo)
@ -95,7 +92,7 @@ func repositoryFromContextPath(ctx context.Context, path string) *git.Repository
func RepositoryFromContextOrOpenPath(ctx context.Context, path string) (*git.Repository, io.Closer, error) { func RepositoryFromContextOrOpenPath(ctx context.Context, path string) (*git.Repository, io.Closer, error) {
gitRepo := repositoryFromContextPath(ctx, path) gitRepo := repositoryFromContextPath(ctx, path)
if gitRepo != nil { if gitRepo != nil {
return gitRepo, nopCloser(nil), nil return gitRepo, util.NopCloser{}, nil
} }
gitRepo, err := git.OpenRepository(ctx, path) gitRepo, err := git.OpenRepository(ctx, path)

View File

@ -17,6 +17,8 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/proxy"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"golang.org/x/sync/errgroup"
) )
// HTTPClient is used to communicate with the LFS server // HTTPClient is used to communicate with the LFS server
@ -113,6 +115,7 @@ func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback Upl
return c.performOperation(ctx, objects, nil, callback) return c.performOperation(ctx, objects, nil, callback)
} }
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error { func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
if len(objects) == 0 { if len(objects) == 0 {
return nil return nil
@ -133,71 +136,87 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
return fmt.Errorf("TransferAdapter not found: %s", result.Transfer) return fmt.Errorf("TransferAdapter not found: %s", result.Transfer)
} }
errGroup, groupCtx := errgroup.WithContext(ctx)
errGroup.SetLimit(setting.LFSClient.BatchOperationConcurrency)
for _, object := range result.Objects { for _, object := range result.Objects {
if object.Error != nil { errGroup.Go(func() error {
log.Trace("Error on object %v: %v", object.Pointer, object.Error) return performSingleOperation(groupCtx, object, dc, uc, transferAdapter)
if uc != nil { })
if _, err := uc(object.Pointer, object.Error); err != nil {
return err
}
} else {
if err := dc(object.Pointer, nil, object.Error); err != nil {
return err
}
}
continue
}
if uc != nil {
if len(object.Actions) == 0 {
log.Trace("%v already present on server", object.Pointer)
continue
}
link, ok := object.Actions["upload"]
if !ok {
log.Debug("%+v", object)
return errors.New("missing action 'upload'")
}
content, err := uc(object.Pointer, nil)
if err != nil {
return err
}
err = transferAdapter.Upload(ctx, link, object.Pointer, content)
if err != nil {
return err
}
link, ok = object.Actions["verify"]
if ok {
if err := transferAdapter.Verify(ctx, link, object.Pointer); err != nil {
return err
}
}
} else {
link, ok := object.Actions["download"]
if !ok {
// no actions block in response, try legacy response schema
link, ok = object.Links["download"]
}
if !ok {
log.Debug("%+v", object)
return errors.New("missing action 'download'")
}
content, err := transferAdapter.Download(ctx, link)
if err != nil {
return err
}
if err := dc(object.Pointer, content, nil); err != nil {
return err
}
}
} }
// only the first error is returned, preserving legacy behavior before concurrency
return errGroup.Wait()
}
// performSingleOperation performs an LFS upload or download operation on a single object
func performSingleOperation(ctx context.Context, object *ObjectResponse, dc DownloadCallback, uc UploadCallback, transferAdapter TransferAdapter) error {
// the response from a lfs batch api request for this specific object id contained an error
if object.Error != nil {
log.Trace("Error on object %v: %v", object.Pointer, object.Error)
// this was an 'upload' request inside the batch request
if uc != nil {
if _, err := uc(object.Pointer, object.Error); err != nil {
return err
}
} else {
// this was NOT an 'upload' request inside the batch request, meaning it must be a 'download' request
if err := dc(object.Pointer, nil, object.Error); err != nil {
return err
}
}
// if the callback returns no err, then the error could be ignored, and the operations should continue
return nil
}
// the response from an lfs batch api request contained necessary upload/download fields to act upon
if uc != nil {
if len(object.Actions) == 0 {
log.Trace("%v already present on server", object.Pointer)
return nil
}
link, ok := object.Actions["upload"]
if !ok {
return errors.New("missing action 'upload'")
}
content, err := uc(object.Pointer, nil)
if err != nil {
return err
}
err = transferAdapter.Upload(ctx, link, object.Pointer, content)
if err != nil {
return err
}
link, ok = object.Actions["verify"]
if ok {
if err := transferAdapter.Verify(ctx, link, object.Pointer); err != nil {
return err
}
}
} else {
link, ok := object.Actions["download"]
if !ok {
// no actions block in response, try legacy response schema
link, ok = object.Links["download"]
}
if !ok {
log.Debug("%+v", object)
return errors.New("missing action 'download'")
}
content, err := transferAdapter.Download(ctx, link)
if err != nil {
return err
}
if err := dc(object.Pointer, content, nil); err != nil {
return err
}
}
return nil return nil
} }

View File

@ -12,6 +12,8 @@ import (
"testing" "testing"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -183,93 +185,84 @@ func TestHTTPClientDownload(t *testing.T) {
cases := []struct { cases := []struct {
endpoint string endpoint string
expectederror string expectedError string
}{ }{
// case 0
{ {
endpoint: "https://status-not-ok.io", endpoint: "https://status-not-ok.io",
expectederror: io.ErrUnexpectedEOF.Error(), expectedError: io.ErrUnexpectedEOF.Error(),
}, },
// case 1
{ {
endpoint: "https://invalid-json-response.io", endpoint: "https://invalid-json-response.io",
expectederror: "invalid json", expectedError: "invalid json",
}, },
// case 2
{ {
endpoint: "https://valid-batch-request-download.io", endpoint: "https://valid-batch-request-download.io",
expectederror: "", expectedError: "",
}, },
// case 3
{ {
endpoint: "https://response-no-objects.io", endpoint: "https://response-no-objects.io",
expectederror: "", expectedError: "",
}, },
// case 4
{ {
endpoint: "https://unknown-transfer-adapter.io", endpoint: "https://unknown-transfer-adapter.io",
expectederror: "TransferAdapter not found: ", expectedError: "TransferAdapter not found: ",
}, },
// case 5
{ {
endpoint: "https://error-in-response-objects.io", endpoint: "https://error-in-response-objects.io",
expectederror: "Object not found", expectedError: "Object not found",
}, },
// case 6
{ {
endpoint: "https://empty-actions-map.io", endpoint: "https://empty-actions-map.io",
expectederror: "missing action 'download'", expectedError: "missing action 'download'",
}, },
// case 7
{ {
endpoint: "https://download-actions-map.io", endpoint: "https://download-actions-map.io",
expectederror: "", expectedError: "",
}, },
// case 8
{ {
endpoint: "https://upload-actions-map.io", endpoint: "https://upload-actions-map.io",
expectederror: "missing action 'download'", expectedError: "missing action 'download'",
}, },
// case 9
{ {
endpoint: "https://verify-actions-map.io", endpoint: "https://verify-actions-map.io",
expectederror: "missing action 'download'", expectedError: "missing action 'download'",
}, },
// case 10
{ {
endpoint: "https://unknown-actions-map.io", endpoint: "https://unknown-actions-map.io",
expectederror: "missing action 'download'", expectedError: "missing action 'download'",
}, },
// case 11
{ {
endpoint: "https://legacy-batch-request-download.io", endpoint: "https://legacy-batch-request-download.io",
expectederror: "", expectedError: "",
}, },
} }
for n, c := range cases { defer test.MockVariableValue(&setting.LFSClient.BatchOperationConcurrency, 3)()
client := &HTTPClient{ for _, c := range cases {
client: hc, t.Run(c.endpoint, func(t *testing.T) {
endpoint: c.endpoint, client := &HTTPClient{
transfers: map[string]TransferAdapter{ client: hc,
"dummy": dummy, endpoint: c.endpoint,
}, transfers: map[string]TransferAdapter{
} "dummy": dummy,
},
err := client.Download(context.Background(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error { }
if objectError != nil {
return objectError err := client.Download(context.Background(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
if objectError != nil {
return objectError
}
b, err := io.ReadAll(content)
assert.NoError(t, err)
assert.Equal(t, []byte("dummy"), b)
return nil
})
if c.expectedError != "" {
assert.ErrorContains(t, err, c.expectedError)
} else {
assert.NoError(t, err)
} }
b, err := io.ReadAll(content)
assert.NoError(t, err)
assert.Equal(t, []byte("dummy"), b)
return nil
}) })
if len(c.expectederror) > 0 {
assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
} else {
assert.NoError(t, err, "case %d", n)
}
} }
} }
@ -296,81 +289,73 @@ func TestHTTPClientUpload(t *testing.T) {
cases := []struct { cases := []struct {
endpoint string endpoint string
expectederror string expectedError string
}{ }{
// case 0
{ {
endpoint: "https://status-not-ok.io", endpoint: "https://status-not-ok.io",
expectederror: io.ErrUnexpectedEOF.Error(), expectedError: io.ErrUnexpectedEOF.Error(),
}, },
// case 1
{ {
endpoint: "https://invalid-json-response.io", endpoint: "https://invalid-json-response.io",
expectederror: "invalid json", expectedError: "invalid json",
}, },
// case 2
{ {
endpoint: "https://valid-batch-request-upload.io", endpoint: "https://valid-batch-request-upload.io",
expectederror: "", expectedError: "",
}, },
// case 3
{ {
endpoint: "https://response-no-objects.io", endpoint: "https://response-no-objects.io",
expectederror: "", expectedError: "",
}, },
// case 4
{ {
endpoint: "https://unknown-transfer-adapter.io", endpoint: "https://unknown-transfer-adapter.io",
expectederror: "TransferAdapter not found: ", expectedError: "TransferAdapter not found: ",
}, },
// case 5
{ {
endpoint: "https://error-in-response-objects.io", endpoint: "https://error-in-response-objects.io",
expectederror: "Object not found", expectedError: "Object not found",
}, },
// case 6
{ {
endpoint: "https://empty-actions-map.io", endpoint: "https://empty-actions-map.io",
expectederror: "", expectedError: "",
}, },
// case 7
{ {
endpoint: "https://download-actions-map.io", endpoint: "https://download-actions-map.io",
expectederror: "missing action 'upload'", expectedError: "missing action 'upload'",
}, },
// case 8
{ {
endpoint: "https://upload-actions-map.io", endpoint: "https://upload-actions-map.io",
expectederror: "", expectedError: "",
}, },
// case 9
{ {
endpoint: "https://verify-actions-map.io", endpoint: "https://verify-actions-map.io",
expectederror: "missing action 'upload'", expectedError: "missing action 'upload'",
}, },
// case 10
{ {
endpoint: "https://unknown-actions-map.io", endpoint: "https://unknown-actions-map.io",
expectederror: "missing action 'upload'", expectedError: "missing action 'upload'",
}, },
} }
for n, c := range cases { defer test.MockVariableValue(&setting.LFSClient.BatchOperationConcurrency, 3)()
client := &HTTPClient{ for _, c := range cases {
client: hc, t.Run(c.endpoint, func(t *testing.T) {
endpoint: c.endpoint, client := &HTTPClient{
transfers: map[string]TransferAdapter{ client: hc,
"dummy": dummy, endpoint: c.endpoint,
}, transfers: map[string]TransferAdapter{
} "dummy": dummy,
},
}
err := client.Upload(context.Background(), []Pointer{p}, func(p Pointer, objectError error) (io.ReadCloser, error) { err := client.Upload(context.Background(), []Pointer{p}, func(p Pointer, objectError error) (io.ReadCloser, error) {
return io.NopCloser(new(bytes.Buffer)), objectError return io.NopCloser(new(bytes.Buffer)), objectError
})
if c.expectedError != "" {
assert.ErrorContains(t, err, c.expectedError)
} else {
assert.NoError(t, err)
}
}) })
if len(c.expectederror) > 0 {
assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror)
} else {
assert.NoError(t, err, "case %d", n)
}
} }
} }

View File

@ -4,8 +4,9 @@
package log package log
import ( import (
"io"
"os" "os"
"code.gitea.io/gitea/modules/util"
) )
type WriterConsoleOption struct { type WriterConsoleOption struct {
@ -18,19 +19,13 @@ type eventWriterConsole struct {
var _ EventWriter = (*eventWriterConsole)(nil) var _ EventWriter = (*eventWriterConsole)(nil)
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error { return nil }
func NewEventWriterConsole(name string, mode WriterMode) EventWriter { func NewEventWriterConsole(name string, mode WriterMode) EventWriter {
w := &eventWriterConsole{EventWriterBaseImpl: NewEventWriterBase(name, "console", mode)} w := &eventWriterConsole{EventWriterBaseImpl: NewEventWriterBase(name, "console", mode)}
opt := mode.WriterOption.(WriterConsoleOption) opt := mode.WriterOption.(WriterConsoleOption)
if opt.Stderr { if opt.Stderr {
w.OutputWriteCloser = nopCloser{os.Stderr} w.OutputWriteCloser = util.NopCloser{Writer: os.Stderr}
} else { } else {
w.OutputWriteCloser = nopCloser{os.Stdout} w.OutputWriteCloser = util.NopCloser{Writer: os.Stdout}
} }
return w return w
} }

View File

@ -6,6 +6,7 @@ package log
import ( import (
"io" "io"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/util/rotatingfilewriter" "code.gitea.io/gitea/modules/util/rotatingfilewriter"
) )
@ -42,7 +43,7 @@ func NewEventWriterFile(name string, mode WriterMode) EventWriter {
// if the log file can't be opened, what should it do? panic/exit? ignore logs? fallback to stderr? // if the log file can't be opened, what should it do? panic/exit? ignore logs? fallback to stderr?
// it seems that "fallback to stderr" is slightly better than others .... // it seems that "fallback to stderr" is slightly better than others ....
FallbackErrorf("unable to open log file %q: %v", opt.FileName, err) FallbackErrorf("unable to open log file %q: %v", opt.FileName, err)
w.fileWriter = nopCloser{Writer: LoggerToWriter(FallbackErrorf)} w.fileWriter = util.NopCloser{Writer: LoggerToWriter(FallbackErrorf)}
} }
w.OutputWriteCloser = w.fileWriter w.OutputWriteCloser = w.fileWriter
return w return w

View File

@ -6,25 +6,12 @@ package markup
import ( import (
"bytes" "bytes"
"io" "io"
"net/url"
"path"
"path/filepath"
"regexp" "regexp"
"slices"
"strings" "strings"
"sync" "sync"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/regexplru"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html" "golang.org/x/net/html"
"golang.org/x/net/html/atom" "golang.org/x/net/html/atom"
@ -451,50 +438,6 @@ func createKeyword(content string) *html.Node {
return span return span
} }
func createEmoji(content, class, name string) *html.Node {
span := &html.Node{
Type: html.ElementNode,
Data: atom.Span.String(),
Attr: []html.Attribute{},
}
if class != "" {
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class})
}
if name != "" {
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name})
}
text := &html.Node{
Type: html.TextNode,
Data: content,
}
span.AppendChild(text)
return span
}
func createCustomEmoji(alias string) *html.Node {
span := &html.Node{
Type: html.ElementNode,
Data: atom.Span.String(),
Attr: []html.Attribute{},
}
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"})
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias})
img := &html.Node{
Type: html.ElementNode,
DataAtom: atom.Img,
Data: "img",
Attr: []html.Attribute{},
}
img.Attr = append(img.Attr, html.Attribute{Key: "alt", Val: ":" + alias + ":"})
img.Attr = append(img.Attr, html.Attribute{Key: "src", Val: setting.StaticURLPrefix + "/assets/img/emoji/" + alias + ".png"})
span.AppendChild(img)
return span
}
func createLink(href, content, class string) *html.Node { func createLink(href, content, class string) *html.Node {
a := &html.Node{ a := &html.Node{
Type: html.ElementNode, Type: html.ElementNode,
@ -515,33 +458,6 @@ func createLink(href, content, class string) *html.Node {
return a return a
} }
func createCodeLink(href, content, class string) *html.Node {
a := &html.Node{
Type: html.ElementNode,
Data: atom.A.String(),
Attr: []html.Attribute{{Key: "href", Val: href}},
}
if class != "" {
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class})
}
text := &html.Node{
Type: html.TextNode,
Data: content,
}
code := &html.Node{
Type: html.ElementNode,
Data: atom.Code.String(),
Attr: []html.Attribute{{Key: "class", Val: "nohighlight"}},
}
code.AppendChild(text)
a.AppendChild(code)
return a
}
// replaceContent takes text node, and in its content it replaces a section of // replaceContent takes text node, and in its content it replaces a section of
// it with the specified newNode. // it with the specified newNode.
func replaceContent(node *html.Node, i, j int, newNode *html.Node) { func replaceContent(node *html.Node, i, j int, newNode *html.Node) {
@ -573,676 +489,3 @@ func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) {
}, nextSibling) }, nextSibling)
} }
} }
func mentionProcessor(ctx *RenderContext, node *html.Node) {
start := 0
nodeStop := node.NextSibling
for node != nodeStop {
found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:]))
if !found {
node = node.NextSibling
start = 0
continue
}
loc.Start += start
loc.End += start
mention := node.Data[loc.Start:loc.End]
teams, ok := ctx.Metas["teams"]
// FIXME: util.URLJoin may not be necessary here:
// - setting.AppURL is defined to have a terminal '/' so unless mention[1:]
// is an AppSubURL link we can probably fallback to concatenation.
// team mention should follow @orgName/teamName style
if ok && strings.Contains(mention, "/") {
mentionOrgAndTeam := strings.Split(mention, "/")
if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention"))
node = node.NextSibling.NextSibling
start = 0
continue
}
start = loc.End
continue
}
mentionedUsername := mention[1:]
if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention"))
node = node.NextSibling.NextSibling
start = 0
} else {
start = loc.End
}
}
}
func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := shortLinkPattern.FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
content := node.Data[m[2]:m[3]]
tail := node.Data[m[4]:m[5]]
props := make(map[string]string)
// MediaWiki uses [[link|text]], while GitHub uses [[text|link]]
// It makes page handling terrible, but we prefer GitHub syntax
// And fall back to MediaWiki only when it is obvious from the look
// Of text and link contents
sl := strings.Split(content, "|")
for _, v := range sl {
if equalPos := strings.IndexByte(v, '='); equalPos == -1 {
// There is no equal in this argument; this is a mandatory arg
if props["name"] == "" {
if IsFullURLString(v) {
// If we clearly see it is a link, we save it so
// But first we need to ensure, that if both mandatory args provided
// look like links, we stick to GitHub syntax
if props["link"] != "" {
props["name"] = props["link"]
}
props["link"] = strings.TrimSpace(v)
} else {
props["name"] = v
}
} else {
props["link"] = strings.TrimSpace(v)
}
} else {
// There is an equal; optional argument.
sep := strings.IndexByte(v, '=')
key, val := v[:sep], html.UnescapeString(v[sep+1:])
// When parsing HTML, x/net/html will change all quotes which are
// not used for syntax into UTF-8 quotes. So checking val[0] won't
// be enough, since that only checks a single byte.
if len(val) > 1 {
if (strings.HasPrefix(val, "“") && strings.HasSuffix(val, "”")) ||
(strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) {
const lenQuote = len("‘")
val = val[lenQuote : len(val)-lenQuote]
} else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) ||
(strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) {
val = val[1 : len(val)-1]
} else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") {
const lenQuote = len("‘")
val = val[1 : len(val)-lenQuote]
}
}
props[key] = val
}
}
var name, link string
if props["link"] != "" {
link = props["link"]
} else if props["name"] != "" {
link = props["name"]
}
if props["title"] != "" {
name = props["title"]
} else if props["name"] != "" {
name = props["name"]
} else {
name = link
}
name += tail
image := false
ext := filepath.Ext(link)
switch ext {
// fast path: empty string, ignore
case "":
// leave image as false
case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg":
image = true
}
childNode := &html.Node{}
linkNode := &html.Node{
FirstChild: childNode,
LastChild: childNode,
Type: html.ElementNode,
Data: "a",
DataAtom: atom.A,
}
childNode.Parent = linkNode
absoluteLink := IsFullURLString(link)
if !absoluteLink {
if image {
link = strings.ReplaceAll(link, " ", "+")
} else {
link = strings.ReplaceAll(link, " ", "-") // FIXME: it should support dashes in the link, eg: "the-dash-support.-"
}
if !strings.Contains(link, "/") {
link = url.PathEscape(link) // FIXME: it doesn't seem right and it might cause double-escaping
}
}
if image {
if !absoluteLink {
link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), link)
}
title := props["title"]
if title == "" {
title = props["alt"]
}
if title == "" {
title = path.Base(name)
}
alt := props["alt"]
if alt == "" {
alt = name
}
// make the childNode an image - if we can, we also place the alt
childNode.Type = html.ElementNode
childNode.Data = "img"
childNode.DataAtom = atom.Img
childNode.Attr = []html.Attribute{
{Key: "src", Val: link},
{Key: "title", Val: title},
{Key: "alt", Val: alt},
}
if alt == "" {
childNode.Attr = childNode.Attr[:2]
}
} else {
link, _ = ResolveLink(ctx, link, "")
childNode.Type = html.TextNode
childNode.Data = name
}
linkNode.Attr = []html.Attribute{{Key: "href", Val: link}}
replaceContent(node, m[0], m[1], linkNode)
node = node.NextSibling.NextSibling
}
}
func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
next := node.NextSibling
for node != nil && node != next {
m := getIssueFullPattern().FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
mDiffView := getFilesChangedFullPattern().FindStringSubmatchIndex(node.Data)
// leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files
if mDiffView != nil {
return
}
link := node.Data[m[0]:m[1]]
text := "#" + node.Data[m[2]:m[3]]
// if m[4] and m[5] is not -1, then link is to a comment
// indicate that in the text by appending (comment)
if m[4] != -1 && m[5] != -1 {
if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
text += " " + locale.TrString("repo.from_comment")
} else {
text += " (comment)"
}
}
// extract repo and org name from matched link like
// http://localhost:3000/gituser/myrepo/issues/1
linkParts := strings.Split(link, "/")
matchOrg := linkParts[len(linkParts)-4]
matchRepo := linkParts[len(linkParts)-3]
if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] {
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue"))
} else {
text = matchOrg + "/" + matchRepo + text
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue"))
}
node = node.NextSibling.NextSibling
}
}
func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
// FIXME: the use of "mode" is quite dirty and hacky, for example: what is a "document"? how should it be rendered?
// The "mode" approach should be refactored to some other more clear&reliable way.
crossLinkOnly := ctx.Metas["mode"] == "document" && !ctx.IsWiki
var (
found bool
ref *references.RenderizableReference
)
next := node.NextSibling
for node != nil && node != next {
_, hasExtTrackFormat := ctx.Metas["format"]
// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
isNumericStyle := ctx.Metas["style"] == "" || ctx.Metas["style"] == IssueNameStyleNumeric
foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
switch ctx.Metas["style"] {
case "", IssueNameStyleNumeric:
found, ref = foundNumeric, refNumeric
case IssueNameStyleAlphanumeric:
found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
case IssueNameStyleRegexp:
pattern, err := regexplru.GetCompiled(ctx.Metas["regexp"])
if err != nil {
return
}
found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
}
// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
if hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
// Allow a free-pass when non-numeric pattern wasn't found.
if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) {
found = foundNumeric
ref = refNumeric
}
}
if !found {
return
}
var link *html.Node
reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
if hasExtTrackFormat && !ref.IsPull {
ctx.Metas["index"] = ref.Issue
res, err := vars.Expand(ctx.Metas["format"], ctx.Metas)
if err != nil {
// here we could just log the error and continue the rendering
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
}
link = createLink(res, reftext, "ref-issue ref-external-issue")
} else {
// Path determines the type of link that will be rendered. It's unknown at this point whether
// the linked item is actually a PR or an issue. Luckily it's of no real consequence because
// Gitea will redirect on click as appropriate.
issuePath := util.Iif(ref.IsPull, "pulls", "issues")
if ref.Owner == "" {
link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue")
} else {
link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue")
}
}
if ref.Action == references.XRefActionNone {
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling
continue
}
// Decorate action keywords if actionable
var keyword *html.Node
if references.IsXrefActionable(ref, hasExtTrackFormat) {
keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
} else {
keyword = &html.Node{
Type: html.TextNode,
Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End],
}
}
spaces := &html.Node{
Type: html.TextNode,
Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start],
}
replaceContentList(node, ref.ActionLocation.Start, ref.RefLocation.End, []*html.Node{keyword, spaces, link})
node = node.NextSibling.NextSibling.NextSibling.NextSibling
}
}
func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
found, ref := references.FindRenderizableCommitCrossReference(node.Data)
if !found {
return
}
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit")
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling
}
}
type anyHashPatternResult struct {
PosStart int
PosEnd int
FullURL string
CommitID string
SubPath string
QueryHash string
}
func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) {
m := anyHashPattern.FindStringSubmatchIndex(s)
if m == nil {
return ret, false
}
ret.PosStart, ret.PosEnd = m[0], m[1]
ret.FullURL = s[ret.PosStart:ret.PosEnd]
if strings.HasSuffix(ret.FullURL, ".") {
// if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence.
ret.PosEnd--
ret.FullURL = ret.FullURL[:len(ret.FullURL)-1]
for i := 0; i < len(m); i++ {
m[i] = min(m[i], ret.PosEnd)
}
}
ret.CommitID = s[m[2]:m[3]]
if m[5] > 0 {
ret.SubPath = s[m[4]:m[5]]
}
lastStart, lastEnd := m[len(m)-2], m[len(m)-1]
if lastEnd > 0 {
ret.QueryHash = s[lastStart:lastEnd][1:]
}
return ret, true
}
// fullHashPatternProcessor renders SHA containing URLs
func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
nodeStop := node.NextSibling
for node != nodeStop {
if node.Type != html.TextNode {
node = node.NextSibling
continue
}
ret, ok := anyHashPatternExtract(node.Data)
if !ok {
node = node.NextSibling
continue
}
text := base.ShortSha(ret.CommitID)
if ret.SubPath != "" {
text += ret.SubPath
}
if ret.QueryHash != "" {
text += " (" + ret.QueryHash + ")"
}
replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit"))
node = node.NextSibling.NextSibling
}
}
func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
nodeStop := node.NextSibling
for node != nodeStop {
if node.Type != html.TextNode {
node = node.NextSibling
continue
}
m := comparePattern.FindStringSubmatchIndex(node.Data)
if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match
node = node.NextSibling
continue
}
urlFull := node.Data[m[0]:m[1]]
text1 := base.ShortSha(node.Data[m[2]:m[3]])
textDots := base.ShortSha(node.Data[m[4]:m[5]])
text2 := base.ShortSha(node.Data[m[6]:m[7]])
hash := ""
if m[9] > 0 {
hash = node.Data[m[8]:m[9]][1:]
}
start := m[0]
end := m[1]
// If url ends in '.', it's very likely that it is not part of the
// actual url but used to finish a sentence.
if strings.HasSuffix(urlFull, ".") {
end--
urlFull = urlFull[:len(urlFull)-1]
if hash != "" {
hash = hash[:len(hash)-1]
} else if text2 != "" {
text2 = text2[:len(text2)-1]
}
}
text := text1 + textDots + text2
if hash != "" {
text += " (" + hash + ")"
}
replaceContent(node, start, end, createCodeLink(urlFull, text, "compare"))
node = node.NextSibling.NextSibling
}
}
// emojiShortCodeProcessor for rendering text like :smile: into emoji
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
start := 0
next := node.NextSibling
for node != nil && node != next && start < len(node.Data) {
m := emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:])
if m == nil {
return
}
m[0] += start
m[1] += start
start = m[1]
alias := node.Data[m[0]:m[1]]
alias = strings.ReplaceAll(alias, ":", "")
converted := emoji.FromAlias(alias)
if converted == nil {
// check if this is a custom reaction
if _, exist := setting.UI.CustomEmojisMap[alias]; exist {
replaceContent(node, m[0], m[1], createCustomEmoji(alias))
node = node.NextSibling.NextSibling
start = 0
continue
}
continue
}
replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description))
node = node.NextSibling.NextSibling
start = 0
}
}
// emoji processor to match emoji and add emoji class
func emojiProcessor(ctx *RenderContext, node *html.Node) {
start := 0
next := node.NextSibling
for node != nil && node != next && start < len(node.Data) {
m := emoji.FindEmojiSubmatchIndex(node.Data[start:])
if m == nil {
return
}
m[0] += start
m[1] += start
codepoint := node.Data[m[0]:m[1]]
start = m[1]
val := emoji.FromCode(codepoint)
if val != nil {
replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description))
node = node.NextSibling.NextSibling
start = 0
}
}
}
// hashCurrentPatternProcessor renders SHA1 strings to corresponding links that
// are assumed to be in the same repository.
func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil || ctx.Metas["user"] == "" || ctx.Metas["repo"] == "" || (ctx.Repo == nil && ctx.GitRepo == nil) {
return
}
start := 0
next := node.NextSibling
if ctx.ShaExistCache == nil {
ctx.ShaExistCache = make(map[string]bool)
}
for node != nil && node != next && start < len(node.Data) {
m := hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:])
if m == nil {
return
}
m[2] += start
m[3] += start
hash := node.Data[m[2]:m[3]]
// The regex does not lie, it matches the hash pattern.
// However, a regex cannot know if a hash actually exists or not.
// We could assume that a SHA1 hash should probably contain alphas AND numerics
// but that is not always the case.
// Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash
// as used by git and github for linking and thus we have to do similar.
// Because of this, we check to make sure that a matched hash is actually
// a commit in the repository before making it a link.
// check cache first
exist, inCache := ctx.ShaExistCache[hash]
if !inCache {
if ctx.GitRepo == nil {
var err error
var closer io.Closer
ctx.GitRepo, closer, err = gitrepo.RepositoryFromContextOrOpen(ctx.Ctx, ctx.Repo)
if err != nil {
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(ctx.Repo), err)
return
}
ctx.AddCancel(func() {
_ = closer.Close()
ctx.GitRepo = nil
})
}
// Don't use IsObjectExist since it doesn't support short hashs with gogit edition.
exist = ctx.GitRepo.IsReferenceExist(hash)
ctx.ShaExistCache[hash] = exist
}
if !exist {
start = m[3]
continue
}
link := util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], "commit", hash)
replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit"))
start = 0
node = node.NextSibling.NextSibling
}
}
// emailAddressProcessor replaces raw email addresses with a mailto: link.
func emailAddressProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := emailRegex.FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
mail := node.Data[m[2]:m[3]]
replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto"))
node = node.NextSibling.NextSibling
}
}
// linkProcessor creates links for any HTTP or HTTPS URL not captured by
// markdown.
func linkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := common.LinkRegex.FindStringIndex(node.Data)
if m == nil {
return
}
uri := node.Data[m[0]:m[1]]
replaceContent(node, m[0], m[1], createLink(uri, uri, "link"))
node = node.NextSibling.NextSibling
}
}
func genDefaultLinkProcessor(defaultLink string) processor {
return func(ctx *RenderContext, node *html.Node) {
ch := &html.Node{
Parent: node,
Type: html.TextNode,
Data: node.Data,
}
node.Type = html.ElementNode
node.Data = "a"
node.DataAtom = atom.A
node.Attr = []html.Attribute{
{Key: "href", Val: defaultLink},
{Key: "class", Val: "default-link muted"},
}
node.FirstChild, node.LastChild = ch, ch
}
}
// descriptionLinkProcessor creates links for DescriptionHTML
func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := common.LinkRegex.FindStringIndex(node.Data)
if m == nil {
return
}
uri := node.Data[m[0]:m[1]]
replaceContent(node, m[0], m[1], createDescriptionLink(uri, uri))
node = node.NextSibling.NextSibling
}
}
func createDescriptionLink(href, content string) *html.Node {
textNode := &html.Node{
Type: html.TextNode,
Data: content,
}
linkNode := &html.Node{
FirstChild: textNode,
LastChild: textNode,
Type: html.ElementNode,
Data: "a",
DataAtom: atom.A,
Attr: []html.Attribute{
{Key: "href", Val: href},
{Key: "target", Val: "_blank"},
{Key: "rel", Val: "noopener noreferrer"},
},
}
textNode.Parent = linkNode
return linkNode
}

View File

@ -0,0 +1,225 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"io"
"slices"
"strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
type anyHashPatternResult struct {
PosStart int
PosEnd int
FullURL string
CommitID string
SubPath string
QueryHash string
}
func createCodeLink(href, content, class string) *html.Node {
a := &html.Node{
Type: html.ElementNode,
Data: atom.A.String(),
Attr: []html.Attribute{{Key: "href", Val: href}},
}
if class != "" {
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class})
}
text := &html.Node{
Type: html.TextNode,
Data: content,
}
code := &html.Node{
Type: html.ElementNode,
Data: atom.Code.String(),
Attr: []html.Attribute{{Key: "class", Val: "nohighlight"}},
}
code.AppendChild(text)
a.AppendChild(code)
return a
}
func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) {
m := anyHashPattern.FindStringSubmatchIndex(s)
if m == nil {
return ret, false
}
ret.PosStart, ret.PosEnd = m[0], m[1]
ret.FullURL = s[ret.PosStart:ret.PosEnd]
if strings.HasSuffix(ret.FullURL, ".") {
// if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence.
ret.PosEnd--
ret.FullURL = ret.FullURL[:len(ret.FullURL)-1]
for i := 0; i < len(m); i++ {
m[i] = min(m[i], ret.PosEnd)
}
}
ret.CommitID = s[m[2]:m[3]]
if m[5] > 0 {
ret.SubPath = s[m[4]:m[5]]
}
lastStart, lastEnd := m[len(m)-2], m[len(m)-1]
if lastEnd > 0 {
ret.QueryHash = s[lastStart:lastEnd][1:]
}
return ret, true
}
// fullHashPatternProcessor renders SHA containing URLs
func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
nodeStop := node.NextSibling
for node != nodeStop {
if node.Type != html.TextNode {
node = node.NextSibling
continue
}
ret, ok := anyHashPatternExtract(node.Data)
if !ok {
node = node.NextSibling
continue
}
text := base.ShortSha(ret.CommitID)
if ret.SubPath != "" {
text += ret.SubPath
}
if ret.QueryHash != "" {
text += " (" + ret.QueryHash + ")"
}
replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit"))
node = node.NextSibling.NextSibling
}
}
func comparePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
nodeStop := node.NextSibling
for node != nodeStop {
if node.Type != html.TextNode {
node = node.NextSibling
continue
}
m := comparePattern.FindStringSubmatchIndex(node.Data)
if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match
node = node.NextSibling
continue
}
urlFull := node.Data[m[0]:m[1]]
text1 := base.ShortSha(node.Data[m[2]:m[3]])
textDots := base.ShortSha(node.Data[m[4]:m[5]])
text2 := base.ShortSha(node.Data[m[6]:m[7]])
hash := ""
if m[9] > 0 {
hash = node.Data[m[8]:m[9]][1:]
}
start := m[0]
end := m[1]
// If url ends in '.', it's very likely that it is not part of the
// actual url but used to finish a sentence.
if strings.HasSuffix(urlFull, ".") {
end--
urlFull = urlFull[:len(urlFull)-1]
if hash != "" {
hash = hash[:len(hash)-1]
} else if text2 != "" {
text2 = text2[:len(text2)-1]
}
}
text := text1 + textDots + text2
if hash != "" {
text += " (" + hash + ")"
}
replaceContent(node, start, end, createCodeLink(urlFull, text, "compare"))
node = node.NextSibling.NextSibling
}
}
// hashCurrentPatternProcessor renders SHA1 strings to corresponding links that
// are assumed to be in the same repository.
func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil || ctx.Metas["user"] == "" || ctx.Metas["repo"] == "" || (ctx.Repo == nil && ctx.GitRepo == nil) {
return
}
start := 0
next := node.NextSibling
if ctx.ShaExistCache == nil {
ctx.ShaExistCache = make(map[string]bool)
}
for node != nil && node != next && start < len(node.Data) {
m := hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:])
if m == nil {
return
}
m[2] += start
m[3] += start
hash := node.Data[m[2]:m[3]]
// The regex does not lie, it matches the hash pattern.
// However, a regex cannot know if a hash actually exists or not.
// We could assume that a SHA1 hash should probably contain alphas AND numerics
// but that is not always the case.
// Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash
// as used by git and github for linking and thus we have to do similar.
// Because of this, we check to make sure that a matched hash is actually
// a commit in the repository before making it a link.
// check cache first
exist, inCache := ctx.ShaExistCache[hash]
if !inCache {
if ctx.GitRepo == nil {
var err error
var closer io.Closer
ctx.GitRepo, closer, err = gitrepo.RepositoryFromContextOrOpen(ctx.Ctx, ctx.Repo)
if err != nil {
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(ctx.Repo), err)
return
}
ctx.AddCancel(func() {
_ = closer.Close()
ctx.GitRepo = nil
})
}
// Don't use IsObjectExist since it doesn't support short hashs with gogit edition.
exist = ctx.GitRepo.IsReferenceExist(hash)
ctx.ShaExistCache[hash] = exist
}
if !exist {
start = m[3]
continue
}
link := util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], "commit", hash)
replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit"))
start = 0
node = node.NextSibling.NextSibling
}
}

View File

@ -0,0 +1,21 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import "golang.org/x/net/html"
// emailAddressProcessor replaces raw email addresses with a mailto: link.
func emailAddressProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := emailRegex.FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
mail := node.Data[m[2]:m[3]]
replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto"))
node = node.NextSibling.NextSibling
}
}

View File

@ -0,0 +1,115 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"strings"
"code.gitea.io/gitea/modules/emoji"
"code.gitea.io/gitea/modules/setting"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
func createEmoji(content, class, name string) *html.Node {
span := &html.Node{
Type: html.ElementNode,
Data: atom.Span.String(),
Attr: []html.Attribute{},
}
if class != "" {
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class})
}
if name != "" {
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name})
}
text := &html.Node{
Type: html.TextNode,
Data: content,
}
span.AppendChild(text)
return span
}
func createCustomEmoji(alias string) *html.Node {
span := &html.Node{
Type: html.ElementNode,
Data: atom.Span.String(),
Attr: []html.Attribute{},
}
span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"})
span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias})
img := &html.Node{
Type: html.ElementNode,
DataAtom: atom.Img,
Data: "img",
Attr: []html.Attribute{},
}
img.Attr = append(img.Attr, html.Attribute{Key: "alt", Val: ":" + alias + ":"})
img.Attr = append(img.Attr, html.Attribute{Key: "src", Val: setting.StaticURLPrefix + "/assets/img/emoji/" + alias + ".png"})
span.AppendChild(img)
return span
}
// emojiShortCodeProcessor for rendering text like :smile: into emoji
func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
start := 0
next := node.NextSibling
for node != nil && node != next && start < len(node.Data) {
m := emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:])
if m == nil {
return
}
m[0] += start
m[1] += start
start = m[1]
alias := node.Data[m[0]:m[1]]
alias = strings.ReplaceAll(alias, ":", "")
converted := emoji.FromAlias(alias)
if converted == nil {
// check if this is a custom reaction
if _, exist := setting.UI.CustomEmojisMap[alias]; exist {
replaceContent(node, m[0], m[1], createCustomEmoji(alias))
node = node.NextSibling.NextSibling
start = 0
continue
}
continue
}
replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description))
node = node.NextSibling.NextSibling
start = 0
}
}
// emoji processor to match emoji and add emoji class
func emojiProcessor(ctx *RenderContext, node *html.Node) {
start := 0
next := node.NextSibling
for node != nil && node != next && start < len(node.Data) {
m := emoji.FindEmojiSubmatchIndex(node.Data[start:])
if m == nil {
return
}
m[0] += start
m[1] += start
codepoint := node.Data[m[0]:m[1]]
start = m[1]
val := emoji.FromCode(codepoint)
if val != nil {
replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description))
node = node.NextSibling.NextSibling
start = 0
}
}
}

View File

@ -0,0 +1,180 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"strings"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/regexplru"
"code.gitea.io/gitea/modules/templates/vars"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
)
func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
next := node.NextSibling
for node != nil && node != next {
m := getIssueFullPattern().FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
mDiffView := getFilesChangedFullPattern().FindStringSubmatchIndex(node.Data)
// leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files
if mDiffView != nil {
return
}
link := node.Data[m[0]:m[1]]
text := "#" + node.Data[m[2]:m[3]]
// if m[4] and m[5] is not -1, then link is to a comment
// indicate that in the text by appending (comment)
if m[4] != -1 && m[5] != -1 {
if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok {
text += " " + locale.TrString("repo.from_comment")
} else {
text += " (comment)"
}
}
// extract repo and org name from matched link like
// http://localhost:3000/gituser/myrepo/issues/1
linkParts := strings.Split(link, "/")
matchOrg := linkParts[len(linkParts)-4]
matchRepo := linkParts[len(linkParts)-3]
if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] {
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue"))
} else {
text = matchOrg + "/" + matchRepo + text
replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue"))
}
node = node.NextSibling.NextSibling
}
}
func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) {
if ctx.Metas == nil {
return
}
// FIXME: the use of "mode" is quite dirty and hacky, for example: what is a "document"? how should it be rendered?
// The "mode" approach should be refactored to some other more clear&reliable way.
crossLinkOnly := ctx.Metas["mode"] == "document" && !ctx.IsWiki
var (
found bool
ref *references.RenderizableReference
)
next := node.NextSibling
for node != nil && node != next {
_, hasExtTrackFormat := ctx.Metas["format"]
// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
isNumericStyle := ctx.Metas["style"] == "" || ctx.Metas["style"] == IssueNameStyleNumeric
foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly)
switch ctx.Metas["style"] {
case "", IssueNameStyleNumeric:
found, ref = foundNumeric, refNumeric
case IssueNameStyleAlphanumeric:
found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data)
case IssueNameStyleRegexp:
pattern, err := regexplru.GetCompiled(ctx.Metas["regexp"])
if err != nil {
return
}
found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern)
}
// Repos with external issue trackers might still need to reference local PRs
// We need to concern with the first one that shows up in the text, whichever it is
if hasExtTrackFormat && !isNumericStyle && refNumeric != nil {
// If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that
// Allow a free-pass when non-numeric pattern wasn't found.
if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) {
found = foundNumeric
ref = refNumeric
}
}
if !found {
return
}
var link *html.Node
reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End]
if hasExtTrackFormat && !ref.IsPull {
ctx.Metas["index"] = ref.Issue
res, err := vars.Expand(ctx.Metas["format"], ctx.Metas)
if err != nil {
// here we could just log the error and continue the rendering
log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err)
}
link = createLink(res, reftext, "ref-issue ref-external-issue")
} else {
// Path determines the type of link that will be rendered. It's unknown at this point whether
// the linked item is actually a PR or an issue. Luckily it's of no real consequence because
// Gitea will redirect on click as appropriate.
issuePath := util.Iif(ref.IsPull, "pulls", "issues")
if ref.Owner == "" {
link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue")
} else {
link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue")
}
}
if ref.Action == references.XRefActionNone {
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling
continue
}
// Decorate action keywords if actionable
var keyword *html.Node
if references.IsXrefActionable(ref, hasExtTrackFormat) {
keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End])
} else {
keyword = &html.Node{
Type: html.TextNode,
Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End],
}
}
spaces := &html.Node{
Type: html.TextNode,
Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start],
}
replaceContentList(node, ref.ActionLocation.Start, ref.RefLocation.End, []*html.Node{keyword, spaces, link})
node = node.NextSibling.NextSibling.NextSibling.NextSibling
}
}
func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
found, ref := references.FindRenderizableCommitCrossReference(node.Data)
if !found {
return
}
reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha)
link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit")
replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link)
node = node.NextSibling.NextSibling
}
}

View File

@ -4,7 +4,16 @@
package markup package markup
import ( import (
"net/url"
"path"
"path/filepath"
"strings"
"code.gitea.io/gitea/modules/markup/common"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
) )
func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (result string, resolved bool) { func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (result string, resolved bool) {
@ -27,3 +36,221 @@ func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (resu
} }
return link, resolved return link, resolved
} }
func shortLinkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := shortLinkPattern.FindStringSubmatchIndex(node.Data)
if m == nil {
return
}
content := node.Data[m[2]:m[3]]
tail := node.Data[m[4]:m[5]]
props := make(map[string]string)
// MediaWiki uses [[link|text]], while GitHub uses [[text|link]]
// It makes page handling terrible, but we prefer GitHub syntax
// And fall back to MediaWiki only when it is obvious from the look
// Of text and link contents
sl := strings.Split(content, "|")
for _, v := range sl {
if equalPos := strings.IndexByte(v, '='); equalPos == -1 {
// There is no equal in this argument; this is a mandatory arg
if props["name"] == "" {
if IsFullURLString(v) {
// If we clearly see it is a link, we save it so
// But first we need to ensure, that if both mandatory args provided
// look like links, we stick to GitHub syntax
if props["link"] != "" {
props["name"] = props["link"]
}
props["link"] = strings.TrimSpace(v)
} else {
props["name"] = v
}
} else {
props["link"] = strings.TrimSpace(v)
}
} else {
// There is an equal; optional argument.
sep := strings.IndexByte(v, '=')
key, val := v[:sep], html.UnescapeString(v[sep+1:])
// When parsing HTML, x/net/html will change all quotes which are
// not used for syntax into UTF-8 quotes. So checking val[0] won't
// be enough, since that only checks a single byte.
if len(val) > 1 {
if (strings.HasPrefix(val, "“") && strings.HasSuffix(val, "”")) ||
(strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) {
const lenQuote = len("‘")
val = val[lenQuote : len(val)-lenQuote]
} else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) ||
(strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) {
val = val[1 : len(val)-1]
} else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") {
const lenQuote = len("‘")
val = val[1 : len(val)-lenQuote]
}
}
props[key] = val
}
}
var name, link string
if props["link"] != "" {
link = props["link"]
} else if props["name"] != "" {
link = props["name"]
}
if props["title"] != "" {
name = props["title"]
} else if props["name"] != "" {
name = props["name"]
} else {
name = link
}
name += tail
image := false
ext := filepath.Ext(link)
switch ext {
// fast path: empty string, ignore
case "":
// leave image as false
case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg":
image = true
}
childNode := &html.Node{}
linkNode := &html.Node{
FirstChild: childNode,
LastChild: childNode,
Type: html.ElementNode,
Data: "a",
DataAtom: atom.A,
}
childNode.Parent = linkNode
absoluteLink := IsFullURLString(link)
if !absoluteLink {
if image {
link = strings.ReplaceAll(link, " ", "+")
} else {
link = strings.ReplaceAll(link, " ", "-") // FIXME: it should support dashes in the link, eg: "the-dash-support.-"
}
if !strings.Contains(link, "/") {
link = url.PathEscape(link) // FIXME: it doesn't seem right and it might cause double-escaping
}
}
if image {
if !absoluteLink {
link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), link)
}
title := props["title"]
if title == "" {
title = props["alt"]
}
if title == "" {
title = path.Base(name)
}
alt := props["alt"]
if alt == "" {
alt = name
}
// make the childNode an image - if we can, we also place the alt
childNode.Type = html.ElementNode
childNode.Data = "img"
childNode.DataAtom = atom.Img
childNode.Attr = []html.Attribute{
{Key: "src", Val: link},
{Key: "title", Val: title},
{Key: "alt", Val: alt},
}
if alt == "" {
childNode.Attr = childNode.Attr[:2]
}
} else {
link, _ = ResolveLink(ctx, link, "")
childNode.Type = html.TextNode
childNode.Data = name
}
linkNode.Attr = []html.Attribute{{Key: "href", Val: link}}
replaceContent(node, m[0], m[1], linkNode)
node = node.NextSibling.NextSibling
}
}
// linkProcessor creates links for any HTTP or HTTPS URL not captured by
// markdown.
func linkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := common.LinkRegex.FindStringIndex(node.Data)
if m == nil {
return
}
uri := node.Data[m[0]:m[1]]
replaceContent(node, m[0], m[1], createLink(uri, uri, "link"))
node = node.NextSibling.NextSibling
}
}
func genDefaultLinkProcessor(defaultLink string) processor {
return func(ctx *RenderContext, node *html.Node) {
ch := &html.Node{
Parent: node,
Type: html.TextNode,
Data: node.Data,
}
node.Type = html.ElementNode
node.Data = "a"
node.DataAtom = atom.A
node.Attr = []html.Attribute{
{Key: "href", Val: defaultLink},
{Key: "class", Val: "default-link muted"},
}
node.FirstChild, node.LastChild = ch, ch
}
}
// descriptionLinkProcessor creates links for DescriptionHTML
func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) {
next := node.NextSibling
for node != nil && node != next {
m := common.LinkRegex.FindStringIndex(node.Data)
if m == nil {
return
}
uri := node.Data[m[0]:m[1]]
replaceContent(node, m[0], m[1], createDescriptionLink(uri, uri))
node = node.NextSibling.NextSibling
}
}
func createDescriptionLink(href, content string) *html.Node {
textNode := &html.Node{
Type: html.TextNode,
Data: content,
}
linkNode := &html.Node{
FirstChild: textNode,
LastChild: textNode,
Type: html.ElementNode,
Data: "a",
DataAtom: atom.A,
Attr: []html.Attribute{
{Key: "href", Val: href},
{Key: "target", Val: "_blank"},
{Key: "rel", Val: "noopener noreferrer"},
},
}
textNode.Parent = linkNode
return linkNode
}

View File

@ -0,0 +1,54 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"strings"
"code.gitea.io/gitea/modules/references"
"code.gitea.io/gitea/modules/util"
"golang.org/x/net/html"
)
func mentionProcessor(ctx *RenderContext, node *html.Node) {
start := 0
nodeStop := node.NextSibling
for node != nodeStop {
found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:]))
if !found {
node = node.NextSibling
start = 0
continue
}
loc.Start += start
loc.End += start
mention := node.Data[loc.Start:loc.End]
teams, ok := ctx.Metas["teams"]
// FIXME: util.URLJoin may not be necessary here:
// - setting.AppURL is defined to have a terminal '/' so unless mention[1:]
// is an AppSubURL link we can probably fallback to concatenation.
// team mention should follow @orgName/teamName style
if ok && strings.Contains(mention, "/") {
mentionOrgAndTeam := strings.Split(mention, "/")
if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") {
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention"))
node = node.NextSibling.NextSibling
start = 0
continue
}
start = loc.End
continue
}
mentionedUsername := mention[1:]
if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) {
replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention"))
node = node.NextSibling.NextSibling
start = 0
} else {
start = loc.End
}
}
}

View File

@ -45,7 +45,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
ctx := pc.Get(renderContextKey).(*markup.RenderContext) ctx := pc.Get(renderContextKey).(*markup.RenderContext)
rc := pc.Get(renderConfigKey).(*RenderConfig) rc := pc.Get(renderConfigKey).(*RenderConfig)
tocList := make([]markup.Header, 0, 20) tocList := make([]Header, 0, 20)
if rc.yamlNode != nil { if rc.yamlNode != nil {
metaNode := rc.toMetaNode() metaNode := rc.toMetaNode()
if metaNode != nil { if metaNode != nil {

View File

@ -7,13 +7,19 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
) )
func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]string) ast.Node { // Header holds the data about a header.
type Header struct {
Level int
Text string
ID string
}
func createTOCNode(toc []Header, lang string, detailsAttrs map[string]string) ast.Node {
details := NewDetails() details := NewDetails()
summary := NewSummary() summary := NewSummary()

View File

@ -13,14 +13,14 @@ import (
"github.com/yuin/goldmark/text" "github.com/yuin/goldmark/text"
) )
func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]markup.Header) { func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]Header) {
for _, attr := range v.Attributes() { for _, attr := range v.Attributes() {
if _, ok := attr.Value.([]byte); !ok { if _, ok := attr.Value.([]byte); !ok {
v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value))) v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value)))
} }
} }
txt := v.Text(reader.Source()) //nolint:staticcheck txt := v.Text(reader.Source()) //nolint:staticcheck
header := markup.Header{ header := Header{
Text: util.UnsafeBytesToString(txt), Text: util.UnsafeBytesToString(txt),
Level: v.Level, Level: v.Level,
} }

226
modules/markup/render.go Normal file
View File

@ -0,0 +1,226 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"path/filepath"
"strings"
"sync"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/yuin/goldmark/ast"
)
type RenderMetaMode string
const (
RenderMetaAsDetails RenderMetaMode = "details" // default
RenderMetaAsNone RenderMetaMode = "none"
RenderMetaAsTable RenderMetaMode = "table"
)
// RenderContext represents a render context
type RenderContext struct {
Ctx context.Context
RelativePath string // relative path from tree root of the branch
Type string
IsWiki bool
Links Links
Metas map[string]string // user, repo, mode(comment/document)
DefaultLink string
GitRepo *git.Repository
Repo gitrepo.Repository
ShaExistCache map[string]bool
cancelFn func()
SidebarTocNode ast.Node
RenderMetaAs RenderMetaMode
InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
}
// Cancel runs any cleanup functions that have been registered for this Ctx
func (ctx *RenderContext) Cancel() {
if ctx == nil {
return
}
ctx.ShaExistCache = map[string]bool{}
if ctx.cancelFn == nil {
return
}
ctx.cancelFn()
}
// AddCancel adds the provided fn as a Cleanup for this Ctx
func (ctx *RenderContext) AddCancel(fn func()) {
if ctx == nil {
return
}
oldCancelFn := ctx.cancelFn
if oldCancelFn == nil {
ctx.cancelFn = fn
return
}
ctx.cancelFn = func() {
defer oldCancelFn()
fn()
}
}
// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
if ctx.Type != "" {
return renderByType(ctx, input, output)
} else if ctx.RelativePath != "" {
return renderFile(ctx, input, output)
}
return errors.New("render options both filename and type missing")
}
// RenderString renders Markup string to HTML with all specific handling stuff and return string
func RenderString(ctx *RenderContext, content string) (string, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
func renderIFrame(ctx *RenderContext, output io.Writer) error {
// set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
// at the moment, only "allow-scripts" is allowed for sandbox mode.
// "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token
// TODO: when using dark theme, if the rendered content doesn't have proper style, the default text color is black, which is not easy to read
_, err := io.WriteString(output, fmt.Sprintf(`
<iframe src="%s/%s/%s/render/%s/%s"
name="giteaExternalRender"
onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
sandbox="allow-scripts"
></iframe>`,
setting.AppSubURL,
url.PathEscape(ctx.Metas["user"]),
url.PathEscape(ctx.Metas["repo"]),
ctx.Metas["BranchNameSubURL"],
url.PathEscape(ctx.RelativePath),
))
return err
}
func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var wg sync.WaitGroup
var err error
pr, pw := io.Pipe()
defer func() {
_ = pr.Close()
_ = pw.Close()
}()
var pr2 io.ReadCloser
var pw2 io.WriteCloser
var sanitizerDisabled bool
if r, ok := renderer.(ExternalRenderer); ok {
sanitizerDisabled = r.SanitizerDisabled()
}
if !sanitizerDisabled {
pr2, pw2 = io.Pipe()
defer func() {
_ = pr2.Close()
_ = pw2.Close()
}()
wg.Add(1)
go func() {
err = SanitizeReader(pr2, renderer.Name(), output)
_ = pr2.Close()
wg.Done()
}()
} else {
pw2 = util.NopCloser{Writer: output}
}
wg.Add(1)
go func() {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
err = PostProcess(ctx, pr, pw2)
} else {
_, err = io.Copy(pw2, pr)
}
_ = pr.Close()
_ = pw2.Close()
wg.Done()
}()
if err1 := renderer.Render(ctx, input, pw); err1 != nil {
return err1
}
_ = pw.Close()
wg.Wait()
return err
}
func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error {
if renderer, ok := renderers[ctx.Type]; ok {
return render(ctx, renderer, input, output)
}
return fmt.Errorf("unsupported render type: %s", ctx.Type)
}
// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render
type ErrUnsupportedRenderExtension struct {
Extension string
}
func IsErrUnsupportedRenderExtension(err error) bool {
_, ok := err.(ErrUnsupportedRenderExtension)
return ok
}
func (err ErrUnsupportedRenderExtension) Error() string {
return fmt.Sprintf("Unsupported render extension: %s", err.Extension)
}
func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
extension := strings.ToLower(filepath.Ext(ctx.RelativePath))
if renderer, ok := extRenderers[extension]; ok {
if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
if !ctx.InStandalonePage {
// for an external render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
}
return render(ctx, renderer, input, output)
}
return ErrUnsupportedRenderExtension{extension}
}
// Init initializes the render global variables
func Init(ph *ProcessorHelper) {
if ph != nil {
DefaultProcessorHelper = *ph
}
if len(setting.Markdown.CustomURLSchemes) > 0 {
CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
}
// since setting maybe changed extensions, this will reload all renderer extensions mapping
extRenderers = make(map[string]Renderer)
for _, renderer := range renderers {
for _, ext := range renderer.Extensions() {
extRenderers[strings.ToLower(ext)] = renderer
}
}
}

View File

@ -0,0 +1,21 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"context"
"html/template"
)
// ProcessorHelper is a helper for the rendering processors (it could be renamed to RenderHelper in the future).
// The main purpose of this helper is to decouple some functions which are not directly available in this package.
type ProcessorHelper struct {
IsUsernameMentionable func(ctx context.Context, username string) bool
ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
}
var DefaultProcessorHelper ProcessorHelper

View File

@ -0,0 +1,56 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package markup
import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
)
type Links struct {
AbsolutePrefix bool // add absolute URL prefix to auto-resolved links like "#issue", but not for pre-provided links and medias
Base string // base prefix for pre-provided links and medias (images, videos)
BranchPath string // actually it is the ref path, eg: "branch/features/feat-12", "tag/v1.0"
TreePath string // the dir of the file, eg: "doc" if the file "doc/CHANGE.md" is being rendered
}
func (l *Links) Prefix() string {
if l.AbsolutePrefix {
return setting.AppURL
}
return setting.AppSubURL
}
func (l *Links) HasBranchInfo() bool {
return l.BranchPath != ""
}
func (l *Links) SrcLink() string {
return util.URLJoin(l.Base, "src", l.BranchPath, l.TreePath)
}
func (l *Links) MediaLink() string {
return util.URLJoin(l.Base, "media", l.BranchPath, l.TreePath)
}
func (l *Links) RawLink() string {
return util.URLJoin(l.Base, "raw", l.BranchPath, l.TreePath)
}
func (l *Links) WikiLink() string {
return util.URLJoin(l.Base, "wiki")
}
func (l *Links) WikiRawLink() string {
return util.URLJoin(l.Base, "wiki/raw")
}
func (l *Links) ResolveMediaLink(isWiki bool) string {
if isWiki {
return l.WikiRawLink()
} else if l.HasBranchInfo() {
return l.MediaLink()
}
return l.Base
}

View File

@ -5,161 +5,13 @@ package markup
import ( import (
"bytes" "bytes"
"context"
"errors"
"fmt"
"html/template"
"io" "io"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"github.com/yuin/goldmark/ast"
) )
type RenderMetaMode string
const (
RenderMetaAsDetails RenderMetaMode = "details" // default
RenderMetaAsNone RenderMetaMode = "none"
RenderMetaAsTable RenderMetaMode = "table"
)
type ProcessorHelper struct {
IsUsernameMentionable func(ctx context.Context, username string) bool
ElementDir string // the direction of the elements, eg: "ltr", "rtl", "auto", default to no direction attribute
RenderRepoFileCodePreview func(ctx context.Context, options RenderCodePreviewOptions) (template.HTML, error)
}
var DefaultProcessorHelper ProcessorHelper
// Init initialize regexps for markdown parsing
func Init(ph *ProcessorHelper) {
if ph != nil {
DefaultProcessorHelper = *ph
}
if len(setting.Markdown.CustomURLSchemes) > 0 {
CustomLinkURLSchemes(setting.Markdown.CustomURLSchemes)
}
// since setting maybe changed extensions, this will reload all renderer extensions mapping
extRenderers = make(map[string]Renderer)
for _, renderer := range renderers {
for _, ext := range renderer.Extensions() {
extRenderers[strings.ToLower(ext)] = renderer
}
}
}
// Header holds the data about a header.
type Header struct {
Level int
Text string
ID string
}
// RenderContext represents a render context
type RenderContext struct {
Ctx context.Context
RelativePath string // relative path from tree root of the branch
Type string
IsWiki bool
Links Links
Metas map[string]string // user, repo, mode(comment/document)
DefaultLink string
GitRepo *git.Repository
Repo gitrepo.Repository
ShaExistCache map[string]bool
cancelFn func()
SidebarTocNode ast.Node
RenderMetaAs RenderMetaMode
InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
}
type Links struct {
AbsolutePrefix bool // add absolute URL prefix to auto-resolved links like "#issue", but not for pre-provided links and medias
Base string // base prefix for pre-provided links and medias (images, videos)
BranchPath string // actually it is the ref path, eg: "branch/features/feat-12", "tag/v1.0"
TreePath string // the dir of the file, eg: "doc" if the file "doc/CHANGE.md" is being rendered
}
func (l *Links) Prefix() string {
if l.AbsolutePrefix {
return setting.AppURL
}
return setting.AppSubURL
}
func (l *Links) HasBranchInfo() bool {
return l.BranchPath != ""
}
func (l *Links) SrcLink() string {
return util.URLJoin(l.Base, "src", l.BranchPath, l.TreePath)
}
func (l *Links) MediaLink() string {
return util.URLJoin(l.Base, "media", l.BranchPath, l.TreePath)
}
func (l *Links) RawLink() string {
return util.URLJoin(l.Base, "raw", l.BranchPath, l.TreePath)
}
func (l *Links) WikiLink() string {
return util.URLJoin(l.Base, "wiki")
}
func (l *Links) WikiRawLink() string {
return util.URLJoin(l.Base, "wiki/raw")
}
func (l *Links) ResolveMediaLink(isWiki bool) string {
if isWiki {
return l.WikiRawLink()
} else if l.HasBranchInfo() {
return l.MediaLink()
}
return l.Base
}
// Cancel runs any cleanup functions that have been registered for this Ctx
func (ctx *RenderContext) Cancel() {
if ctx == nil {
return
}
ctx.ShaExistCache = map[string]bool{}
if ctx.cancelFn == nil {
return
}
ctx.cancelFn()
}
// AddCancel adds the provided fn as a Cleanup for this Ctx
func (ctx *RenderContext) AddCancel(fn func()) {
if ctx == nil {
return
}
oldCancelFn := ctx.cancelFn
if oldCancelFn == nil {
ctx.cancelFn = fn
return
}
ctx.cancelFn = func() {
defer oldCancelFn()
fn()
}
}
// Renderer defines an interface for rendering markup file to HTML // Renderer defines an interface for rendering markup file to HTML
type Renderer interface { type Renderer interface {
Name() string // markup format name Name() string // markup format name
@ -173,7 +25,7 @@ type PostProcessRenderer interface {
NeedPostProcess() bool NeedPostProcess() bool
} }
// PostProcessRenderer defines an interface for external renderers // ExternalRenderer defines an interface for external renderers
type ExternalRenderer interface { type ExternalRenderer interface {
// SanitizerDisabled disabled sanitize if return true // SanitizerDisabled disabled sanitize if return true
SanitizerDisabled() bool SanitizerDisabled() bool
@ -207,11 +59,6 @@ func GetRendererByFileName(filename string) Renderer {
return extRenderers[extension] return extRenderers[extension]
} }
// GetRendererByType returns a renderer according type
func GetRendererByType(tp string) Renderer {
return renderers[tp]
}
// DetectRendererType detects the markup type of the content // DetectRendererType detects the markup type of the content
func DetectRendererType(filename string, input io.Reader) string { func DetectRendererType(filename string, input io.Reader) string {
buf, err := io.ReadAll(input) buf, err := io.ReadAll(input)
@ -226,152 +73,6 @@ func DetectRendererType(filename string, input io.Reader) string {
return "" return ""
} }
// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
if ctx.Type != "" {
return renderByType(ctx, input, output)
} else if ctx.RelativePath != "" {
return renderFile(ctx, input, output)
}
return errors.New("Render options both filename and type missing")
}
// RenderString renders Markup string to HTML with all specific handling stuff and return string
func RenderString(ctx *RenderContext, content string) (string, error) {
var buf strings.Builder
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
return "", err
}
return buf.String(), nil
}
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error { return nil }
func renderIFrame(ctx *RenderContext, output io.Writer) error {
// set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
// at the moment, only "allow-scripts" is allowed for sandbox mode.
// "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token
// TODO: when using dark theme, if the rendered content doesn't have proper style, the default text color is black, which is not easy to read
_, err := io.WriteString(output, fmt.Sprintf(`
<iframe src="%s/%s/%s/render/%s/%s"
name="giteaExternalRender"
onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
sandbox="allow-scripts"
></iframe>`,
setting.AppSubURL,
url.PathEscape(ctx.Metas["user"]),
url.PathEscape(ctx.Metas["repo"]),
ctx.Metas["BranchNameSubURL"],
url.PathEscape(ctx.RelativePath),
))
return err
}
func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var wg sync.WaitGroup
var err error
pr, pw := io.Pipe()
defer func() {
_ = pr.Close()
_ = pw.Close()
}()
var pr2 io.ReadCloser
var pw2 io.WriteCloser
var sanitizerDisabled bool
if r, ok := renderer.(ExternalRenderer); ok {
sanitizerDisabled = r.SanitizerDisabled()
}
if !sanitizerDisabled {
pr2, pw2 = io.Pipe()
defer func() {
_ = pr2.Close()
_ = pw2.Close()
}()
wg.Add(1)
go func() {
err = SanitizeReader(pr2, renderer.Name(), output)
_ = pr2.Close()
wg.Done()
}()
} else {
pw2 = nopCloser{output}
}
wg.Add(1)
go func() {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
err = PostProcess(ctx, pr, pw2)
} else {
_, err = io.Copy(pw2, pr)
}
_ = pr.Close()
_ = pw2.Close()
wg.Done()
}()
if err1 := renderer.Render(ctx, input, pw); err1 != nil {
return err1
}
_ = pw.Close()
wg.Wait()
return err
}
// ErrUnsupportedRenderType represents
type ErrUnsupportedRenderType struct {
Type string
}
func (err ErrUnsupportedRenderType) Error() string {
return fmt.Sprintf("Unsupported render type: %s", err.Type)
}
func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error {
if renderer, ok := renderers[ctx.Type]; ok {
return render(ctx, renderer, input, output)
}
return ErrUnsupportedRenderType{ctx.Type}
}
// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render
type ErrUnsupportedRenderExtension struct {
Extension string
}
func IsErrUnsupportedRenderExtension(err error) bool {
_, ok := err.(ErrUnsupportedRenderExtension)
return ok
}
func (err ErrUnsupportedRenderExtension) Error() string {
return fmt.Sprintf("Unsupported render extension: %s", err.Extension)
}
func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
extension := strings.ToLower(filepath.Ext(ctx.RelativePath))
if renderer, ok := extRenderers[extension]; ok {
if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
if !ctx.InStandalonePage {
// for an external render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
}
return render(ctx, renderer, input, output)
}
return ErrUnsupportedRenderExtension{extension}
}
// DetectMarkupTypeByFileName returns the possible markup format type via the filename // DetectMarkupTypeByFileName returns the possible markup format type via the filename
func DetectMarkupTypeByFileName(filename string) string { func DetectMarkupTypeByFileName(filename string) string {
if parser := GetRendererByFileName(filename); parser != nil { if parser := GetRendererByFileName(filename); parser != nil {

View File

@ -10,6 +10,7 @@ import (
"io" "io"
"testing" "testing"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/zstd" "code.gitea.io/gitea/modules/zstd"
"github.com/blakesmith/ar" "github.com/blakesmith/ar"
@ -77,7 +78,7 @@ func TestParsePackage(t *testing.T) {
{ {
Extension: "", Extension: "",
WriterFactory: func(w io.Writer) io.WriteCloser { WriterFactory: func(w io.Writer) io.WriteCloser {
return nopCloser{w} return util.NopCloser{Writer: w}
}, },
}, },
{ {
@ -129,14 +130,6 @@ func TestParsePackage(t *testing.T) {
}) })
} }
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error {
return nil
}
func TestParseControlFile(t *testing.T) { func TestParseControlFile(t *testing.T) {
buildContent := func(name, version, architecture string) *bytes.Buffer { buildContent := func(name, version, architecture string) *bytes.Buffer {
var buf bytes.Buffer var buf bytes.Buffer

View File

@ -181,11 +181,12 @@ func StoreMissingLfsObjectsInRepository(ctx context.Context, repo *repo_model.Re
downloadObjects := func(pointers []lfs.Pointer) error { downloadObjects := func(pointers []lfs.Pointer) error {
err := lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error { err := lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
if errors.Is(objectError, lfs.ErrObjectNotExist) {
log.Warn("Ignoring missing upstream LFS object %-v: %v", p, objectError)
return nil
}
if objectError != nil { if objectError != nil {
if errors.Is(objectError, lfs.ErrObjectNotExist) {
log.Warn("Repo[%-v]: Ignore missing LFS object %-v: %v", repo, p, objectError)
return nil
}
return objectError return objectError
} }

View File

@ -28,7 +28,8 @@ var LFS = struct {
// LFSClient represents configuration for Gitea's LFS clients, for example: mirroring upstream Git LFS // LFSClient represents configuration for Gitea's LFS clients, for example: mirroring upstream Git LFS
var LFSClient = struct { var LFSClient = struct {
BatchSize int `ini:"BATCH_SIZE"` BatchSize int `ini:"BATCH_SIZE"`
BatchOperationConcurrency int `ini:"BATCH_OPERATION_CONCURRENCY"`
}{} }{}
func loadLFSFrom(rootCfg ConfigProvider) error { func loadLFSFrom(rootCfg ConfigProvider) error {
@ -66,6 +67,11 @@ func loadLFSFrom(rootCfg ConfigProvider) error {
LFSClient.BatchSize = 20 LFSClient.BatchSize = 20
} }
if LFSClient.BatchOperationConcurrency < 1 {
// match the default git-lfs's `lfs.concurrenttransfers`
LFSClient.BatchOperationConcurrency = 3
}
LFS.HTTPAuthExpiry = sec.Key("LFS_HTTP_AUTH_EXPIRY").MustDuration(24 * time.Hour) LFS.HTTPAuthExpiry = sec.Key("LFS_HTTP_AUTH_EXPIRY").MustDuration(24 * time.Hour)
if !LFS.StartServer || !InstallLock { if !LFS.StartServer || !InstallLock {

View File

@ -114,4 +114,17 @@ BATCH_SIZE = 0
assert.NoError(t, loadLFSFrom(cfg)) assert.NoError(t, loadLFSFrom(cfg))
assert.EqualValues(t, 100, LFS.MaxBatchSize) assert.EqualValues(t, 100, LFS.MaxBatchSize)
assert.EqualValues(t, 20, LFSClient.BatchSize) assert.EqualValues(t, 20, LFSClient.BatchSize)
assert.EqualValues(t, 3, LFSClient.BatchOperationConcurrency)
iniStr = `
[lfs_client]
BATCH_SIZE = 50
BATCH_OPERATION_CONCURRENCY = 10
`
cfg, err = NewConfigProviderFromData(iniStr)
assert.NoError(t, err)
assert.NoError(t, loadLFSFrom(cfg))
assert.EqualValues(t, 50, LFSClient.BatchSize)
assert.EqualValues(t, 10, LFSClient.BatchOperationConcurrency)
} }

View File

@ -20,7 +20,6 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/templates/eval" "code.gitea.io/gitea/modules/templates/eval"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/gitdiff" "code.gitea.io/gitea/services/gitdiff"
"code.gitea.io/gitea/services/webtheme" "code.gitea.io/gitea/services/webtheme"
@ -67,16 +66,18 @@ func NewFuncMap() template.FuncMap {
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// time / number / format // time / number / format
"FileSize": base.FileSize, "FileSize": base.FileSize,
"CountFmt": base.FormatNumberSI, "CountFmt": base.FormatNumberSI,
"TimeSince": timeutil.TimeSince, "Sec2Time": util.SecToTime,
"TimeSinceUnix": timeutil.TimeSinceUnix,
"DateTime": dateTimeLegacy, // for backward compatibility only, do not use it anymore
"Sec2Time": util.SecToTime,
"LoadTimes": func(startTime time.Time) string { "LoadTimes": func(startTime time.Time) string {
return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms" return fmt.Sprint(time.Since(startTime).Nanoseconds()/1e6) + "ms"
}, },
// for backward compatibility only, do not use them anymore
"TimeSince": timeSinceLegacy,
"TimeSinceUnix": timeSinceLegacy,
"DateTime": dateTimeLegacy,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// setting // setting
"AppName": func() string { "AppName": func() string {

View File

@ -4,35 +4,40 @@
package templates package templates
import ( import (
"context" "fmt"
"html"
"html/template" "html/template"
"strings"
"time" "time"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/translation"
) )
type DateUtils struct { type DateUtils struct{}
ctx context.Context
}
func NewDateUtils(ctx context.Context) *DateUtils { func NewDateUtils() *DateUtils {
return &DateUtils{ctx} return (*DateUtils)(nil) // the util is stateless, and we do not need to create an instance
} }
// AbsoluteShort renders in "Jan 01, 2006" format // AbsoluteShort renders in "Jan 01, 2006" format
func (du *DateUtils) AbsoluteShort(time any) template.HTML { func (du *DateUtils) AbsoluteShort(time any) template.HTML {
return timeutil.DateTime("short", time) return dateTimeFormat("short", time)
} }
// AbsoluteLong renders in "January 01, 2006" format // AbsoluteLong renders in "January 01, 2006" format
func (du *DateUtils) AbsoluteLong(time any) template.HTML { func (du *DateUtils) AbsoluteLong(time any) template.HTML {
return timeutil.DateTime("short", time) return dateTimeFormat("short", time)
} }
// FullTime renders in "Jan 01, 2006 20:33:44" format // FullTime renders in "Jan 01, 2006 20:33:44" format
func (du *DateUtils) FullTime(time any) template.HTML { func (du *DateUtils) FullTime(time any) template.HTML {
return timeutil.DateTime("full", time) return dateTimeFormat("full", time)
}
func (du *DateUtils) TimeSince(time any) template.HTML {
return TimeSince(time)
} }
// ParseLegacy parses the datetime in legacy format, eg: "2016-01-02" in server's timezone. // ParseLegacy parses the datetime in legacy format, eg: "2016-01-02" in server's timezone.
@ -56,5 +61,91 @@ func dateTimeLegacy(format string, datetime any, _ ...string) template.HTML {
if s, ok := datetime.(string); ok { if s, ok := datetime.(string); ok {
datetime = parseLegacy(s) datetime = parseLegacy(s)
} }
return timeutil.DateTime(format, datetime) return dateTimeFormat(format, datetime)
}
func timeSinceLegacy(time any, _ translation.Locale) template.HTML {
if !setting.IsProd || setting.IsInTesting {
panic("timeSinceLegacy is for backward compatibility only, do not use it in new code")
}
return TimeSince(time)
}
func anyToTime(any any) (t time.Time, isZero bool) {
switch v := any.(type) {
case nil:
// it is zero
case *time.Time:
if v != nil {
t = *v
}
case time.Time:
t = v
case timeutil.TimeStamp:
t = v.AsTime()
case timeutil.TimeStampNano:
t = v.AsTime()
case int:
t = timeutil.TimeStamp(v).AsTime()
case int64:
t = timeutil.TimeStamp(v).AsTime()
default:
panic(fmt.Sprintf("Unsupported time type %T", any))
}
return t, t.IsZero() || t.Unix() == 0
}
func dateTimeFormat(format string, datetime any) template.HTML {
t, isZero := anyToTime(datetime)
if isZero {
return "-"
}
var textEscaped string
datetimeEscaped := html.EscapeString(t.Format(time.RFC3339))
if format == "full" {
textEscaped = html.EscapeString(t.Format("2006-01-02 15:04:05 -07:00"))
} else {
textEscaped = html.EscapeString(t.Format("2006-01-02"))
}
attrs := []string{`weekday=""`, `year="numeric"`}
switch format {
case "short", "long": // date only
attrs = append(attrs, `month="`+format+`"`, `day="numeric"`)
return template.HTML(fmt.Sprintf(`<absolute-date %s date="%s">%s</absolute-date>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
case "full": // full date including time
attrs = append(attrs, `format="datetime"`, `month="short"`, `day="numeric"`, `hour="numeric"`, `minute="numeric"`, `second="numeric"`, `data-tooltip-content`, `data-tooltip-interactive="true"`)
return template.HTML(fmt.Sprintf(`<relative-time %s datetime="%s">%s</relative-time>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
default:
panic(fmt.Sprintf("Unsupported format %s", format))
}
}
func timeSinceTo(then any, now time.Time) template.HTML {
thenTime, isZero := anyToTime(then)
if isZero {
return "-"
}
friendlyText := thenTime.Format("2006-01-02 15:04:05 -07:00")
// document: https://github.com/github/relative-time-element
attrs := `tense="past"`
isFuture := now.Before(thenTime)
if isFuture {
attrs = `tense="future"`
}
// declare data-tooltip-content attribute to switch from "title" tooltip to "tippy" tooltip
htm := fmt.Sprintf(`<relative-time prefix="" %s datetime="%s" data-tooltip-content data-tooltip-interactive="true">%s</relative-time>`,
attrs, thenTime.Format(time.RFC3339), friendlyText)
return template.HTML(htm)
}
// TimeSince renders relative time HTML given a time
func TimeSince(then any) template.HTML {
if setting.UI.PreferredTimestampTense == "absolute" {
return dateTimeFormat("full", then)
}
return timeSinceTo(then, time.Now())
} }

View File

@ -19,7 +19,7 @@ func TestDateTime(t *testing.T) {
defer test.MockVariableValue(&setting.DefaultUILocation, testTz)() defer test.MockVariableValue(&setting.DefaultUILocation, testTz)()
defer test.MockVariableValue(&setting.IsInTesting, false)() defer test.MockVariableValue(&setting.IsInTesting, false)()
du := NewDateUtils(nil) du := NewDateUtils()
refTimeStr := "2018-01-01T00:00:00Z" refTimeStr := "2018-01-01T00:00:00Z"
refDateStr := "2018-01-01" refDateStr := "2018-01-01"
@ -49,3 +49,24 @@ func TestDateTime(t *testing.T) {
actual = du.FullTime(refTimeStamp) actual = du.FullTime(refTimeStamp)
assert.EqualValues(t, `<relative-time weekday="" year="numeric" format="datetime" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" data-tooltip-content data-tooltip-interactive="true" datetime="2017-12-31T19:00:00-05:00">2017-12-31 19:00:00 -05:00</relative-time>`, actual) assert.EqualValues(t, `<relative-time weekday="" year="numeric" format="datetime" month="short" day="numeric" hour="numeric" minute="numeric" second="numeric" data-tooltip-content data-tooltip-interactive="true" datetime="2017-12-31T19:00:00-05:00">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
} }
func TestTimeSince(t *testing.T) {
testTz, _ := time.LoadLocation("America/New_York")
defer test.MockVariableValue(&setting.DefaultUILocation, testTz)()
defer test.MockVariableValue(&setting.IsInTesting, false)()
du := NewDateUtils()
assert.EqualValues(t, "-", du.TimeSince(nil))
refTimeStr := "2018-01-01T00:00:00Z"
refTime, _ := time.Parse(time.RFC3339, refTimeStr)
actual := du.TimeSince(refTime)
assert.EqualValues(t, `<relative-time prefix="" tense="past" datetime="2018-01-01T00:00:00Z" data-tooltip-content data-tooltip-interactive="true">2018-01-01 00:00:00 +00:00</relative-time>`, actual)
actual = timeSinceTo(&refTime, time.Time{})
assert.EqualValues(t, `<relative-time prefix="" tense="future" datetime="2018-01-01T00:00:00Z" data-tooltip-content data-tooltip-interactive="true">2018-01-01 00:00:00 +00:00</relative-time>`, actual)
actual = timeSinceLegacy(timeutil.TimeStampNano(refTime.UnixNano()), nil)
assert.EqualValues(t, `<relative-time prefix="" tense="past" datetime="2017-12-31T19:00:00-05:00" data-tooltip-content data-tooltip-interactive="true">2017-12-31 19:00:00 -05:00</relative-time>`, actual)
}

View File

@ -1,60 +0,0 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package timeutil
import (
"fmt"
"html"
"html/template"
"strings"
"time"
)
// DateTime renders an absolute time HTML element by datetime.
func DateTime(format string, datetime any) template.HTML {
if p, ok := datetime.(*time.Time); ok {
datetime = *p
}
if p, ok := datetime.(*TimeStamp); ok {
datetime = *p
}
switch v := datetime.(type) {
case TimeStamp:
datetime = v.AsTime()
case int:
datetime = TimeStamp(v).AsTime()
case int64:
datetime = TimeStamp(v).AsTime()
}
var datetimeEscaped, textEscaped string
switch v := datetime.(type) {
case nil:
return "-"
case time.Time:
if v.IsZero() || v.Unix() == 0 {
return "-"
}
datetimeEscaped = html.EscapeString(v.Format(time.RFC3339))
if format == "full" {
textEscaped = html.EscapeString(v.Format("2006-01-02 15:04:05 -07:00"))
} else {
textEscaped = html.EscapeString(v.Format("2006-01-02"))
}
default:
panic(fmt.Sprintf("Unsupported time type %T", datetime))
}
attrs := []string{`weekday=""`, `year="numeric"`}
switch format {
case "short", "long": // date only
attrs = append(attrs, `month="`+format+`"`, `day="numeric"`)
return template.HTML(fmt.Sprintf(`<absolute-date %s date="%s">%s</absolute-date>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
case "full": // full date including time
attrs = append(attrs, `format="datetime"`, `month="short"`, `day="numeric"`, `hour="numeric"`, `minute="numeric"`, `second="numeric"`, `data-tooltip-content`, `data-tooltip-interactive="true"`)
return template.HTML(fmt.Sprintf(`<relative-time %s datetime="%s">%s</relative-time>`, strings.Join(attrs, " "), datetimeEscaped, textEscaped))
default:
panic(fmt.Sprintf("Unsupported format %s", format))
}
}

View File

@ -4,12 +4,9 @@
package timeutil package timeutil
import ( import (
"fmt"
"html/template"
"strings" "strings"
"time" "time"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
) )
@ -81,16 +78,11 @@ func computeTimeDiffFloor(diff int64, lang translation.Locale) (int64, string) {
return diff, diffStr return diff, diffStr
} }
// MinutesToFriendly returns a user friendly string with number of minutes // MinutesToFriendly returns a user-friendly string with number of minutes
// converted to hours and minutes. // converted to hours and minutes.
func MinutesToFriendly(minutes int, lang translation.Locale) string { func MinutesToFriendly(minutes int, lang translation.Locale) string {
duration := time.Duration(minutes) * time.Minute duration := time.Duration(minutes) * time.Minute
return TimeSincePro(time.Now().Add(-duration), lang) return timeSincePro(time.Now().Add(-duration), time.Now(), lang)
}
// TimeSincePro calculates the time interval and generate full user-friendly string.
func TimeSincePro(then time.Time, lang translation.Locale) string {
return timeSincePro(then, time.Now(), lang)
} }
func timeSincePro(then, now time.Time, lang translation.Locale) string { func timeSincePro(then, now time.Time, lang translation.Locale) string {
@ -114,32 +106,3 @@ func timeSincePro(then, now time.Time, lang translation.Locale) string {
} }
return strings.TrimPrefix(timeStr, ", ") return strings.TrimPrefix(timeStr, ", ")
} }
func timeSinceUnix(then, now time.Time, _ translation.Locale) template.HTML {
friendlyText := then.Format("2006-01-02 15:04:05 -07:00")
// document: https://github.com/github/relative-time-element
attrs := `tense="past"`
isFuture := now.Before(then)
if isFuture {
attrs = `tense="future"`
}
// declare data-tooltip-content attribute to switch from "title" tooltip to "tippy" tooltip
htm := fmt.Sprintf(`<relative-time prefix="" %s datetime="%s" data-tooltip-content data-tooltip-interactive="true">%s</relative-time>`,
attrs, then.Format(time.RFC3339), friendlyText)
return template.HTML(htm)
}
// TimeSince renders relative time HTML given a time.Time
func TimeSince(then time.Time, lang translation.Locale) template.HTML {
if setting.UI.PreferredTimestampTense == "absolute" {
return DateTime("full", then)
}
return timeSinceUnix(then, time.Now(), lang)
}
// TimeSinceUnix renders relative time HTML given a TimeStamp
func TimeSinceUnix(then TimeStamp, lang translation.Locale) template.HTML {
return TimeSince(then.AsLocalTime(), lang)
}

View File

@ -9,6 +9,12 @@ import (
"io" "io"
) )
type NopCloser struct {
io.Writer
}
func (NopCloser) Close() error { return nil }
// ReadAtMost reads at most len(buf) bytes from r into buf. // ReadAtMost reads at most len(buf) bytes from r into buf.
// It returns the number of bytes copied. n is only less than len(buf) if r provides fewer bytes. // It returns the number of bytes copied. n is only less than len(buf) if r provides fewer bytes.
// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil. // If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.

View File

@ -209,6 +209,10 @@ buttons.link.tooltip = Add a link
buttons.list.unordered.tooltip = Add a bullet list buttons.list.unordered.tooltip = Add a bullet list
buttons.list.ordered.tooltip = Add a numbered list buttons.list.ordered.tooltip = Add a numbered list
buttons.list.task.tooltip = Add a list of tasks buttons.list.task.tooltip = Add a list of tasks
buttons.table.add.tooltip = Add a table
buttons.table.add.insert = Add
buttons.table.rows = Rows
buttons.table.cols = Columns
buttons.mention.tooltip = Mention a user or team buttons.mention.tooltip = Mention a user or team
buttons.ref.tooltip = Reference an issue or pull request buttons.ref.tooltip = Reference an issue or pull request
buttons.switch_to_legacy.tooltip = Use the legacy editor instead buttons.switch_to_legacy.tooltip = Use the legacy editor instead

View File

@ -18,7 +18,6 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
files_service "code.gitea.io/gitea/services/repository/files" files_service "code.gitea.io/gitea/services/repository/files"
@ -280,7 +279,7 @@ func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames
commitCnt++ commitCnt++
// User avatar image // User avatar image
commitSince := timeutil.TimeSinceUnix(timeutil.TimeStamp(commit.Author.When.Unix()), ctx.Locale) commitSince := templates.TimeSince(commit.Author.When)
var avatar string var avatar string
if commit.User != nil { if commit.User != nil {

View File

@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"github.com/sergi/go-diff/diffmatchpatch" "github.com/sergi/go-diff/diffmatchpatch"
@ -73,10 +72,10 @@ func GetContentHistoryList(ctx *context.Context) {
class := avatars.DefaultAvatarClass + " tw-mr-2" class := avatars.DefaultAvatarClass + " tw-mr-2"
name := html.EscapeString(username) name := html.EscapeString(username)
avatarHTML := string(templates.AvatarHTML(src, 28, class, username)) avatarHTML := string(templates.AvatarHTML(src, 28, class, username))
timeSinceText := string(timeutil.TimeSinceUnix(item.EditedUnix, ctx.Locale)) timeSinceHTML := string(templates.TimeSince(item.EditedUnix))
results = append(results, map[string]any{ results = append(results, map[string]any{
"name": avatarHTML + "<strong>" + name + "</strong> " + actionText + " " + timeSinceText, "name": avatarHTML + "<strong>" + name + "</strong> " + actionText + " " + timeSinceHTML,
"value": item.HistoryID, "value": item.HistoryID,
}) })
} }

View File

@ -100,7 +100,6 @@ func NewTemplateContextForWeb(ctx *Context) TemplateContext {
tmplCtx := NewTemplateContext(ctx) tmplCtx := NewTemplateContext(ctx)
tmplCtx["Locale"] = ctx.Base.Locale tmplCtx["Locale"] = ctx.Base.Locale
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
tmplCtx["DateUtils"] = templates.NewDateUtils(ctx)
tmplCtx["RootData"] = ctx.Data tmplCtx["RootData"] = ctx.Data
tmplCtx["Consts"] = map[string]any{ tmplCtx["Consts"] = map[string]any{
"RepoUnitTypeCode": unit.TypeCode, "RepoUnitTypeCode": unit.TypeCode,

View File

@ -26,8 +26,8 @@
<td><a href="{{AppSubUrl}}/-/admin/auths/{{.ID}}">{{.Name}}</a></td> <td><a href="{{AppSubUrl}}/-/admin/auths/{{.ID}}">{{.Name}}</a></td>
<td>{{.TypeName}}</td> <td>{{.TypeName}}</td>
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .UpdatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .UpdatedUnix}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td><a href="{{AppSubUrl}}/-/admin/auths/{{.ID}}">{{svg "octicon-pencil"}}</a></td> <td><a href="{{AppSubUrl}}/-/admin/auths/{{.ID}}">{{svg "octicon-pencil"}}</a></td>
</tr> </tr>
{{end}} {{end}}

View File

@ -23,8 +23,8 @@
<td><button type="submit" class="ui primary button" name="op" value="{{.Name}}" title="{{ctx.Locale.Tr "admin.dashboard.operation_run"}}">{{svg "octicon-triangle-right"}}</button></td> <td><button type="submit" class="ui primary button" name="op" value="{{.Name}}" title="{{ctx.Locale.Tr "admin.dashboard.operation_run"}}">{{svg "octicon-triangle-right"}}</button></td>
<td>{{ctx.Locale.Tr (printf "admin.dashboard.%s" .Name)}}</td> <td>{{ctx.Locale.Tr (printf "admin.dashboard.%s" .Name)}}</td>
<td>{{.Spec}}</td> <td>{{.Spec}}</td>
<td>{{ctx.DateUtils.FullTime .Next}}</td> <td>{{DateUtils.FullTime .Next}}</td>
<td>{{if gt .Prev.Year 1}}{{ctx.DateUtils.FullTime .Prev}}{{else}}-{{end}}</td> <td>{{if gt .Prev.Year 1}}{{DateUtils.FullTime .Prev}}{{else}}-{{end}}</td>
<td>{{.ExecTimes}}</td> <td>{{.ExecTimes}}</td>
<td {{if ne .Status ""}}data-tooltip-content="{{.FormatLastMessage ctx.Locale}}"{{end}} >{{if eq .Status ""}}{{else}}{{svg (Iif (eq .Status "finished") "octicon-check" "octicon-x") 16}}{{end}}</td> <td {{if ne .Status ""}}data-tooltip-content="{{.FormatLastMessage ctx.Locale}}"{{end}} >{{if eq .Status ""}}{{else}}{{svg (Iif (eq .Status "finished") "octicon-check" "octicon-x") 16}}{{end}}</td>
</tr> </tr>

View File

@ -21,7 +21,7 @@
<td>{{.ID}}</td> <td>{{.ID}}</td>
<td>{{ctx.Locale.Tr .TrStr}}</td> <td>{{ctx.Locale.Tr .TrStr}}</td>
<td class="view-detail auto-ellipsis tw-w-4/5"><span class="notice-description">{{.Description}}</span></td> <td class="view-detail auto-ellipsis tw-w-4/5"><span class="notice-description">{{.Description}}</span></td>
<td nowrap>{{ctx.DateUtils.AbsoluteShort .CreatedUnix}}</td> <td nowrap>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td class="view-detail"><a href="#">{{svg "octicon-note" 16}}</a></td> <td class="view-detail"><a href="#">{{svg "octicon-note" 16}}</a></td>
</tr> </tr>
{{end}} {{end}}

View File

@ -63,7 +63,7 @@
<td>{{.NumTeams}}</td> <td>{{.NumTeams}}</td>
<td>{{.NumMembers}}</td> <td>{{.NumMembers}}</td>
<td>{{.NumRepos}}</td> <td>{{.NumRepos}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td><a href="{{.OrganisationLink}}/settings" data-tooltip-content="{{ctx.Locale.Tr "edit"}}">{{svg "octicon-pencil"}}</a></td> <td><a href="{{.OrganisationLink}}/settings" data-tooltip-content="{{ctx.Locale.Tr "edit"}}">{{svg "octicon-pencil"}}</a></td>
</tr> </tr>
{{end}} {{end}}

View File

@ -71,7 +71,7 @@
{{end}} {{end}}
</td> </td>
<td>{{FileSize .CalculateBlobSize}}</td> <td>{{FileSize .CalculateBlobSize}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .Version.CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
<td><a class="delete-button" href="" data-url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}" data-id="{{.Version.ID}}" data-name="{{.Package.Name}}" data-data-version="{{.Version.Version}}">{{svg "octicon-trash"}}</a></td> <td><a class="delete-button" href="" data-url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}" data-id="{{.Version.ID}}" data-name="{{.Package.Name}}" data-data-version="{{.Version.Version}}">{{svg "octicon-trash"}}</a></td>
</tr> </tr>
{{end}} {{end}}

View File

@ -82,8 +82,8 @@
<td>{{.NumIssues}}</td> <td>{{.NumIssues}}</td>
<td>{{FileSize .GitSize}}</td> <td>{{FileSize .GitSize}}</td>
<td>{{FileSize .LFSSize}}</td> <td>{{FileSize .LFSSize}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .UpdatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .UpdatedUnix}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
<td><a class="delete-button" href="" data-url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}" data-id="{{.ID}}" data-name="{{.Name}}">{{svg "octicon-trash"}}</a></td> <td><a class="delete-button" href="" data-url="{{$.Link}}/delete?page={{$.Page.Paginater.Current}}&sort={{$.SortType}}" data-id="{{.ID}}" data-name="{{.Name}}">{{svg "octicon-trash"}}</a></td>
</tr> </tr>
{{end}} {{end}}

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="content tw-flex-1"> <div class="content tw-flex-1">
<div class="header">{{.Process.Description}}</div> <div class="header">{{.Process.Description}}</div>
<div class="description">{{if ne .Process.Type "none"}}{{TimeSince .Process.Start ctx.Locale}}{{end}}</div> <div class="description">{{if ne .Process.Type "none"}}{{DateUtils.TimeSince .Process.Start}}{{end}}</div>
</div> </div>
<div> <div>
{{if or (eq .Process.Type "request") (eq .Process.Type "normal")}} {{if or (eq .Process.Type "request") (eq .Process.Type "normal")}}

View File

@ -96,9 +96,9 @@
<td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsActive "octicon-check" "octicon-x")}}</td>
<td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif .IsRestricted "octicon-check" "octicon-x")}}</td>
<td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td> <td>{{svg (Iif (index $.UsersTwoFaStatus .ID) "octicon-check" "octicon-x")}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .CreatedUnix}}</td>
{{if .LastLoginUnix}} {{if .LastLoginUnix}}
<td>{{ctx.DateUtils.AbsoluteShort .LastLoginUnix}}</td> <td>{{DateUtils.AbsoluteShort .LastLoginUnix}}</td>
{{else}} {{else}}
<td><span>{{ctx.Locale.Tr "admin.users.never_login"}}</span></td> <td><span>{{ctx.Locale.Tr "admin.users.never_login"}}</span></td>
{{end}} {{end}}

View File

@ -139,13 +139,13 @@
<div> <div>
<h1>TimeSince</h1> <h1>TimeSince</h1>
<div>Now: {{TimeSince .TimeNow ctx.Locale}}</div> <div>Now: {{DateUtils.TimeSince .TimeNow}}</div>
<div>5s past: {{TimeSince .TimePast5s ctx.Locale}}</div> <div>5s past: {{DateUtils.TimeSince .TimePast5s}}</div>
<div>5s future: {{TimeSince .TimeFuture5s ctx.Locale}}</div> <div>5s future: {{DateUtils.TimeSince .TimeFuture5s}}</div>
<div>2m past: {{TimeSince .TimePast2m ctx.Locale}}</div> <div>2m past: {{DateUtils.TimeSince .TimePast2m}}</div>
<div>2m future: {{TimeSince .TimeFuture2m ctx.Locale}}</div> <div>2m future: {{DateUtils.TimeSince .TimeFuture2m}}</div>
<div>1y past: {{TimeSince .TimePast1y ctx.Locale}}</div> <div>1y past: {{DateUtils.TimeSince .TimePast1y}}</div>
<div>1y future: {{TimeSince .TimeFuture1y ctx.Locale}}</div> <div>1y future: {{DateUtils.TimeSince .TimeFuture1y}}</div>
</div> </div>
<div> <div>

View File

@ -60,7 +60,7 @@
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
<div class="flex-item-body">{{ctx.Locale.Tr "org.repo_updated"}} {{TimeSinceUnix .UpdatedUnix ctx.Locale}}</div> <div class="flex-item-body">{{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .UpdatedUnix}}</div>
</div> </div>
</div> </div>
{{else}} {{else}}

View File

@ -21,7 +21,7 @@
<a href="mailto:{{.Email}}">{{.Email}}</a> <a href="mailto:{{.Email}}">{{.Email}}</a>
</span> </span>
{{end}} {{end}}
<span class="flex-text-inline">{{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.joined_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}}</span> <span class="flex-text-inline">{{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .CreatedUnix)}}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -22,7 +22,7 @@
<td><a href="{{.VersionWebLink}}">{{.Version.Version}}</a></td> <td><a href="{{.VersionWebLink}}">{{.Version.Version}}</a></td>
<td><a href="{{.Creator.HomeLink}}">{{.Creator.Name}}</a></td> <td><a href="{{.Creator.HomeLink}}">{{.Creator.Name}}</a></td>
<td>{{FileSize .CalculateBlobSize}}</td> <td>{{FileSize .CalculateBlobSize}}</td>
<td>{{ctx.DateUtils.AbsoluteShort .Version.CreatedUnix}}</td> <td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
</tr> </tr>
{{else}} {{else}}
<tr> <tr>

View File

@ -24,7 +24,7 @@
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span> <span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
</div> </div>
<div class="flex-item-body"> <div class="flex-item-body">
{{$timeStr := TimeSinceUnix .Version.CreatedUnix ctx.Locale}} {{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}} {{$hasRepositoryAccess := false}}
{{if .Repository}} {{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}} {{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}

View File

@ -25,7 +25,7 @@
<div class="flex-item-main"> <div class="flex-item-main">
<a class="flex-item-title" href="{{.VersionWebLink}}">{{.Version.LowerVersion}}</a> <a class="flex-item-title" href="{{.VersionWebLink}}">{{.Version.LowerVersion}}</a>
<div class="flex-item-body"> <div class="flex-item-body">
{{ctx.Locale.Tr "packages.published_by" (TimeSinceUnix .Version.CreatedUnix ctx.Locale) .Creator.HomeLink .Creator.GetDisplayName}} {{ctx.Locale.Tr "packages.published_by" (DateUtils.TimeSince .Version.CreatedUnix) .Creator.HomeLink .Creator.GetDisplayName}}
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@
<h1>{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}})</h1> <h1>{{.PackageDescriptor.Package.Name}} ({{.PackageDescriptor.Version.Version}})</h1>
</div> </div>
<div> <div>
{{$timeStr := TimeSinceUnix .PackageDescriptor.Version.CreatedUnix ctx.Locale}} {{$timeStr := DateUtils.TimeSince .PackageDescriptor.Version.CreatedUnix}}
{{if .HasRepositoryAccess}} {{if .HasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .PackageDescriptor.Creator.HomeLink .PackageDescriptor.Creator.GetDisplayName .PackageDescriptor.Repository.Link .PackageDescriptor.Repository.FullName}} {{ctx.Locale.Tr "packages.published_by_in" $timeStr .PackageDescriptor.Creator.HomeLink .PackageDescriptor.Creator.GetDisplayName .PackageDescriptor.Repository.Link .PackageDescriptor.Repository.FullName}}
{{else}} {{else}}
@ -47,7 +47,7 @@
{{if .HasRepositoryAccess}} {{if .HasRepositoryAccess}}
<div class="item">{{svg "octicon-repo" 16 "tw-mr-2"}} <a href="{{.PackageDescriptor.Repository.Link}}">{{.PackageDescriptor.Repository.FullName}}</a></div> <div class="item">{{svg "octicon-repo" 16 "tw-mr-2"}} <a href="{{.PackageDescriptor.Repository.Link}}">{{.PackageDescriptor.Repository.FullName}}</a></div>
{{end}} {{end}}
<div class="item">{{svg "octicon-calendar" 16 "tw-mr-2"}} {{TimeSinceUnix .PackageDescriptor.Version.CreatedUnix ctx.Locale}}</div> <div class="item">{{svg "octicon-calendar" 16 "tw-mr-2"}} {{DateUtils.TimeSince .PackageDescriptor.Version.CreatedUnix}}</div>
<div class="item">{{svg "octicon-download" 16 "tw-mr-2"}} {{.PackageDescriptor.Version.DownloadCount}}</div> <div class="item">{{svg "octicon-download" 16 "tw-mr-2"}} {{.PackageDescriptor.Version.DownloadCount}}</div>
{{template "package/metadata/alpine" .}} {{template "package/metadata/alpine" .}}
{{template "package/metadata/cargo" .}} {{template "package/metadata/cargo" .}}
@ -92,7 +92,7 @@
{{range .LatestVersions}} {{range .LatestVersions}}
<div class="item tw-flex"> <div class="item tw-flex">
<a class="tw-flex-1 gt-ellipsis" title="{{.Version}}" href="{{$.PackageDescriptor.PackageWebLink}}/{{PathEscape .LowerVersion}}">{{.Version}}</a> <a class="tw-flex-1 gt-ellipsis" title="{{.Version}}" href="{{$.PackageDescriptor.PackageWebLink}}/{{PathEscape .LowerVersion}}">{{.Version}}</a>
<span class="text small">{{ctx.DateUtils.AbsoluteShort .CreatedUnix}}</span> <span class="text small">{{DateUtils.AbsoluteShort .CreatedUnix}}</span>
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@ -33,7 +33,7 @@
<span class="ui label run-list-ref gt-ellipsis">{{.PrettyRef}}</span> <span class="ui label run-list-ref gt-ellipsis">{{.PrettyRef}}</span>
{{end}} {{end}}
<div class="run-list-item-right"> <div class="run-list-item-right">
<div class="run-list-meta">{{svg "octicon-calendar" 16}}{{TimeSinceUnix .Updated ctx.Locale}}</div> <div class="run-list-meta">{{svg "octicon-calendar" 16}}{{DateUtils.TimeSince .Updated}}</div>
<div class="run-list-meta">{{svg "octicon-stopwatch" 16}}{{.Duration}}</div> <div class="run-list-meta">{{svg "octicon-stopwatch" 16}}{{.Duration}}</div>
</div> </div>
</div> </div>

View File

@ -27,7 +27,7 @@
<button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DefaultBranchBranch.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button> <button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DefaultBranchBranch.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button>
{{template "repo/commit_statuses" dict "Status" (index $.CommitStatus .DefaultBranchBranch.DBBranch.CommitID) "Statuses" (index $.CommitStatuses .DefaultBranchBranch.DBBranch.CommitID)}} {{template "repo/commit_statuses" dict "Status" (index $.CommitStatus .DefaultBranchBranch.DBBranch.CommitID) "Statuses" (index $.CommitStatuses .DefaultBranchBranch.DBBranch.CommitID)}}
</div> </div>
<p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{.RepoLink}}/commit/{{PathEscape .DefaultBranchBranch.DBBranch.CommitID}}">{{ShortSha .DefaultBranchBranch.DBBranch.CommitID}}</a> · <span class="commit-message">{{RenderCommitMessage $.Context .DefaultBranchBranch.DBBranch.CommitMessage (.Repository.ComposeMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{TimeSince .DefaultBranchBranch.DBBranch.CommitTime.AsTime ctx.Locale}}{{if .DefaultBranchBranch.DBBranch.Pusher}} &nbsp;{{template "shared/user/avatarlink" dict "user" .DefaultBranchBranch.DBBranch.Pusher}}{{template "shared/user/namelink" .DefaultBranchBranch.DBBranch.Pusher}}{{end}}</p> <p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{.RepoLink}}/commit/{{PathEscape .DefaultBranchBranch.DBBranch.CommitID}}">{{ShortSha .DefaultBranchBranch.DBBranch.CommitID}}</a> · <span class="commit-message">{{RenderCommitMessage $.Context .DefaultBranchBranch.DBBranch.CommitMessage (.Repository.ComposeMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .DefaultBranchBranch.DBBranch.CommitTime}}{{if .DefaultBranchBranch.DBBranch.Pusher}} &nbsp;{{template "shared/user/avatarlink" dict "user" .DefaultBranchBranch.DBBranch.Pusher}}{{template "shared/user/namelink" .DefaultBranchBranch.DBBranch.Pusher}}{{end}}</p>
</td> </td>
<td class="right aligned middle aligned overflow-visible"> <td class="right aligned middle aligned overflow-visible">
{{if and $.IsWriter (not $.Repository.IsArchived) (not .IsDeleted)}} {{if and $.IsWriter (not $.Repository.IsArchived) (not .IsDeleted)}}
@ -92,7 +92,7 @@
<span class="gt-ellipsis">{{.DBBranch.Name}}</span> <span class="gt-ellipsis">{{.DBBranch.Name}}</span>
<button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button> <button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button>
</div> </div>
<p class="info">{{ctx.Locale.Tr "repo.branch.deleted_by" .DBBranch.DeletedBy.Name}} {{TimeSinceUnix .DBBranch.DeletedUnix ctx.Locale}}</p> <p class="info">{{ctx.Locale.Tr "repo.branch.deleted_by" .DBBranch.DeletedBy.Name}} {{DateUtils.TimeSince .DBBranch.DeletedUnix}}</p>
{{else}} {{else}}
<div class="flex-text-block"> <div class="flex-text-block">
<a class="gt-ellipsis" href="{{$.RepoLink}}/src/branch/{{PathEscapeSegments .DBBranch.Name}}">{{.DBBranch.Name}}</a> <a class="gt-ellipsis" href="{{$.RepoLink}}/src/branch/{{PathEscapeSegments .DBBranch.Name}}">{{.DBBranch.Name}}</a>
@ -102,7 +102,7 @@
<button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button> <button class="btn interact-fg tw-px-1" data-clipboard-text="{{.DBBranch.Name}}" data-tooltip-content="{{ctx.Locale.Tr "copy_branch"}}">{{svg "octicon-copy" 14}}</button>
{{template "repo/commit_statuses" dict "Status" (index $.CommitStatus .DBBranch.CommitID) "Statuses" (index $.CommitStatuses .DBBranch.CommitID)}} {{template "repo/commit_statuses" dict "Status" (index $.CommitStatus .DBBranch.CommitID) "Statuses" (index $.CommitStatuses .DBBranch.CommitID)}}
</div> </div>
<p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{$.RepoLink}}/commit/{{PathEscape .DBBranch.CommitID}}">{{ShortSha .DBBranch.CommitID}}</a> · <span class="commit-message">{{RenderCommitMessage $.Context .DBBranch.CommitMessage ($.Repository.ComposeMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{TimeSince .DBBranch.CommitTime.AsTime ctx.Locale}}{{if .DBBranch.Pusher}} &nbsp;{{template "shared/user/avatarlink" dict "user" .DBBranch.Pusher}} &nbsp;{{template "shared/user/namelink" .DBBranch.Pusher}}{{end}}</p> <p class="info tw-flex tw-items-center tw-my-1">{{svg "octicon-git-commit" 16 "tw-mr-1"}}<a href="{{$.RepoLink}}/commit/{{PathEscape .DBBranch.CommitID}}">{{ShortSha .DBBranch.CommitID}}</a> · <span class="commit-message">{{RenderCommitMessage $.Context .DBBranch.CommitMessage ($.Repository.ComposeMetas ctx)}}</span> · {{ctx.Locale.Tr "org.repo_updated"}} {{DateUtils.TimeSince .DBBranch.CommitTime}}{{if .DBBranch.Pusher}} &nbsp;{{template "shared/user/avatarlink" dict "user" .DBBranch.Pusher}} &nbsp;{{template "shared/user/namelink" .DBBranch.Pusher}}{{end}}</p>
{{end}} {{end}}
</td> </td>
<td class="two wide ui"> <td class="two wide ui">

View File

@ -1,7 +1,7 @@
{{range .RecentlyPushedNewBranches}} {{range .RecentlyPushedNewBranches}}
<div class="ui positive message tw-flex tw-items-center tw-gap-2"> <div class="ui positive message tw-flex tw-items-center tw-gap-2">
<div class="tw-flex-1 tw-break-anywhere"> <div class="tw-flex-1 tw-break-anywhere">
{{$timeSince := TimeSince .CommitTime.AsTime ctx.Locale}} {{$timeSince := DateUtils.TimeSince .CommitTime}}
{{$branchLink := HTMLFormat `<a href="%s">%s</a>` .BranchLink .BranchDisplayName}} {{$branchLink := HTMLFormat `<a href="%s">%s</a>` .BranchLink .BranchDisplayName}}
{{ctx.Locale.Tr "repo.pulls.recently_pushed_new_branches" $branchLink $timeSince}} {{ctx.Locale.Tr "repo.pulls.recently_pushed_new_branches" $branchLink $timeSince}}
</div> </div>

View File

@ -152,7 +152,7 @@
{{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 28 "tw-mr-2"}} {{ctx.AvatarUtils.AvatarByEmail .Commit.Author.Email .Commit.Author.Email 28 "tw-mr-2"}}
<strong>{{.Commit.Author.Name}}</strong> <strong>{{.Commit.Author.Name}}</strong>
{{end}} {{end}}
<span class="text grey tw-ml-2" id="authored-time">{{TimeSince .Commit.Author.When ctx.Locale}}</span> <span class="text grey tw-ml-2" id="authored-time">{{DateUtils.TimeSince .Commit.Author.When}}</span>
{{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}} {{if or (ne .Commit.Committer.Name .Commit.Author.Name) (ne .Commit.Committer.Email .Commit.Author.Email)}}
<span class="text grey tw-mx-2">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span> <span class="text grey tw-mx-2">{{ctx.Locale.Tr "repo.diff.committed_by"}}</span>
{{if ne .Verification.CommittingUser.ID 0}} {{if ne .Verification.CommittingUser.ID 0}}
@ -273,7 +273,7 @@
{{else}} {{else}}
<strong>{{.NoteCommit.Author.Name}}</strong> <strong>{{.NoteCommit.Author.Name}}</strong>
{{end}} {{end}}
<span class="text grey" id="note-authored-time">{{TimeSince .NoteCommit.Author.When ctx.Locale}}</span> <span class="text grey" id="note-authored-time">{{DateUtils.TimeSince .NoteCommit.Author.When}}</span>
</div> </div>
<div class="ui bottom attached info segment git-notes"> <div class="ui bottom attached info segment git-notes">
<pre class="commit-body">{{.NoteRendered | SanitizeHTML}}</pre> <pre class="commit-body">{{.NoteRendered | SanitizeHTML}}</pre>

View File

@ -79,9 +79,9 @@
{{end}} {{end}}
</td> </td>
{{if .Committer}} {{if .Committer}}
<td class="text right aligned">{{TimeSince .Committer.When ctx.Locale}}</td> <td class="text right aligned">{{DateUtils.TimeSince .Committer.When}}</td>
{{else}} {{else}}
<td class="text right aligned">{{TimeSince .Author.When ctx.Locale}}</td> <td class="text right aligned">{{DateUtils.TimeSince .Author.When}}</td>
{{end}} {{end}}
<td class="text right aligned tw-py-0"> <td class="text right aligned tw-py-0">
<button class="btn interact-bg tw-p-2" data-tooltip-content="{{ctx.Locale.Tr "copy_hash"}}" data-clipboard-text="{{.ID}}">{{svg "octicon-copy"}}</button> <button class="btn interact-bg tw-p-2" data-tooltip-content="{{ctx.Locale.Tr "copy_hash"}}" data-clipboard-text="{{.ID}}">{{svg "octicon-copy"}}</button>

View File

@ -4,7 +4,7 @@
{{$IsCommit:= .IsCommit}} {{$IsCommit:= .IsCommit}}
{{range .Comments}} {{range .Comments}}
{{if call $.ShouldShowCommentType .Type}} {{if call $.ShouldShowCommentType .Type}}
{{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} {{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, <!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF,
5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 8 = MILESTONE_CHANGE, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 8 = MILESTONE_CHANGE,
@ -157,7 +157,7 @@
{{else if eq .RefAction 2}} {{else if eq .RefAction 2}}
{{$refTr = "repo.issues.ref_reopening_from"}} {{$refTr = "repo.issues.ref_reopening_from"}}
{{end}} {{end}}
{{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} {{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
<div class="timeline-item event" id="{{.HashTag}}"> <div class="timeline-item event" id="{{.HashTag}}">
<span class="badge">{{svg "octicon-bookmark"}}</span> <span class="badge">{{svg "octicon-bookmark"}}</span>
{{template "shared/user/avatarlink" dict "user" .Poster}} {{template "shared/user/avatarlink" dict "user" .Poster}}
@ -316,7 +316,7 @@
{{template "shared/user/avatarlink" dict "user" .Poster}} {{template "shared/user/avatarlink" dict "user" .Poster}}
<span class="text grey muted-links"> <span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}} {{template "shared/user/authorlink" .Poster}}
{{$dueDate := ctx.DateUtils.AbsoluteLong (.Content|ctx.DateUtils.ParseLegacy)}} {{$dueDate := DateUtils.AbsoluteLong (.Content|DateUtils.ParseLegacy)}}
{{ctx.Locale.Tr "repo.issues.due_date_added" $dueDate $createdStr}} {{ctx.Locale.Tr "repo.issues.due_date_added" $dueDate $createdStr}}
</span> </span>
</div> </div>
@ -328,8 +328,8 @@
{{template "shared/user/authorlink" .Poster}} {{template "shared/user/authorlink" .Poster}}
{{$parsedDeadline := StringUtils.Split .Content "|"}} {{$parsedDeadline := StringUtils.Split .Content "|"}}
{{if eq (len $parsedDeadline) 2}} {{if eq (len $parsedDeadline) 2}}
{{$to := ctx.DateUtils.AbsoluteLong ((index $parsedDeadline 0)|ctx.DateUtils.ParseLegacy)}} {{$to := DateUtils.AbsoluteLong ((index $parsedDeadline 0)|DateUtils.ParseLegacy)}}
{{$from := ctx.DateUtils.AbsoluteLong ((index $parsedDeadline 1)|ctx.DateUtils.ParseLegacy)}} {{$from := DateUtils.AbsoluteLong ((index $parsedDeadline 1)|DateUtils.ParseLegacy)}}
{{ctx.Locale.Tr "repo.issues.due_date_modified" $to $from $createdStr}} {{ctx.Locale.Tr "repo.issues.due_date_modified" $to $from $createdStr}}
{{end}} {{end}}
</span> </span>
@ -340,7 +340,7 @@
{{template "shared/user/avatarlink" dict "user" .Poster}} {{template "shared/user/avatarlink" dict "user" .Poster}}
<span class="text grey muted-links"> <span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}} {{template "shared/user/authorlink" .Poster}}
{{$dueDate := ctx.DateUtils.AbsoluteLong (.Content|ctx.DateUtils.ParseLegacy)}} {{$dueDate := DateUtils.AbsoluteLong (.Content|DateUtils.ParseLegacy)}}
{{ctx.Locale.Tr "repo.issues.due_date_remove" $dueDate $createdStr}} {{ctx.Locale.Tr "repo.issues.due_date_remove" $dueDate $createdStr}}
</span> </span>
</div> </div>

View File

@ -1,6 +1,6 @@
{{range .comments}} {{range .comments}}
{{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} {{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
<div class="comment" id="{{.HashTag}}"> <div class="comment" id="{{.HashTag}}">
{{if .OriginalAuthor}} {{if .OriginalAuthor}}
<span class="avatar">{{ctx.AvatarUtils.Avatar nil}}</span> <span class="avatar">{{ctx.AvatarUtils.Avatar nil}}</span>

View File

@ -204,7 +204,7 @@
{{if .Repository.ArchivedUnix.IsZero}} {{if .Repository.ArchivedUnix.IsZero}}
{{ctx.Locale.Tr "repo.archive.title"}} {{ctx.Locale.Tr "repo.archive.title"}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.archive.title_date" (ctx.DateUtils.AbsoluteLong .Repository.ArchivedUnix)}} {{ctx.Locale.Tr "repo.archive.title_date" (DateUtils.AbsoluteLong .Repository.ArchivedUnix)}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@ -10,7 +10,7 @@
{{if .Repository.ArchivedUnix.IsZero}} {{if .Repository.ArchivedUnix.IsZero}}
{{ctx.Locale.Tr "repo.archive.title"}} {{ctx.Locale.Tr "repo.archive.title"}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.archive.title_date" (ctx.DateUtils.AbsoluteLong .Repository.ArchivedUnix)}} {{ctx.Locale.Tr "repo.archive.title_date" (DateUtils.AbsoluteLong .Repository.ArchivedUnix)}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@ -69,7 +69,7 @@
{{$userName}} {{$userName}}
{{end}} {{end}}
</span> </span>
<span class="time tw-flex tw-items-center">{{ctx.DateUtils.FullTime $commit.Date}}</span> <span class="time tw-flex tw-items-center">{{DateUtils.FullTime $commit.Date}}</span>
{{end}} {{end}}
</li> </li>
{{end}} {{end}}

View File

@ -121,7 +121,7 @@
<div class="fork-flag"> <div class="fork-flag">
{{ctx.Locale.Tr "repo.mirror_from"}} {{ctx.Locale.Tr "repo.mirror_from"}}
<a target="_blank" rel="noopener noreferrer" href="{{$.PullMirror.RemoteAddress}}">{{$.PullMirror.RemoteAddress}}</a> <a target="_blank" rel="noopener noreferrer" href="{{$.PullMirror.RemoteAddress}}">{{$.PullMirror.RemoteAddress}}</a>
{{if $.PullMirror.UpdatedUnix}}{{ctx.Locale.Tr "repo.mirror_sync"}} {{TimeSinceUnix $.PullMirror.UpdatedUnix ctx.Locale}}{{end}} {{if $.PullMirror.UpdatedUnix}}{{ctx.Locale.Tr "repo.mirror_sync"}} {{DateUtils.TimeSince $.PullMirror.UpdatedUnix}}{{end}}
</div> </div>
{{end}} {{end}}
{{if .IsFork}}<div class="fork-flag">{{ctx.Locale.Tr "repo.forked_from"}} <a href="{{.BaseRepo.Link}}">{{.BaseRepo.FullName}}</a></div>{{end}} {{if .IsFork}}<div class="fork-flag">{{ctx.Locale.Tr "repo.forked_from"}} <a href="{{.BaseRepo.Link}}">{{.BaseRepo.FullName}}</a></div>{{end}}

View File

@ -37,7 +37,7 @@
{{if .Repository.ArchivedUnix.IsZero}} {{if .Repository.ArchivedUnix.IsZero}}
{{ctx.Locale.Tr "repo.archive.title"}} {{ctx.Locale.Tr "repo.archive.title"}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.archive.title_date" (ctx.DateUtils.AbsoluteLong .Repository.ArchivedUnix)}} {{ctx.Locale.Tr "repo.archive.title_date" (DateUtils.AbsoluteLong .Repository.ArchivedUnix)}}
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@ -24,7 +24,7 @@
<div class="meta"> <div class="meta">
<span class="text light grey muted-links"> <span class="text light grey muted-links">
{{if not $.Page.Repository}}{{.Repo.FullName}}{{end}}#{{.Index}} {{if not $.Page.Repository}}{{.Repo.FullName}}{{end}}#{{.Index}}
{{$timeStr := TimeSinceUnix .GetLastEventTimestamp ctx.Locale}} {{$timeStr := DateUtils.TimeSince .GetLastEventTimestamp}}
{{if .OriginalAuthor}} {{if .OriginalAuthor}}
{{ctx.Locale.Tr .GetLastEventLabelFake $timeStr .OriginalAuthor}} {{ctx.Locale.Tr .GetLastEventLabelFake $timeStr .OriginalAuthor}}
{{else if gt .Poster.ID 0}} {{else if gt .Poster.ID 0}}

View File

@ -30,7 +30,7 @@
<progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></progress> <progress class="milestone-progress-big" value="{{.Milestone.Completeness}}" max="100"></progress>
<div class="tw-flex tw-gap-4"> <div class="tw-flex tw-gap-4">
<div classs="tw-flex tw-items-center"> <div classs="tw-flex tw-items-center">
{{$closedDate:= TimeSinceUnix .Milestone.ClosedDateUnix ctx.Locale}} {{$closedDate:= DateUtils.TimeSince .Milestone.ClosedDateUnix}}
{{if .IsClosed}} {{if .IsClosed}}
{{svg "octicon-clock"}} {{ctx.Locale.Tr "repo.milestones.closed" $closedDate}} {{svg "octicon-clock"}} {{ctx.Locale.Tr "repo.milestones.closed" $closedDate}}
{{else}} {{else}}
@ -38,7 +38,7 @@
{{if .Milestone.DeadlineString}} {{if .Milestone.DeadlineString}}
<span{{if .IsOverdue}} class="text red"{{end}}> <span{{if .IsOverdue}} class="text red"{{end}}>
{{svg "octicon-calendar"}} {{svg "octicon-calendar"}}
{{ctx.DateUtils.AbsoluteShort (.Milestone.DeadlineString|ctx.DateUtils.ParseLegacy)}} {{DateUtils.AbsoluteShort (.Milestone.DeadlineString|DateUtils.ParseLegacy)}}
</span> </span>
{{else}} {{else}}
{{svg "octicon-calendar"}} {{svg "octicon-calendar"}}

View File

@ -47,19 +47,19 @@
{{if .UpdatedUnix}} {{if .UpdatedUnix}}
<div class="flex-text-block"> <div class="flex-text-block">
{{svg "octicon-clock"}} {{svg "octicon-clock"}}
{{ctx.Locale.Tr "repo.milestones.update_ago" (TimeSinceUnix .UpdatedUnix ctx.Locale)}} {{ctx.Locale.Tr "repo.milestones.update_ago" (DateUtils.TimeSince .UpdatedUnix)}}
</div> </div>
{{end}} {{end}}
<div class="flex-text-block"> <div class="flex-text-block">
{{if .IsClosed}} {{if .IsClosed}}
{{$closedDate:= TimeSinceUnix .ClosedDateUnix ctx.Locale}} {{$closedDate:= DateUtils.TimeSince .ClosedDateUnix}}
{{svg "octicon-clock" 14}} {{svg "octicon-clock" 14}}
{{ctx.Locale.Tr "repo.milestones.closed" $closedDate}} {{ctx.Locale.Tr "repo.milestones.closed" $closedDate}}
{{else}} {{else}}
{{if .DeadlineString}} {{if .DeadlineString}}
<span class="flex-text-inline {{if .IsOverdue}}text red{{end}}"> <span class="flex-text-inline {{if .IsOverdue}}text red{{end}}">
{{svg "octicon-calendar" 14}} {{svg "octicon-calendar" 14}}
{{ctx.DateUtils.AbsoluteShort (.DeadlineString|ctx.DateUtils.ParseLegacy)}} {{DateUtils.AbsoluteShort (.DeadlineString|DateUtils.ParseLegacy)}}
</span> </span>
{{else}} {{else}}
{{svg "octicon-calendar" 14}} {{svg "octicon-calendar" 14}}

View File

@ -6,7 +6,7 @@
<input type="hidden" id="issueIndex" value="{{.Issue.Index}}"> <input type="hidden" id="issueIndex" value="{{.Issue.Index}}">
<input type="hidden" id="type" value="{{.IssueType}}"> <input type="hidden" id="type" value="{{.IssueType}}">
{{$createdStr:= TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} {{$createdStr:= DateUtils.TimeSince .Issue.CreatedUnix}}
<div class="issue-content-left comment-list prevent-before-timeline"> <div class="issue-content-left comment-list prevent-before-timeline">
{{template "repo/conversation/conversation" .}} {{template "repo/conversation/conversation" .}}
</div> </div>

View File

@ -0,0 +1,143 @@
{{if len .comments}}
{{$comment := index .comments 0}}
{{$invalid := $comment.Invalidated}}
{{$resolved := $comment.IsResolved}}
{{$resolveDoer := $comment.ResolveDoer}}
{{$hasReview := and $comment.Review}}
{{$isReviewPending := and $hasReview (eq $comment.Review.Type 0)}}
<div class="ui segments conversation-holder">
<div class="ui segment collapsible-comment-box tw-py-2 tw-flex tw-items-center tw-justify-between">
<div class="tw-flex tw-items-center">
<a href="{{$comment.CodeCommentLink ctx}}" class="file-comment tw-ml-2 tw-break-anywhere">{{$comment.TreePath}}</a>
{{if $invalid}}
<span class="ui label basic small tw-ml-2" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.review.outdated_description"}}">
{{ctx.Locale.Tr "repo.issues.review.outdated"}}
</span>
{{end}}
</div>
<div>
{{if or $invalid $resolved}}
<button id="show-outdated-{{$comment.ID}}" data-comment="{{$comment.ID}}" class="{{if not $resolved}}tw-hidden {{end}}ui compact labeled button show-outdated tw-flex tw-items-center">
{{svg "octicon-unfold" 16 "tw-mr-2"}}
{{if $resolved}}
{{ctx.Locale.Tr "repo.issues.review.show_resolved"}}
{{else}}
{{ctx.Locale.Tr "repo.issues.review.show_outdated"}}
{{end}}
</button>
<button id="hide-outdated-{{$comment.ID}}" data-comment="{{$comment.ID}}" class="{{if $resolved}}tw-hidden {{end}}ui compact labeled button hide-outdated tw-flex tw-items-center">
{{svg "octicon-fold" 16 "tw-mr-2"}}
{{if $resolved}}
{{ctx.Locale.Tr "repo.issues.review.hide_resolved"}}
{{else}}
{{ctx.Locale.Tr "repo.issues.review.hide_outdated"}}
{{end}}
</button>
{{end}}
</div>
</div>
{{$diff := (CommentMustAsDiff ctx $comment)}}
{{if $diff}}
{{$file := (index $diff.Files 0)}}
<div id="code-preview-{{$comment.ID}}" class="ui table segment{{if $resolved}} tw-hidden{{end}}">
<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}}">
<div class="file-body file-code code-view code-diff code-diff-unified unicode-escaped">
<table>
<tbody>
{{template "repo/diff/section_unified" dict "file" $file "root" $}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
<div id="code-comments-{{$comment.ID}}" class="comment-code-cloud ui segment{{if $resolved}} tw-hidden{{end}}">
<div class="ui comments tw-mb-0">
{{range .comments}}
{{$createdSubStr:= DateUtils.TimeSince .CreatedUnix}}
<div class="comment code-comment" id="{{.HashTag}}">
<div class="content comment-container">
<div class="header comment-header">
<div class="comment-header-left tw-flex tw-items-center">
{{if not .OriginalAuthor}}
<a class="avatar">
{{ctx.AvatarUtils.Avatar .Poster 20}}
</a>
{{end}}
<span class="text grey muted-links">
{{if .OriginalAuthor}}
<span class="text black">
{{svg (MigrationIcon $.Repository.GetOriginalURLHostname)}}
{{.OriginalAuthor}}
</span>
{{if $.Repository.OriginalURL}}
<span class="migrate">({{ctx.Locale.Tr "repo.migrated_from" $.Repository.OriginalURL $.Repository.GetOriginalURLHostname}})</span>
{{end}}
{{else}}
{{template "shared/user/authorlink" .Poster}}
{{end}}
{{ctx.Locale.Tr "repo.issues.commented_at" .HashTag $createdSubStr}}
</span>
</div>
<div class="comment-header-right actions tw-flex tw-items-center">
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
{{if not $.Repository.IsArchived}}
{{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
{{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" true "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
{{end}}
</div>
</div>
<div class="text comment-content">
<div class="render-content markup" {{if or $.Permission.IsAdmin $.HasIssuesOrPullsWritePermission (and $.IsSigned (eq $.SignedUserID .PosterID))}}data-can-edit="true"{{end}}>
{{if .RenderedContent}}
{{.RenderedContent}}
{{else}}
<span class="no-content">{{ctx.Locale.Tr "repo.issues.no_content"}}</span>
{{end}}
</div>
<div id="issuecomment-{{.ID}}-raw" class="raw-content tw-hidden">{{.Content}}</div>
<div class="edit-content-zone tw-hidden" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-content-version="{{.ContentVersion}}" data-context="{{$.RepoLink}}" data-attachment-url="{{$.RepoLink}}/comments/{{.ID}}/attachments"></div>
{{if .Attachments}}
{{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}}
{{end}}
</div>
{{$reactions := .Reactions.GroupByType}}
{{if $reactions}}
{{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}}
{{end}}
</div>
</div>
{{end}}
</div>
<div class="code-comment-buttons tw-flex tw-items-center tw-flex-wrap tw-mt-2 tw-mb-1 tw-mx-2">
<div class="tw-flex-1">
{{if $resolved}}
<div class="ui grey text">
{{svg "octicon-check" 16 "tw-mr-1"}}
<b>{{$resolveDoer.Name}}</b> {{ctx.Locale.Tr "repo.issues.review.resolved_by"}}
</div>
{{end}}
</div>
<div class="code-comment-buttons-buttons">
{{if and $.CanMarkConversation $hasReview (not $isReviewPending)}}
<button class="ui tiny basic button resolve-conversation" data-origin="timeline" data-action="{{if not $resolved}}Resolve{{else}}UnResolve{{end}}" data-comment-id="{{$comment.ID}}" data-update-url="{{$.RepoLink}}/issues/resolve_conversation">
{{if $resolved}}
{{ctx.Locale.Tr "repo.issues.review.un_resolve_conversation"}}
{{else}}
{{ctx.Locale.Tr "repo.issues.review.resolve_conversation"}}
{{end}}
</button>
{{end}}
{{if and $.SignedUserID (not $.Repository.IsArchived)}}
<button class="comment-form-reply ui primary tiny labeled icon button tw-ml-1 tw-mr-0">
{{svg "octicon-reply" 16 "reply icon tw-mr-1"}}{{ctx.Locale.Tr "repo.diff.comment.reply"}}
</button>
{{end}}
</div>
</div>
{{template "repo/diff/comment_form_datahandler" dict "hidden" true "reply" $comment.ReviewID "root" $ "comment" $comment}}
</div>
</div>
{{else}}
{{template "repo/diff/conversation_outdated"}}
{{end}}

View File

@ -206,7 +206,7 @@
{{if or $prUnit.PullRequestsConfig.AllowMerge $prUnit.PullRequestsConfig.AllowRebase $prUnit.PullRequestsConfig.AllowRebaseMerge $prUnit.PullRequestsConfig.AllowSquash $prUnit.PullRequestsConfig.AllowFastForwardOnly}} {{if or $prUnit.PullRequestsConfig.AllowMerge $prUnit.PullRequestsConfig.AllowRebase $prUnit.PullRequestsConfig.AllowRebaseMerge $prUnit.PullRequestsConfig.AllowSquash $prUnit.PullRequestsConfig.AllowFastForwardOnly}}
{{$hasPendingPullRequestMergeTip := ""}} {{$hasPendingPullRequestMergeTip := ""}}
{{if .HasPendingPullRequestMerge}} {{if .HasPendingPullRequestMerge}}
{{$createdPRMergeStr := TimeSinceUnix .PendingPullRequestMerge.CreatedUnix ctx.Locale}} {{$createdPRMergeStr := DateUtils.TimeSince .PendingPullRequestMerge.CreatedUnix}}
{{$hasPendingPullRequestMergeTip = ctx.Locale.Tr "repo.pulls.auto_merge_has_pending_schedule" .PendingPullRequestMerge.Doer.Name $createdPRMergeStr}} {{$hasPendingPullRequestMergeTip = ctx.Locale.Tr "repo.pulls.auto_merge_has_pending_schedule" .PendingPullRequestMerge.Doer.Name $createdPRMergeStr}}
{{end}} {{end}}
<div class="divider"></div> <div class="divider"></div>

View File

@ -368,7 +368,7 @@
<div class="tw-flex tw-justify-between tw-items-center"> <div class="tw-flex tw-justify-between tw-items-center">
<div class="due-date {{if .Issue.IsOverdue}}text red{{end}}" {{if .Issue.IsOverdue}}data-tooltip-content="{{ctx.Locale.Tr "repo.issues.due_date_overdue"}}"{{end}}> <div class="due-date {{if .Issue.IsOverdue}}text red{{end}}" {{if .Issue.IsOverdue}}data-tooltip-content="{{ctx.Locale.Tr "repo.issues.due_date_overdue"}}"{{end}}>
{{svg "octicon-calendar" 16 "tw-mr-2"}} {{svg "octicon-calendar" 16 "tw-mr-2"}}
{{ctx.DateUtils.AbsoluteLong .Issue.DeadlineUnix}} {{DateUtils.AbsoluteLong .Issue.DeadlineUnix}}
</div> </div>
<div> <div>
{{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}} {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}

View File

@ -63,7 +63,7 @@
{{$baseHref = HTMLFormat `<a href="%s">%s</a>` .BaseBranchLink $baseHref}} {{$baseHref = HTMLFormat `<a href="%s">%s</a>` .BaseBranchLink $baseHref}}
{{end}} {{end}}
{{if .Issue.PullRequest.HasMerged}} {{if .Issue.PullRequest.HasMerged}}
{{$mergedStr:= TimeSinceUnix .Issue.PullRequest.MergedUnix ctx.Locale}} {{$mergedStr:= DateUtils.TimeSince .Issue.PullRequest.MergedUnix}}
{{if .Issue.OriginalAuthor}} {{if .Issue.OriginalAuthor}}
{{.Issue.OriginalAuthor}} {{.Issue.OriginalAuthor}}
<span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr}}</span> <span class="pull-desc">{{ctx.Locale.Tr "repo.pulls.merged_title_desc" .NumCommits $headHref $baseHref $mergedStr}}</span>
@ -111,7 +111,7 @@
</span> </span>
{{end}} {{end}}
{{else}} {{else}}
{{$createdStr:= TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} {{$createdStr:= DateUtils.TimeSince .Issue.CreatedUnix}}
<span class="time-desc"> <span class="time-desc">
{{if .Issue.OriginalAuthor}} {{if .Issue.OriginalAuthor}}
{{ctx.Locale.Tr "repo.issues.opened_by_fake" $createdStr .Issue.OriginalAuthor}} {{ctx.Locale.Tr "repo.issues.opened_by_fake" $createdStr .Issue.OriginalAuthor}}

View File

@ -1,5 +1,5 @@
<h2 class="ui header activity-header"> <h2 class="ui header activity-header">
<span>{{ctx.DateUtils.AbsoluteLong .DateFrom}} - {{ctx.DateUtils.AbsoluteLong .DateUntil}}</span> <span>{{DateUtils.AbsoluteLong .DateFrom}} - {{DateUtils.AbsoluteLong .DateUntil}}</span>
<!-- Period --> <!-- Period -->
<div class="ui floating dropdown jump filter"> <div class="ui floating dropdown jump filter">
<div class="ui basic compact button"> <div class="ui basic compact button">
@ -127,7 +127,7 @@
{{if not .IsTag}} {{if not .IsTag}}
<a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> <a class="title" href="{{$.RepoLink}}/src/{{.TagName | PathEscapeSegments}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
{{end}} {{end}}
{{TimeSinceUnix .CreatedUnix ctx.Locale}} {{DateUtils.TimeSince .CreatedUnix}}
</p> </p>
{{end}} {{end}}
</div> </div>
@ -146,7 +146,7 @@
<p class="desc"> <p class="desc">
<span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span> <span class="ui purple label">{{ctx.Locale.Tr "repo.activity.merged_prs_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
{{TimeSinceUnix .MergedUnix ctx.Locale}} {{DateUtils.TimeSince .MergedUnix}}
</p> </p>
{{end}} {{end}}
</div> </div>
@ -165,7 +165,7 @@
<p class="desc"> <p class="desc">
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span> <span class="ui green label">{{ctx.Locale.Tr "repo.activity.opened_prs_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/pulls/{{.Index}}">{{.Issue.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
{{TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} {{DateUtils.TimeSince .Issue.CreatedUnix}}
</p> </p>
{{end}} {{end}}
</div> </div>
@ -184,7 +184,7 @@
<p class="desc"> <p class="desc">
<span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span> <span class="ui red label">{{ctx.Locale.Tr "repo.activity.closed_issue_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
{{TimeSinceUnix .ClosedUnix ctx.Locale}} {{DateUtils.TimeSince .ClosedUnix}}
</p> </p>
{{end}} {{end}}
</div> </div>
@ -203,7 +203,7 @@
<p class="desc"> <p class="desc">
<span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span> <span class="ui green label">{{ctx.Locale.Tr "repo.activity.new_issue_label"}}</span>
#{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> #{{.Index}} <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
{{TimeSinceUnix .CreatedUnix ctx.Locale}} {{DateUtils.TimeSince .CreatedUnix}}
</p> </p>
{{end}} {{end}}
</div> </div>
@ -224,7 +224,7 @@
{{else}} {{else}}
<a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a> <a class="title" href="{{$.RepoLink}}/issues/{{.Index}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
{{end}} {{end}}
{{TimeSinceUnix .UpdatedUnix ctx.Locale}} {{DateUtils.TimeSince .UpdatedUnix}}
</p> </p>
{{end}} {{end}}
</div> </div>

View File

@ -51,7 +51,7 @@
{{ctx.Locale.Tr "repo.released_this"}} {{ctx.Locale.Tr "repo.released_this"}}
</span> </span>
{{if $release.CreatedUnix}} {{if $release.CreatedUnix}}
<span class="time">{{TimeSinceUnix $release.CreatedUnix ctx.Locale}}</span> <span class="time">{{DateUtils.TimeSince $release.CreatedUnix}}</span>
{{end}} {{end}}
{{if and (not $release.IsDraft) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}} {{if and (not $release.IsDraft) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}}
| <span class="ahead"><a href="{{$.RepoLink}}/compare/{{$release.TagName | PathEscapeSegments}}...{{$release.TargetBehind | PathEscapeSegments}}">{{ctx.Locale.Tr "repo.release.ahead.commits" $release.NumCommitsBehind}}</a> {{ctx.Locale.Tr "repo.release.ahead.target" $release.TargetBehind}}</span> | <span class="ahead"><a href="{{$.RepoLink}}/compare/{{$release.TagName | PathEscapeSegments}}...{{$release.TargetBehind | PathEscapeSegments}}">{{ctx.Locale.Tr "repo.release.ahead.commits" $release.NumCommitsBehind}}</a> {{ctx.Locale.Tr "repo.release.ahead.target" $release.TargetBehind}}</span>

View File

@ -55,7 +55,7 @@
{{.Fingerprint}} {{.Fingerprint}}
</div> </div>
<div class="flex-item-body"> <div class="flex-item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="text green"{{end}}>{{ctx.DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} - <span>{{ctx.Locale.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}}</span></i> <i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="text green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}} - <span>{{ctx.Locale.Tr "settings.can_read_info"}}{{if not .IsReadOnly}} / {{ctx.Locale.Tr "settings.can_write_info"}} {{end}}</span></i>
</div> </div>
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">

View File

@ -17,7 +17,7 @@
</a> </a>
</td> </td>
<td>{{FileSize .Size}}</td> <td>{{FileSize .Size}}</td>
<td>{{TimeSince .CreatedUnix.AsTime ctx.Locale}}</td> <td>{{DateUtils.TimeSince .CreatedUnix}}</td>
<td class="right aligned"> <td class="right aligned">
<a class="ui primary button" href="{{$.Link}}/find?oid={{.Oid}}&size={{.Size}}">{{ctx.Locale.Tr "repo.settings.lfs_findcommits"}}</a> <a class="ui primary button" href="{{$.Link}}/find?oid={{.Oid}}&size={{.Size}}">{{ctx.Locale.Tr "repo.settings.lfs_findcommits"}}</a>
<button class="ui basic show-modal icon button red" data-modal="#delete-{{.Oid}}"> <button class="ui basic show-modal icon button red" data-modal="#delete-{{.Oid}}">

View File

@ -32,7 +32,7 @@
{{ctx.Locale.Tr "repo.diff.commit"}} {{ctx.Locale.Tr "repo.diff.commit"}}
<a class="ui primary sha label" href="{{$.RepoLink}}/commit/{{.SHA}}">{{ShortSha .SHA}}</a> <a class="ui primary sha label" href="{{$.RepoLink}}/commit/{{.SHA}}">{{ShortSha .SHA}}</a>
</td> </td>
<td>{{TimeSince .When ctx.Locale}}</td> <td>{{DateUtils.TimeSince .When}}</td>
</tr> </tr>
{{else}} {{else}}
<tr> <tr>

View File

@ -35,7 +35,7 @@
{{$lock.Owner.DisplayName}} {{$lock.Owner.DisplayName}}
</a> </a>
</td> </td>
<td>{{TimeSince .Created ctx.Locale}}</td> <td>{{DateUtils.TimeSince .Created}}</td>
<td class="right aligned"> <td class="right aligned">
<form action="{{$.LFSFilesLink}}/locks/{{$lock.ID}}/unlock" method="post"> <form action="{{$.LFSFilesLink}}/locks/{{$lock.ID}}/unlock" method="post">
{{$.CsrfTokenHtml}} {{$.CsrfTokenHtml}}

View File

@ -117,7 +117,7 @@
<tr> <tr>
<td>{{.PullMirror.RemoteAddress}}</td> <td>{{.PullMirror.RemoteAddress}}</td>
<td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.pull"}}</td> <td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.pull"}}</td>
<td>{{ctx.DateUtils.FullTime .PullMirror.UpdatedUnix}}</td> <td>{{DateUtils.FullTime .PullMirror.UpdatedUnix}}</td>
<td class="right aligned"> <td class="right aligned">
<form method="post" class="tw-inline-block"> <form method="post" class="tw-inline-block">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
@ -205,7 +205,7 @@
<tr> <tr>
<td class="tw-break-anywhere">{{.RemoteAddress}}</td> <td class="tw-break-anywhere">{{.RemoteAddress}}</td>
<td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td> <td>{{ctx.Locale.Tr "repo.settings.mirror_settings.direction.push"}}</td>
<td>{{if .LastUpdateUnix}}{{ctx.DateUtils.FullTime .LastUpdateUnix}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} {{if .LastError}}<div class="ui red label" data-tooltip-content="{{.LastError}}">{{ctx.Locale.Tr "error"}}</div>{{end}}</td> <td>{{if .LastUpdateUnix}}{{DateUtils.FullTime .LastUpdateUnix}}{{else}}{{ctx.Locale.Tr "never"}}{{end}} {{if .LastError}}<div class="ui red label" data-tooltip-content="{{.LastError}}">{{ctx.Locale.Tr "error"}}</div>{{end}}</td>
<td class="right aligned"> <td class="right aligned">
<button <button
class="ui tiny button show-modal" class="ui tiny button show-modal"

View File

@ -29,7 +29,7 @@
<a class="ui primary sha label toggle button show-panel" data-panel="#info-{{.ID}}">{{.UUID}}</a> <a class="ui primary sha label toggle button show-panel" data-panel="#info-{{.ID}}">{{.UUID}}</a>
</div> </div>
<span class="text grey"> <span class="text grey">
{{TimeSince .Delivered.AsTime ctx.Locale}} {{DateUtils.TimeSince .Delivered}}
</span> </span>
</div> </div>
<div class="info tw-hidden" id="info-{{.ID}}"> <div class="info tw-hidden" id="info-{{.ID}}">

View File

@ -32,7 +32,7 @@
<div class="download tw-flex tw-items-center"> <div class="download tw-flex tw-items-center">
{{if $.Permission.CanRead ctx.Consts.RepoUnitTypeCode}} {{if $.Permission.CanRead ctx.Consts.RepoUnitTypeCode}}
{{if .CreatedUnix}} {{if .CreatedUnix}}
<span class="tw-mr-2">{{svg "octicon-clock" 16 "tw-mr-1"}}{{TimeSinceUnix .CreatedUnix ctx.Locale}}</span> <span class="tw-mr-2">{{svg "octicon-clock" 16 "tw-mr-1"}}{{DateUtils.TimeSince .CreatedUnix}}</span>
{{end}} {{end}}
<a class="tw-mr-2 tw-font-mono muted" href="{{$.RepoLink}}/src/commit/{{.Sha1}}" rel="nofollow">{{svg "octicon-git-commit" 16 "tw-mr-1"}}{{ShortSha .Sha1}}</a> <a class="tw-mr-2 tw-font-mono muted" href="{{$.RepoLink}}/src/commit/{{.Sha1}}" rel="nofollow">{{svg "octicon-git-commit" 16 "tw-mr-1"}}{{ShortSha .Sha1}}</a>

View File

@ -18,7 +18,7 @@
{{else if .Location}} {{else if .Location}}
{{svg "octicon-location"}} {{.Location}} {{svg "octicon-location"}} {{.Location}}
{{else}} {{else}}
{{svg "octicon-calendar"}} {{ctx.Locale.Tr "user.joined_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}} {{svg "octicon-calendar"}} {{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .CreatedUnix)}}
{{end}} {{end}}
</div> </div>
</li> </li>

View File

@ -18,7 +18,7 @@
{{if .LatestCommit}} {{if .LatestCommit}}
{{if .LatestCommit.Committer}} {{if .LatestCommit.Committer}}
<div class="text grey age"> <div class="text grey age">
{{TimeSince .LatestCommit.Committer.When ctx.Locale}} {{DateUtils.TimeSince .LatestCommit.Committer.When}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}

View File

@ -8,7 +8,7 @@
</div> </div>
</div> </div>
</th> </th>
<th class="text grey right age">{{if .LatestCommit}}{{if .LatestCommit.Committer}}{{TimeSince .LatestCommit.Committer.When ctx.Locale}}{{end}}{{end}}</th> <th class="text grey right age">{{if .LatestCommit}}{{if .LatestCommit.Committer}}{{DateUtils.TimeSince .LatestCommit.Committer.When}}{{end}}{{end}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -63,7 +63,7 @@
{{end}} {{end}}
</span> </span>
</td> </td>
<td class="text right age three wide">{{if $commit}}{{TimeSince $commit.Committer.When ctx.Locale}}{{end}}</td> <td class="text right age three wide">{{if $commit}}{{DateUtils.TimeSince $commit.Committer.When}}{{end}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>

View File

@ -20,7 +20,7 @@
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
<a class="wiki-git-entry" href="{{$.RepoLink}}/wiki/{{.GitEntryName | PathEscape}}" data-tooltip-content="{{ctx.Locale.Tr "repo.wiki.original_git_entry_tooltip"}}">{{svg "octicon-chevron-right"}}</a> <a class="wiki-git-entry" href="{{$.RepoLink}}/wiki/{{.GitEntryName | PathEscape}}" data-tooltip-content="{{ctx.Locale.Tr "repo.wiki.original_git_entry_tooltip"}}">{{svg "octicon-chevron-right"}}</a>
</td> </td>
{{$timeSince := TimeSinceUnix .UpdatedUnix ctx.Locale}} {{$timeSince := DateUtils.TimeSince .UpdatedUnix}}
<td class="text right">{{ctx.Locale.Tr "repo.wiki.last_updated" $timeSince}}</td> <td class="text right">{{ctx.Locale.Tr "repo.wiki.last_updated" $timeSince}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@ -9,7 +9,7 @@
<a class="file-revisions-btn ui basic button" title="{{ctx.Locale.Tr "repo.wiki.back_to_wiki"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}"><span>{{.revision}}</span> {{svg "octicon-home"}}</a> <a class="file-revisions-btn ui basic button" title="{{ctx.Locale.Tr "repo.wiki.back_to_wiki"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}"><span>{{.revision}}</span> {{svg "octicon-home"}}</a>
{{$title}} {{$title}}
<div class="ui sub header tw-break-anywhere"> <div class="ui sub header tw-break-anywhere">
{{$timeSince := TimeSince .Author.When ctx.Locale}} {{$timeSince := DateUtils.TimeSince .Author.When}}
{{ctx.Locale.Tr "repo.wiki.last_commit_info" .Author.Name $timeSince}} {{ctx.Locale.Tr "repo.wiki.last_commit_info" .Author.Name $timeSince}}
</div> </div>
</div> </div>

View File

@ -39,7 +39,7 @@
<a class="file-revisions-btn ui basic button" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" ><span>{{.CommitCount}}</span> {{svg "octicon-history"}}</a> <a class="file-revisions-btn ui basic button" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" ><span>{{.CommitCount}}</span> {{svg "octicon-history"}}</a>
{{$title}} {{$title}}
<div class="ui sub header"> <div class="ui sub header">
{{$timeSince := TimeSince .Author.When ctx.Locale}} {{$timeSince := DateUtils.TimeSince .Author.When}}
{{ctx.Locale.Tr "repo.wiki.last_commit_info" .Author.Name $timeSince}} {{ctx.Locale.Tr "repo.wiki.last_commit_info" .Author.Name $timeSince}}
</div> </div>
</div> </div>

View File

@ -13,7 +13,7 @@
</div> </div>
<div class="field tw-inline-block tw-mr-4"> <div class="field tw-inline-block tw-mr-4">
<label>{{ctx.Locale.Tr "actions.runners.last_online"}}</label> <label>{{ctx.Locale.Tr "actions.runners.last_online"}}</label>
<span>{{if .Runner.LastOnline}}{{TimeSinceUnix .Runner.LastOnline ctx.Locale}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</span> <span>{{if .Runner.LastOnline}}{{DateUtils.TimeSince .Runner.LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</span>
</div> </div>
<div class="field tw-inline-block tw-mr-4"> <div class="field tw-inline-block tw-mr-4">
<label>{{ctx.Locale.Tr "actions.runners.labels"}}</label> <label>{{ctx.Locale.Tr "actions.runners.labels"}}</label>
@ -70,7 +70,7 @@
<strong><a href="{{.GetCommitLink}}" target="_blank">{{ShortSha .CommitSHA}}</a></strong> <strong><a href="{{.GetCommitLink}}" target="_blank">{{ShortSha .CommitSHA}}</a></strong>
</td> </td>
<td>{{if .IsStopped}} <td>{{if .IsStopped}}
<span>{{TimeSinceUnix .Stopped ctx.Locale}}</span> <span>{{DateUtils.TimeSince .Stopped}}</span>
{{else}}-{{end}}</td> {{else}}-{{end}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@ -73,7 +73,7 @@
<td class="tw-flex tw-flex-wrap tw-gap-2 runner-tags"> <td class="tw-flex tw-flex-wrap tw-gap-2 runner-tags">
{{range .AgentLabels}}<span class="ui label">{{.}}</span>{{end}} {{range .AgentLabels}}<span class="ui label">{{.}}</span>{{end}}
</td> </td>
<td>{{if .LastOnline}}{{TimeSinceUnix .LastOnline ctx.Locale}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</td> <td>{{if .LastOnline}}{{DateUtils.TimeSince .LastOnline}}{{else}}{{ctx.Locale.Tr "never"}}{{end}}</td>
<td class="runner-ops"> <td class="runner-ops">
{{if .Editable $.RunnerOwnerID $.RunnerRepoID}} {{if .Editable $.RunnerOwnerID $.RunnerRepoID}}
<a href="{{$.Link}}/{{.ID}}">{{svg "octicon-pencil"}}</a> <a href="{{$.Link}}/{{.ID}}">{{svg "octicon-pencil"}}</a>

View File

@ -21,7 +21,11 @@ Template Attributes:
<div class="ui tab active" data-tab-panel="markdown-writer"> <div class="ui tab active" data-tab-panel="markdown-writer">
<markdown-toolbar> <markdown-toolbar>
<div class="markdown-toolbar-group"> <div class="markdown-toolbar-group">
<md-header class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header> <md-header class="markdown-toolbar-button" level="1" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
<md-header class="markdown-toolbar-button" level="2" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
<md-header class="markdown-toolbar-button" level="3" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.heading.tooltip"}}">{{svg "octicon-heading"}}</md-header>
</div>
<div class="markdown-toolbar-group">
<md-bold class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.bold.tooltip"}}">{{svg "octicon-bold"}}</md-bold> <md-bold class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.bold.tooltip"}}">{{svg "octicon-bold"}}</md-bold>
<md-italic class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.italic.tooltip"}}">{{svg "octicon-italic"}}</md-italic> <md-italic class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.italic.tooltip"}}">{{svg "octicon-italic"}}</md-italic>
</div> </div>
@ -34,6 +38,7 @@ Template Attributes:
<md-unordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.unordered.tooltip"}}">{{svg "octicon-list-unordered"}}</md-unordered-list> <md-unordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.unordered.tooltip"}}">{{svg "octicon-list-unordered"}}</md-unordered-list>
<md-ordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.ordered.tooltip"}}">{{svg "octicon-list-ordered"}}</md-ordered-list> <md-ordered-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.ordered.tooltip"}}">{{svg "octicon-list-ordered"}}</md-ordered-list>
<md-task-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.task.tooltip"}}">{{svg "octicon-tasklist"}}</md-task-list> <md-task-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.task.tooltip"}}">{{svg "octicon-tasklist"}}</md-task-list>
<button class="markdown-toolbar-button markdown-button-table-add" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.add.tooltip"}}">{{svg "octicon-table"}}</button>
</div> </div>
<div class="markdown-toolbar-group"> <div class="markdown-toolbar-group">
<md-mention class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.mention.tooltip"}}">{{svg "octicon-mention"}}</md-mention> <md-mention class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.mention.tooltip"}}">{{svg "octicon-mention"}}</md-mention>
@ -56,4 +61,12 @@ Template Attributes:
<div class="ui tab markup" data-tab-panel="markdown-previewer"> <div class="ui tab markup" data-tab-panel="markdown-previewer">
{{ctx.Locale.Tr "loading"}} {{ctx.Locale.Tr "loading"}}
</div> </div>
<div class="markdown-add-table-panel tippy-target">
<div class="ui form tw-p-4 flex-text-block">
<input type="number" name="rows" min="1" value="3" size="3" class="tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.rows"}}">
x
<input type="number" name="cols" min="1" value="3" size="3" class="tw-w-24" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.cols"}}">
<button class="ui button primary" type="button">{{ctx.Locale.Tr "editor.buttons.table.add.insert"}}</button>
</div>
</div>
</div> </div>

View File

@ -60,7 +60,7 @@
#{{.Index}} #{{.Index}}
{{end}} {{end}}
</a> </a>
{{$timeStr := TimeSinceUnix .GetLastEventTimestamp ctx.Locale}} {{$timeStr := DateUtils.TimeSince .GetLastEventTimestamp}}
{{if .OriginalAuthor}} {{if .OriginalAuthor}}
{{ctx.Locale.Tr .GetLastEventLabelFake $timeStr .OriginalAuthor}} {{ctx.Locale.Tr .GetLastEventLabelFake $timeStr .OriginalAuthor}}
{{else if gt .Poster.ID 0}} {{else if gt .Poster.ID 0}}
@ -117,7 +117,7 @@
<span class="due-date flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.due_date"}}"> <span class="due-date flex-text-inline" data-tooltip-content="{{ctx.Locale.Tr "repo.issues.due_date"}}">
<span{{if .IsOverdue}} class="text red"{{end}}> <span{{if .IsOverdue}} class="text red"{{end}}>
{{svg "octicon-calendar" 14}} {{svg "octicon-calendar" 14}}
{{ctx.DateUtils.AbsoluteShort .DeadlineUnix}} {{DateUtils.AbsoluteShort .DeadlineUnix}}
</span> </span>
</span> </span>
{{end}} {{end}}

View File

@ -7,7 +7,7 @@
</div> </div>
<div class="tw-mr-4"> <div class="tw-mr-4">
{{if not .result.UpdatedUnix.IsZero}} {{if not .result.UpdatedUnix.IsZero}}
<span class="ui grey text">{{ctx.Locale.Tr "explore.code_last_indexed_at" (TimeSinceUnix .result.UpdatedUnix ctx.Locale)}}</span> <span class="ui grey text">{{ctx.Locale.Tr "explore.code_last_indexed_at" (DateUtils.TimeSince .result.UpdatedUnix)}}</span>
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@ -28,7 +28,7 @@
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">
<span class="color-text-light-2"> <span class="color-text-light-2">
{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}} {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}
</span> </span>
<button class="ui btn interact-bg link-action tw-p-2" <button class="ui btn interact-bg link-action tw-p-2"
data-url="{{$.Link}}/delete?id={{.ID}}" data-url="{{$.Link}}/delete?id={{.ID}}"

View File

@ -79,7 +79,7 @@
</li> </li>
{{end}} {{end}}
{{end}} {{end}}
<li>{{svg "octicon-calendar"}} <span>{{ctx.Locale.Tr "user.joined_on" (ctx.DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}</span></li> <li>{{svg "octicon-calendar"}} <span>{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}</span></li>
{{if and .Orgs .HasOrgsVisible}} {{if and .Orgs .HasOrgsVisible}}
<li> <li>
<ul class="user-orgs"> <ul class="user-orgs">

View File

@ -30,7 +30,7 @@
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">
<span class="color-text-light-2"> <span class="color-text-light-2">
{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}} {{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}
</span> </span>
<button class="btn interact-bg tw-p-2 show-modal" <button class="btn interact-bg tw-p-2 show-modal"
data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}" data-tooltip-content="{{ctx.Locale.Tr "actions.variables.edit"}}"

View File

@ -78,7 +78,7 @@
{{$reviewer := index .GetIssueInfos 1}} {{$reviewer := index .GetIssueInfos 1}}
{{ctx.Locale.Tr "action.review_dismissed" (printf "%s/pulls/%s" (.GetRepoLink ctx) $index) $index (.ShortRepoPath ctx) $reviewer}} {{ctx.Locale.Tr "action.review_dismissed" (printf "%s/pulls/%s" (.GetRepoLink ctx) $index) $index (.ShortRepoPath ctx) $reviewer}}
{{end}} {{end}}
{{TimeSince .GetCreate ctx.Locale}} {{DateUtils.TimeSince .GetCreate}}
</div> </div>
{{if .GetOpType.InActions "commit_repo" "mirror_sync_push"}} {{if .GetOpType.InActions "commit_repo" "mirror_sync_push"}}
{{$push := ActionContent2Commits .}} {{$push := ActionContent2Commits .}}

View File

@ -104,19 +104,19 @@
{{if .UpdatedUnix}} {{if .UpdatedUnix}}
<div class="flex-text-block"> <div class="flex-text-block">
{{svg "octicon-clock"}} {{svg "octicon-clock"}}
{{ctx.Locale.Tr "repo.milestones.update_ago" (TimeSinceUnix .UpdatedUnix ctx.Locale)}} {{ctx.Locale.Tr "repo.milestones.update_ago" (DateUtils.TimeSince .UpdatedUnix)}}
</div> </div>
{{end}} {{end}}
<div class="flex-text-block"> <div class="flex-text-block">
{{if .IsClosed}} {{if .IsClosed}}
{{$closedDate:= TimeSinceUnix .ClosedDateUnix ctx.Locale}} {{$closedDate:= DateUtils.TimeSince .ClosedDateUnix}}
{{svg "octicon-clock" 14}} {{svg "octicon-clock" 14}}
{{ctx.Locale.Tr "repo.milestones.closed" $closedDate}} {{ctx.Locale.Tr "repo.milestones.closed" $closedDate}}
{{else}} {{else}}
{{if .DeadlineString}} {{if .DeadlineString}}
<span{{if .IsOverdue}} class="text red"{{end}}> <span{{if .IsOverdue}} class="text red"{{end}}>
{{svg "octicon-calendar" 14}} {{svg "octicon-calendar" 14}}
{{ctx.DateUtils.AbsoluteShort (.DeadlineString|ctx.DateUtils.ParseLegacy)}} {{DateUtils.AbsoluteShort (.DeadlineString|DateUtils.ParseLegacy)}}
</span> </span>
{{else}} {{else}}
{{svg "octicon-calendar" 14}} {{svg "octicon-calendar" 14}}

View File

@ -62,9 +62,9 @@
</a> </a>
<div class="notifications-updated tw-items-center tw-mr-2"> <div class="notifications-updated tw-items-center tw-mr-2">
{{if .Issue}} {{if .Issue}}
{{TimeSinceUnix .Issue.UpdatedUnix ctx.Locale}} {{DateUtils.TimeSince .Issue.UpdatedUnix}}
{{else}} {{else}}
{{TimeSinceUnix .UpdatedUnix ctx.Locale}} {{DateUtils.TimeSince .UpdatedUnix}}
{{end}} {{end}}
</div> </div>
<div class="notifications-buttons tw-items-center tw-justify-end tw-gap-1 tw-px-1"> <div class="notifications-buttons tw-items-center tw-justify-end tw-gap-1 tw-px-1">

View File

@ -36,7 +36,7 @@
</ul> </ul>
</details> </details>
<div class="flex-item-body"> <div class="flex-item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="text green"{{end}}>{{ctx.DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i> <i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info"}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="text green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i>
</div> </div>
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">

View File

@ -14,7 +14,7 @@
<div class="flex-item-main"> <div class="flex-item-main">
<div class="flex-item-title">{{.Application.Name}}</div> <div class="flex-item-title">{{.Application.Name}}</div>
<div class="flex-item-body"> <div class="flex-item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}}</i> <i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}</i>
</div> </div>
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">

View File

@ -63,9 +63,9 @@
<b>{{ctx.Locale.Tr "settings.subkeys"}}:</b> {{range .SubsKey}} {{.PaddedKeyID}} {{end}} <b>{{ctx.Locale.Tr "settings.subkeys"}}:</b> {{range .SubsKey}} {{.PaddedKeyID}} {{end}}
</div> </div>
<div class="flex-item-body"> <div class="flex-item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .AddedUnix)}}</i> <i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .AddedUnix)}}</i>
- -
<i>{{if not .ExpiredUnix.IsZero}}{{ctx.Locale.Tr "settings.valid_until_date" (ctx.DateUtils.AbsoluteShort .ExpiredUnix)}}{{else}}{{ctx.Locale.Tr "settings.valid_forever"}}{{end}}</i> <i>{{if not .ExpiredUnix.IsZero}}{{ctx.Locale.Tr "settings.valid_until_date" (DateUtils.AbsoluteShort .ExpiredUnix)}}{{else}}{{ctx.Locale.Tr "settings.valid_forever"}}{{end}}</i>
</div> </div>
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">

View File

@ -22,7 +22,7 @@
<div class="flex-item-main"> <div class="flex-item-main">
<div class="flex-item-title">{{.Name}}</div> <div class="flex-item-title">{{.Name}}</div>
<div class="flex-item-body"> <div class="flex-item-body">
<i>{{ctx.Locale.Tr "settings.added_on" (ctx.DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info" 16}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{ctx.DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i> <i>{{ctx.Locale.Tr "settings.added_on" (DateUtils.AbsoluteShort .CreatedUnix)}}{{svg "octicon-info" 16}} {{if .HasUsed}}{{ctx.Locale.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{DateUtils.AbsoluteShort .UpdatedUnix}}</span>{{else}}{{ctx.Locale.Tr "settings.no_activity"}}{{end}}</i>
</div> </div>
</div> </div>
<div class="flex-item-trailing"> <div class="flex-item-trailing">

Some files were not shown because too many files have changed in this diff Show More