mirror of
https://github.com/go-gitea/gitea.git
synced 2025-02-27 03:51:54 +08:00
Reapply "Merge remote-tracking branch 'upstream/main'"
This reverts commit a3e5eaf91727b68d8711cb4b32835450a47d8d65.
This commit is contained in:
parent
19ba51f5e5
commit
c7c1010077
@ -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
2
go.mod
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
225
modules/markup/html_commit.go
Normal file
225
modules/markup/html_commit.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
21
modules/markup/html_email.go
Normal file
21
modules/markup/html_email.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
115
modules/markup/html_emoji.go
Normal file
115
modules/markup/html_emoji.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
180
modules/markup/html_issue.go
Normal file
180
modules/markup/html_issue.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
54
modules/markup/html_mention.go
Normal file
54
modules/markup/html_mention.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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
226
modules/markup/render.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
modules/markup/render_helper.go
Normal file
21
modules/markup/render_helper.go
Normal 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
|
56
modules/markup/render_links.go
Normal file
56
modules/markup/render_links.go
Normal 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
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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())
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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")}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}} {{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}} {{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}} {{template "shared/user/avatarlink" dict "user" .DBBranch.Pusher}} {{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}} {{template "shared/user/avatarlink" dict "user" .DBBranch.Pusher}} {{template "shared/user/namelink" .DBBranch.Pusher}}{{end}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="two wide ui">
|
<td class="two wide ui">
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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"}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
143
templates/repo/issue/view_content/conversation.tmpl
Normal file
143
templates/repo/issue/view_content/conversation.tmpl
Normal 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}}
|
@ -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>
|
||||||
|
@ -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)}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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}}">
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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"
|
||||||
|
@ -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}}">
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}}
|
||||||
|
@ -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>
|
||||||
|
@ -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}}"
|
||||||
|
@ -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">
|
||||||
|
@ -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"}}"
|
||||||
|
@ -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 .}}
|
||||||
|
@ -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}}
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user