mirror of
https://github.com/go-gitea/gitea.git
synced 2025-01-20 17:24:16 +08:00
Merge branch 'main' into enhancement/show-blocking-in-issue-list
This commit is contained in:
commit
33fb710bbf
|
@ -8,7 +8,9 @@
|
|||
},
|
||||
"ghcr.io/devcontainers/features/git-lfs:1.1.0": {},
|
||||
"ghcr.io/devcontainers-contrib/features/poetry:2": {},
|
||||
"ghcr.io/devcontainers/features/python:1": {}
|
||||
"ghcr.io/devcontainers/features/python:1": {
|
||||
"version": "3.12"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
|
|
|
@ -832,7 +832,7 @@ Default templates for project boards:
|
|||
## Issue and pull request attachments (`attachment`)
|
||||
|
||||
- `ENABLED`: **true**: Whether issue and pull request attachments are enabled.
|
||||
- `ALLOWED_TYPES`: **.csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
|
||||
- `ALLOWED_TYPES`: **.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
|
||||
- `MAX_SIZE`: **2048**: Maximum size (MB).
|
||||
- `MAX_FILES`: **5**: Maximum number of attachments that can be uploaded at once.
|
||||
- `STORAGE_TYPE`: **local**: Storage type for attachments, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]`
|
||||
|
|
|
@ -782,7 +782,7 @@ Gitea 创建以下非唯一队列:
|
|||
## 工单和合并请求的附件 (`attachment`)
|
||||
|
||||
- `ENABLED`: **true**: 是否允许用户上传附件。
|
||||
- `ALLOWED_TYPES`: **.csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: 允许的文件扩展名(`.zip`)、mime 类型(`text/plain`)或通配符类型(`image/*`、`audio/*`、`video/*`)的逗号分隔列表。空值或 `*/*` 允许所有类型。
|
||||
- `ALLOWED_TYPES`: **.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: 允许的文件扩展名(`.zip`)、mime 类型(`text/plain`)或通配符类型(`image/*`、`audio/*`、`video/*`)的逗号分隔列表。空值或 `*/*` 允许所有类型。
|
||||
- `MAX_SIZE`: **2048**: 附件的最大限制(MB)。
|
||||
- `MAX_FILES`: **5**: 一次最多上传的附件数量。
|
||||
- `STORAGE_TYPE`: **local**: 附件的存储类型,`local` 表示本地磁盘,`minio` 表示兼容 S3 的对象存储服务,如果未设置将使用默认值 `local` 或其他在 `[storage.xxx]` 中定义的名称。
|
||||
|
|
|
@ -233,21 +233,21 @@ func (b *Indexer) Delete(_ context.Context, repoID int64) error {
|
|||
|
||||
// Search searches for files in the specified repo.
|
||||
// Returns the matching file-paths
|
||||
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
var (
|
||||
indexerQuery query.Query
|
||||
keywordQuery query.Query
|
||||
)
|
||||
|
||||
if isMatch {
|
||||
prefixQuery := bleve.NewPrefixQuery(keyword)
|
||||
prefixQuery.FieldVal = "Content"
|
||||
keywordQuery = prefixQuery
|
||||
} else {
|
||||
if isFuzzy {
|
||||
phraseQuery := bleve.NewMatchPhraseQuery(keyword)
|
||||
phraseQuery.FieldVal = "Content"
|
||||
phraseQuery.Analyzer = repoIndexerAnalyzer
|
||||
keywordQuery = phraseQuery
|
||||
} else {
|
||||
prefixQuery := bleve.NewPrefixQuery(keyword)
|
||||
prefixQuery.FieldVal = "Content"
|
||||
keywordQuery = prefixQuery
|
||||
}
|
||||
|
||||
if len(repoIDs) > 0 {
|
||||
|
|
|
@ -281,10 +281,10 @@ func extractAggs(searchResult *elastic.SearchResult) []*internal.SearchResultLan
|
|||
}
|
||||
|
||||
// Search searches for codes and language stats by given conditions.
|
||||
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
searchType := esMultiMatchTypeBestFields
|
||||
if isMatch {
|
||||
searchType = esMultiMatchTypePhrasePrefix
|
||||
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
|
||||
searchType := esMultiMatchTypePhrasePrefix
|
||||
if isFuzzy {
|
||||
searchType = esMultiMatchTypeBestFields
|
||||
}
|
||||
|
||||
kwQuery := elastic.NewMultiMatchQuery(keyword, "content").Type(searchType)
|
||||
|
|
|
@ -70,7 +70,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
|
|||
|
||||
for _, kw := range keywords {
|
||||
t.Run(kw.Keyword, func(t *testing.T) {
|
||||
total, res, langs, err := indexer.Search(context.TODO(), kw.RepoIDs, "", kw.Keyword, 1, 10, false)
|
||||
total, res, langs, err := indexer.Search(context.TODO(), kw.RepoIDs, "", kw.Keyword, 1, 10, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, kw.IDs, int(total))
|
||||
assert.Len(t, langs, kw.Langs)
|
||||
|
|
|
@ -16,7 +16,7 @@ type Indexer interface {
|
|||
internal.Indexer
|
||||
Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error
|
||||
Delete(ctx context.Context, repoID int64) error
|
||||
Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*SearchResult, []*SearchResultLanguages, error)
|
||||
Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*SearchResult, []*SearchResultLanguages, error)
|
||||
}
|
||||
|
||||
// NewDummyIndexer returns a dummy indexer
|
||||
|
@ -38,6 +38,6 @@ func (d *dummyIndexer) Delete(ctx context.Context, repoID int64) error {
|
|||
return fmt.Errorf("indexer is not ready")
|
||||
}
|
||||
|
||||
func (d *dummyIndexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*SearchResult, []*SearchResultLanguages, error) {
|
||||
func (d *dummyIndexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*SearchResult, []*SearchResultLanguages, error) {
|
||||
return 0, nil, nil, fmt.Errorf("indexer is not ready")
|
||||
}
|
||||
|
|
|
@ -124,12 +124,13 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
|
|||
}
|
||||
|
||||
// PerformSearch perform a search on a repository
|
||||
func PerformSearch(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int, []*Result, []*internal.SearchResultLanguages, error) {
|
||||
// if isFuzzy is true set the Damerau-Levenshtein distance from 0 to 2
|
||||
func PerformSearch(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int, []*Result, []*internal.SearchResultLanguages, error) {
|
||||
if len(keyword) == 0 {
|
||||
return 0, nil, nil, nil
|
||||
}
|
||||
|
||||
total, results, resultLanguages, err := (*globalIndexer.Load()).Search(ctx, repoIDs, language, keyword, page, pageSize, isMatch)
|
||||
total, results, resultLanguages, err := (*globalIndexer.Load()).Search(ctx, repoIDs, language, keyword, page, pageSize, isFuzzy)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
|
|
@ -25,6 +25,13 @@ func MatchPhraseQuery(matchPhrase, field, analyzer string) *query.MatchPhraseQue
|
|||
return q
|
||||
}
|
||||
|
||||
// PrefixQuery generates a match prefix query for the given prefix and field
|
||||
func PrefixQuery(matchPrefix, field string) *query.PrefixQuery {
|
||||
q := bleve.NewPrefixQuery(matchPrefix)
|
||||
q.FieldVal = field
|
||||
return q
|
||||
}
|
||||
|
||||
// BoolFieldQuery generates a bool field query for the given value and field
|
||||
func BoolFieldQuery(value bool, field string) *query.BoolFieldQuery {
|
||||
q := bleve.NewBoolFieldQuery(value)
|
||||
|
|
|
@ -156,12 +156,19 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
|||
var queries []query.Query
|
||||
|
||||
if options.Keyword != "" {
|
||||
keywordQueries := []query.Query{
|
||||
inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer),
|
||||
inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer),
|
||||
inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer),
|
||||
if options.IsFuzzyKeyword {
|
||||
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
|
||||
inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer),
|
||||
inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer),
|
||||
inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer),
|
||||
}...))
|
||||
} else {
|
||||
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
|
||||
inner_bleve.PrefixQuery(options.Keyword, "title"),
|
||||
inner_bleve.PrefixQuery(options.Keyword, "content"),
|
||||
inner_bleve.PrefixQuery(options.Keyword, "comments"),
|
||||
}...))
|
||||
}
|
||||
queries = append(queries, bleve.NewDisjunctionQuery(keywordQueries...))
|
||||
}
|
||||
|
||||
if len(options.RepoIDs) > 0 || options.AllPublic {
|
||||
|
|
|
@ -19,6 +19,10 @@ import (
|
|||
|
||||
const (
|
||||
issueIndexerLatestVersion = 1
|
||||
// multi-match-types, currently only 2 types are used
|
||||
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
|
||||
esMultiMatchTypeBestFields = "best_fields"
|
||||
esMultiMatchTypePhrasePrefix = "phrase_prefix"
|
||||
)
|
||||
|
||||
var _ internal.Indexer = &Indexer{}
|
||||
|
@ -141,7 +145,13 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
|||
query := elastic.NewBoolQuery()
|
||||
|
||||
if options.Keyword != "" {
|
||||
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments"))
|
||||
|
||||
searchType := esMultiMatchTypePhrasePrefix
|
||||
if options.IsFuzzyKeyword {
|
||||
searchType = esMultiMatchTypeBestFields
|
||||
}
|
||||
|
||||
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(searchType))
|
||||
}
|
||||
|
||||
if len(options.RepoIDs) > 0 {
|
||||
|
|
|
@ -74,6 +74,8 @@ type SearchResult struct {
|
|||
type SearchOptions struct {
|
||||
Keyword string // keyword to search
|
||||
|
||||
IsFuzzyKeyword bool // if false the levenshtein distance is 0
|
||||
|
||||
RepoIDs []int64 // repository IDs which the issues belong to
|
||||
AllPublic bool // if include all public repositories
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ package meilisearch
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
@ -16,12 +17,15 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
issueIndexerLatestVersion = 2
|
||||
issueIndexerLatestVersion = 3
|
||||
|
||||
// TODO: make this configurable if necessary
|
||||
maxTotalHits = 10000
|
||||
)
|
||||
|
||||
// ErrMalformedResponse is never expected as we initialize the indexer ourself and so define the types.
|
||||
var ErrMalformedResponse = errors.New("meilisearch returned unexpected malformed content")
|
||||
|
||||
var _ internal.Indexer = &Indexer{}
|
||||
|
||||
// Indexer implements Indexer interface
|
||||
|
@ -47,6 +51,9 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
|
|||
},
|
||||
DisplayedAttributes: []string{
|
||||
"id",
|
||||
"title",
|
||||
"content",
|
||||
"comments",
|
||||
},
|
||||
FilterableAttributes: []string{
|
||||
"repo_id",
|
||||
|
@ -221,11 +228,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
|
|||
return nil, err
|
||||
}
|
||||
|
||||
hits := make([]internal.Match, 0, len(searchRes.Hits))
|
||||
for _, hit := range searchRes.Hits {
|
||||
hits = append(hits, internal.Match{
|
||||
ID: int64(hit.(map[string]any)["id"].(float64)),
|
||||
})
|
||||
hits, err := nonFuzzyWorkaround(searchRes, options.Keyword, options.IsFuzzyKeyword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &internal.SearchResult{
|
||||
|
@ -241,3 +246,77 @@ func parseSortBy(sortBy internal.SortBy) string {
|
|||
}
|
||||
return field + ":asc"
|
||||
}
|
||||
|
||||
// nonFuzzyWorkaround is needed as meilisearch does not have an exact search
|
||||
// and you can only change "typo tolerance" per index. So we have to post-filter the results
|
||||
// https://www.meilisearch.com/docs/learn/configuration/typo_tolerance#configuring-typo-tolerance
|
||||
// TODO: remove once https://github.com/orgs/meilisearch/discussions/377 is addressed
|
||||
func nonFuzzyWorkaround(searchRes *meilisearch.SearchResponse, keyword string, isFuzzy bool) ([]internal.Match, error) {
|
||||
hits := make([]internal.Match, 0, len(searchRes.Hits))
|
||||
for _, hit := range searchRes.Hits {
|
||||
hit, ok := hit.(map[string]any)
|
||||
if !ok {
|
||||
return nil, ErrMalformedResponse
|
||||
}
|
||||
|
||||
if !isFuzzy {
|
||||
keyword = strings.ToLower(keyword)
|
||||
|
||||
// declare a anon func to check if the title, content or at least one comment contains the keyword
|
||||
found, err := func() (bool, error) {
|
||||
// check if title match first
|
||||
title, ok := hit["title"].(string)
|
||||
if !ok {
|
||||
return false, ErrMalformedResponse
|
||||
} else if strings.Contains(strings.ToLower(title), keyword) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// check if content has a match
|
||||
content, ok := hit["content"].(string)
|
||||
if !ok {
|
||||
return false, ErrMalformedResponse
|
||||
} else if strings.Contains(strings.ToLower(content), keyword) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// now check for each comment if one has a match
|
||||
// so we first try to cast and skip if there are no comments
|
||||
comments, ok := hit["comments"].([]any)
|
||||
if !ok {
|
||||
return false, ErrMalformedResponse
|
||||
} else if len(comments) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// now we iterate over all and report as soon as we detect one match
|
||||
for i := range comments {
|
||||
comment, ok := comments[i].(string)
|
||||
if !ok {
|
||||
return false, ErrMalformedResponse
|
||||
}
|
||||
if strings.Contains(strings.ToLower(comment), keyword) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// we got no match
|
||||
return false, nil
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
issueID, ok := hit["id"].(float64)
|
||||
if !ok {
|
||||
return nil, ErrMalformedResponse
|
||||
}
|
||||
hits = append(hits, internal.Match{
|
||||
ID: int64(issueID),
|
||||
})
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/indexer/issues/internal"
|
||||
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMeilisearchIndexer(t *testing.T) {
|
||||
|
@ -48,3 +52,44 @@ func TestMeilisearchIndexer(t *testing.T) {
|
|||
|
||||
tests.TestIndexer(t, indexer)
|
||||
}
|
||||
|
||||
func TestNonFuzzyWorkaround(t *testing.T) {
|
||||
// get unexpected return
|
||||
_, err := nonFuzzyWorkaround(&meilisearch.SearchResponse{
|
||||
Hits: []any{"aa", "bb", "cc", "dd"},
|
||||
}, "bowling", false)
|
||||
assert.ErrorIs(t, err, ErrMalformedResponse)
|
||||
|
||||
validResponse := &meilisearch.SearchResponse{
|
||||
Hits: []any{
|
||||
map[string]any{
|
||||
"id": float64(11),
|
||||
"title": "a title",
|
||||
"content": "issue body with no match",
|
||||
"comments": []any{"hey whats up?", "I'm currently bowling", "nice"},
|
||||
},
|
||||
map[string]any{
|
||||
"id": float64(22),
|
||||
"title": "Bowling as title",
|
||||
"content": "",
|
||||
"comments": []any{},
|
||||
},
|
||||
map[string]any{
|
||||
"id": float64(33),
|
||||
"title": "Bowl-ing as fuzzy match",
|
||||
"content": "",
|
||||
"comments": []any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// nonFuzzy
|
||||
hits, err := nonFuzzyWorkaround(validResponse, "bowling", false)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, []internal.Match{{ID: 11}, {ID: 22}}, hits)
|
||||
|
||||
// fuzzy
|
||||
hits, err = nonFuzzyWorkaround(validResponse, "bowling", true)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualValues(t, []internal.Match{{ID: 11}, {ID: 22}, {ID: 33}}, hits)
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ var Attachment = struct {
|
|||
Enabled bool
|
||||
}{
|
||||
Storage: &Storage{},
|
||||
AllowedTypes: ".csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip",
|
||||
AllowedTypes: ".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip",
|
||||
MaxSize: 2048,
|
||||
MaxFiles: 5,
|
||||
Enabled: true,
|
||||
|
@ -25,7 +25,7 @@ func loadAttachmentFrom(rootCfg ConfigProvider) (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip")
|
||||
Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip")
|
||||
Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(2048)
|
||||
Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(5)
|
||||
Attachment.Enabled = sec.Key("ENABLED").MustBool(true)
|
||||
|
|
|
@ -35,7 +35,7 @@ func Code(ctx *context.Context) {
|
|||
keyword := ctx.FormTrim("q")
|
||||
|
||||
queryType := ctx.FormTrim("t")
|
||||
isMatch := queryType == "match"
|
||||
isFuzzy := queryType != "match"
|
||||
|
||||
ctx.Data["Keyword"] = keyword
|
||||
ctx.Data["Language"] = language
|
||||
|
@ -77,7 +77,7 @@ func Code(ctx *context.Context) {
|
|||
)
|
||||
|
||||
if (len(repoIDs) > 0) || isAdmin {
|
||||
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch)
|
||||
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isFuzzy)
|
||||
if err != nil {
|
||||
if code_indexer.IsAvailable(ctx) {
|
||||
ctx.ServerError("SearchResults", err)
|
||||
|
|
|
@ -25,7 +25,7 @@ func Search(ctx *context.Context) {
|
|||
keyword := ctx.FormTrim("q")
|
||||
|
||||
queryType := ctx.FormTrim("t")
|
||||
isMatch := queryType == "match"
|
||||
isFuzzy := queryType != "match"
|
||||
|
||||
ctx.Data["Keyword"] = keyword
|
||||
ctx.Data["Language"] = language
|
||||
|
@ -43,7 +43,7 @@ func Search(ctx *context.Context) {
|
|||
}
|
||||
|
||||
total, searchResults, searchResultLanguages, err := code_indexer.PerformSearch(ctx, []int64{ctx.Repo.Repository.ID},
|
||||
language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch)
|
||||
language, keyword, page, setting.UI.RepoSearchPagingNum, isFuzzy)
|
||||
if err != nil {
|
||||
if code_indexer.IsAvailable(ctx) {
|
||||
ctx.ServerError("SearchResults", err)
|
||||
|
|
|
@ -40,7 +40,7 @@ func CodeSearch(ctx *context.Context) {
|
|||
keyword := ctx.FormTrim("q")
|
||||
|
||||
queryType := ctx.FormTrim("t")
|
||||
isMatch := queryType == "match"
|
||||
isFuzzy := queryType != "match"
|
||||
|
||||
ctx.Data["Keyword"] = keyword
|
||||
ctx.Data["Language"] = language
|
||||
|
@ -75,7 +75,7 @@ func CodeSearch(ctx *context.Context) {
|
|||
)
|
||||
|
||||
if len(repoIDs) > 0 {
|
||||
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch)
|
||||
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isFuzzy)
|
||||
if err != nil {
|
||||
if code_indexer.IsAvailable(ctx) {
|
||||
ctx.ServerError("SearchResults", err)
|
||||
|
|
|
@ -59,7 +59,7 @@ func (p *AuthSourceProvider) DisplayName() string {
|
|||
|
||||
func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
|
||||
if p.iconURL != "" {
|
||||
img := fmt.Sprintf(`<img class="gt-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`,
|
||||
img := fmt.Sprintf(`<img class="tw-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`,
|
||||
size,
|
||||
size,
|
||||
html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),
|
||||
|
|
|
@ -59,7 +59,7 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus,
|
|||
}
|
||||
}
|
||||
|
||||
if matchedCount == 0 {
|
||||
if matchedCount == 0 && returnedStatus == structs.CommitStatusSuccess {
|
||||
status := git_model.CalcCommitStatus(commitStatuses)
|
||||
if status != nil {
|
||||
return status.State
|
||||
|
|
|
@ -31,6 +31,9 @@ export default {
|
|||
isProduction && '!./web_src/js/standalone/devtest.js',
|
||||
'!./templates/swagger/v1_json.tmpl',
|
||||
'!./templates/user/auth/oidc_wellknown.tmpl',
|
||||
'!**/*_test.go',
|
||||
'!./modules/{public,options,templates}/bindata.go',
|
||||
'./{build,models,modules,routers,services}/**/*.go',
|
||||
'./templates/**/*.tmpl',
|
||||
'./web_src/js/**/*.{js,vue}',
|
||||
].filter(Boolean),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{if .Statuses}}
|
||||
{{if and (eq (len .Statuses) 1) .Status.TargetURL}}
|
||||
<a class="gt-vm {{.AdditionalClasses}} gt-no-underline" data-tippy="commit-statuses" href="{{.Status.TargetURL}}">
|
||||
<a class="gt-vm {{.AdditionalClasses}} tw-no-underline" data-tippy="commit-statuses" href="{{.Status.TargetURL}}">
|
||||
{{template "repo/commit_status" .Status}}
|
||||
</a>
|
||||
{{else}}
|
||||
|
|
|
@ -80,7 +80,7 @@
|
|||
</div>
|
||||
{{else if eq .Type 1}}
|
||||
<div class="timeline-item event" id="{{.HashTag}}">
|
||||
<span class="badge gt-bg-green gt-text-white">{{svg "octicon-dot-fill"}}</span>
|
||||
<span class="badge tw-bg-green tw-text-white">{{svg "octicon-dot-fill"}}</span>
|
||||
{{if not .OriginalAuthor}}
|
||||
{{template "shared/user/avatarlink" dict "user" .Poster}}
|
||||
{{end}}
|
||||
|
@ -95,7 +95,7 @@
|
|||
</div>
|
||||
{{else if eq .Type 2}}
|
||||
<div class="timeline-item event" id="{{.HashTag}}">
|
||||
<span class="badge gt-bg-red gt-text-white">{{svg "octicon-circle-slash"}}</span>
|
||||
<span class="badge tw-bg-red tw-text-white">{{svg "octicon-circle-slash"}}</span>
|
||||
{{if not .OriginalAuthor}}
|
||||
{{template "shared/user/avatarlink" dict "user" .Poster}}
|
||||
{{end}}
|
||||
|
@ -110,7 +110,7 @@
|
|||
</div>
|
||||
{{else if eq .Type 28}}
|
||||
<div class="timeline-item event" id="{{.HashTag}}">
|
||||
<span class="badge gt-bg-purple gt-text-white">{{svg "octicon-git-merge"}}</span>
|
||||
<span class="badge tw-bg-purple tw-text-white">{{svg "octicon-git-merge"}}</span>
|
||||
{{if not .OriginalAuthor}}
|
||||
{{template "shared/user/avatarlink" dict "user" .Poster}}
|
||||
{{end}}
|
||||
|
@ -379,7 +379,7 @@
|
|||
{{ctx.AvatarUtils.Avatar .Poster 40}}
|
||||
</a>
|
||||
{{end}}
|
||||
<span class="badge{{if eq .Review.Type 1}} gt-bg-green gt-text-white{{else if eq .Review.Type 3}} gt-bg-red gt-text-white{{end}}">{{svg (printf "octicon-%s" .Review.Type.Icon)}}</span>
|
||||
<span class="badge{{if eq .Review.Type 1}} tw-bg-green tw-text-white{{else if eq .Review.Type 3}} tw-bg-red tw-text-white{{end}}">{{svg (printf "octicon-%s" .Review.Type.Icon)}}</span>
|
||||
<span class="text grey muted-links">
|
||||
{{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}}
|
||||
{{if eq .Review.Type 1}}
|
||||
|
|
|
@ -108,7 +108,7 @@
|
|||
{{end}}
|
||||
|
||||
{{if gt .Activity.PublishedReleaseCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="published-releases">
|
||||
<h4 class="divider divider-text tw-normal-case" id="published-releases">
|
||||
{{svg "octicon-tag" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.releases_published_by"
|
||||
(ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount)
|
||||
|
@ -130,7 +130,7 @@
|
|||
{{end}}
|
||||
|
||||
{{if gt .Activity.MergedPRCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="merged-pull-requests">
|
||||
<h4 class="divider divider-text tw-normal-case" id="merged-pull-requests">
|
||||
{{svg "octicon-git-pull-request" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.prs_merged_by"
|
||||
(ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount)
|
||||
|
@ -149,7 +149,7 @@
|
|||
{{end}}
|
||||
|
||||
{{if gt .Activity.OpenedPRCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests">
|
||||
<h4 class="divider divider-text tw-normal-case" id="proposed-pull-requests">
|
||||
{{svg "octicon-git-branch" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.prs_opened_by"
|
||||
(ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount)
|
||||
|
@ -168,7 +168,7 @@
|
|||
{{end}}
|
||||
|
||||
{{if gt .Activity.ClosedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="closed-issues">
|
||||
<h4 class="divider divider-text tw-normal-case" id="closed-issues">
|
||||
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.issues_closed_from"
|
||||
(ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount)
|
||||
|
@ -187,7 +187,7 @@
|
|||
{{end}}
|
||||
|
||||
{{if gt .Activity.OpenedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="new-issues">
|
||||
<h4 class="divider divider-text tw-normal-case" id="new-issues">
|
||||
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.Tr "repo.activity.title.issues_created_by"
|
||||
(ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount)
|
||||
|
@ -206,7 +206,7 @@
|
|||
{{end}}
|
||||
|
||||
{{if gt .Activity.UnresolvedIssueCount 0}}
|
||||
<h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
|
||||
<h4 class="divider divider-text tw-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
|
||||
{{svg "octicon-comment-discussion" 16 "gt-mr-3"}}
|
||||
{{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}}
|
||||
</h4>
|
||||
|
|
|
@ -68,7 +68,7 @@
|
|||
{{range .Runners}}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="ui {{if .IsOnline}}green{{else}}basic{{end}} label">{{.StatusLocaleName ctx.Locale}}</span>
|
||||
<span class="ui {{if .IsOnline}}green{{end}} label">{{.StatusLocaleName ctx.Locale}}</span>
|
||||
</td>
|
||||
<td>{{.ID}}</td>
|
||||
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<div class="flex-item-main">
|
||||
<div class="flex-item-header">
|
||||
<div class="flex-item-title">
|
||||
<a class="gt-no-underline issue-title" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">{{RenderEmoji $.Context .Title | RenderCodeBlock}}</a>
|
||||
<a class="tw-no-underline issue-title" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">{{RenderEmoji $.Context .Title | RenderCodeBlock}}</a>
|
||||
{{if .IsPull}}
|
||||
{{if (index $.CommitStatuses .PullRequest.ID)}}
|
||||
{{template "repo/commit_statuses" dict "Status" (index $.CommitLastStatus .PullRequest.ID) "Statuses" (index $.CommitStatuses .PullRequest.ID)}}
|
||||
|
@ -36,7 +36,7 @@
|
|||
{{if .Assignees}}
|
||||
<div class="text grey">
|
||||
{{range .Assignees}}
|
||||
<a class="ui assignee gt-no-underline" href="{{.HomeLink}}" data-tooltip-content="{{.GetDisplayName}}">
|
||||
<a class="ui assignee tw-no-underline" href="{{.HomeLink}}" data-tooltip-content="{{.GetDisplayName}}">
|
||||
{{ctx.AvatarUtils.Avatar . 20}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
@ -44,7 +44,7 @@
|
|||
{{end}}
|
||||
{{if .NumComments}}
|
||||
<div class="text grey">
|
||||
<a class="gt-no-underline muted flex-text-block" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">
|
||||
<a class="tw-no-underline muted flex-text-block" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">
|
||||
{{svg "octicon-comment" 16}}{{.NumComments}}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div class="ui container">
|
||||
<div class="flex-container">
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui secondary vertical filter menu gt-bg-transparent">
|
||||
<div class="ui secondary vertical filter menu tw-bg-transparent">
|
||||
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{.Link}}?type=your_repositories&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
|
||||
{{ctx.Locale.Tr "home.issues.in_your_repos"}}
|
||||
<strong>{{CountFmt .IssueStats.YourRepositoriesCount}}</strong>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div class="ui container">
|
||||
<div class="flex-container">
|
||||
<div class="flex-container-nav">
|
||||
<div class="ui secondary vertical filter menu gt-bg-transparent">
|
||||
<div class="ui secondary vertical filter menu tw-bg-transparent">
|
||||
<div class="item">
|
||||
{{ctx.Locale.Tr "home.issues.in_your_repos"}}
|
||||
<strong>{{.Total}}</strong>
|
||||
|
|
|
@ -872,10 +872,18 @@ img.ui.avatar,
|
|||
border-color: var(--color-error-border) !important;
|
||||
}
|
||||
|
||||
/* A fix for text visibility issue in Chrome autofill in dark mode. */
|
||||
/* It's a problem from Formatic UI, and this rule overrides it. */
|
||||
.ui.form .field.field input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--color-black) !important;
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:active,
|
||||
.ui.form .field.field input:-webkit-autofill,
|
||||
.ui.form .field.field input:-webkit-autofill:focus,
|
||||
.ui.form .field.field input:-webkit-autofill:hover,
|
||||
.ui.form .field.field input:-webkit-autofill:active {
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: var(--color-text);
|
||||
box-shadow: 0 0 0 100px var(--color-primary-light-6) inset !important;
|
||||
border-color: var(--color-primary-light-4) !important;
|
||||
}
|
||||
|
||||
.ui.form .field.muted {
|
||||
|
|
|
@ -46,12 +46,6 @@ Gitea's private styles use `g-` prefix.
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* below class names match Tailwind CSS */
|
||||
.gt-object-contain { object-fit: contain !important; }
|
||||
.gt-no-underline { text-decoration-line: none !important; }
|
||||
.gt-normal-case { text-transform: none !important; }
|
||||
.gt-italic { font-style: italic !important; }
|
||||
|
||||
.gt-font-light { font-weight: var(--font-weight-light) !important; }
|
||||
.gt-font-normal { font-weight: var(--font-weight-normal) !important; }
|
||||
.gt-font-medium { font-weight: var(--font-weight-medium) !important; }
|
||||
|
@ -70,23 +64,6 @@ Gitea's private styles use `g-` prefix.
|
|||
.gt-border-secondary-left { border-left: 1px solid var(--color-secondary) !important; }
|
||||
.gt-border-secondary-right { border-right: 1px solid var(--color-secondary) !important; }
|
||||
|
||||
.gt-bg-red { background: var(--color-red) !important; }
|
||||
.gt-bg-orange { background: var(--color-orange) !important; }
|
||||
.gt-bg-yellow { background: var(--color-yellow) !important; }
|
||||
.gt-bg-olive { background: var(--color-olive) !important; }
|
||||
.gt-bg-green { background: var(--color-green) !important; }
|
||||
.gt-bg-teal { background: var(--color-teal) !important; }
|
||||
.gt-bg-blue { background: var(--color-blue) !important; }
|
||||
.gt-bg-violet { background: var(--color-violet) !important; }
|
||||
.gt-bg-purple { background: var(--color-purple) !important; }
|
||||
.gt-bg-pink { background: var(--color-pink) !important; }
|
||||
.gt-bg-brown { background: var(--color-brown) !important; }
|
||||
.gt-bg-grey { background: var(--color-grey) !important; }
|
||||
.gt-bg-gold { background: var(--color-gold) !important; }
|
||||
.gt-bg-transparent { background: transparent !important; }
|
||||
|
||||
.gt-text-white { color: var(--color-white) !important; }
|
||||
|
||||
.interact-fg { color: inherit !important; }
|
||||
.interact-fg:hover { color: var(--color-primary) !important; }
|
||||
.interact-fg:active { color: var(--color-primary-active) !important; }
|
||||
|
|
8
web_src/js/bootstrap.js
vendored
8
web_src/js/bootstrap.js
vendored
|
@ -6,10 +6,18 @@
|
|||
// This file must be imported before any lazy-loading is being attempted.
|
||||
__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
|
||||
|
||||
const filteredErrors = new Set([
|
||||
'getModifierState is not a function', // https://github.com/microsoft/monaco-editor/issues/4325
|
||||
]);
|
||||
|
||||
export function showGlobalErrorMessage(msg) {
|
||||
const pageContent = document.querySelector('.page-content');
|
||||
if (!pageContent) return;
|
||||
|
||||
for (const filteredError of filteredErrors) {
|
||||
if (msg.includes(filteredError)) return;
|
||||
}
|
||||
|
||||
// compact the message to a data attribute to avoid too many duplicated messages
|
||||
const msgCompact = msg.replace(/\W/g, '').trim();
|
||||
let msgDiv = pageContent.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||
|
|
|
@ -3,7 +3,7 @@ import '@github/text-expander-element';
|
|||
import $ from 'jquery';
|
||||
import {attachTribute} from '../tribute.js';
|
||||
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
|
||||
import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
|
||||
import {initEasyMDEPaste, initTextareaPaste} from './Paste.js';
|
||||
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
|
||||
import {renderPreviewPanelContent} from '../repo-editor.js';
|
||||
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
|
||||
|
@ -84,6 +84,17 @@ class ComboMarkdownEditor {
|
|||
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
|
||||
}
|
||||
|
||||
this.textarea.addEventListener('keydown', (e) => {
|
||||
if (e.shiftKey) {
|
||||
e.target._shiftDown = true;
|
||||
}
|
||||
});
|
||||
this.textarea.addEventListener('keyup', (e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.target._shiftDown = false;
|
||||
}
|
||||
});
|
||||
|
||||
const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
|
||||
const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
|
||||
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
|
||||
|
@ -108,7 +119,7 @@ class ComboMarkdownEditor {
|
|||
});
|
||||
|
||||
if (this.dropzone) {
|
||||
initTextareaImagePaste(this.textarea, this.dropzone);
|
||||
initTextareaPaste(this.textarea, this.dropzone);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -241,7 +252,7 @@ class ComboMarkdownEditor {
|
|||
});
|
||||
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
|
||||
await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
|
||||
initEasyMDEImagePaste(this.easyMDE, this.dropzone);
|
||||
initEasyMDEPaste(this.easyMDE, this.dropzone);
|
||||
hideElem(this.textareaMarkdownToolbar);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import {htmlEscape} from 'escape-goat';
|
||||
import {POST} from '../../modules/fetch.js';
|
||||
import {imageInfo} from '../../utils/image.js';
|
||||
import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js';
|
||||
import {isUrl} from '../../utils/url.js';
|
||||
|
||||
async function uploadFile(file, uploadUrl) {
|
||||
const formData = new FormData();
|
||||
|
@ -10,17 +12,6 @@ async function uploadFile(file, uploadUrl) {
|
|||
return await res.json();
|
||||
}
|
||||
|
||||
function clipboardPastedImages(e) {
|
||||
if (!e.clipboardData) return [];
|
||||
|
||||
const files = [];
|
||||
for (const item of e.clipboardData.items || []) {
|
||||
if (!item.type || !item.type.startsWith('image/')) continue;
|
||||
files.push(item.getAsFile());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function triggerEditorContentChanged(target) {
|
||||
target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
|
||||
}
|
||||
|
@ -91,20 +82,16 @@ class CodeMirrorEditor {
|
|||
}
|
||||
}
|
||||
|
||||
const uploadClipboardImage = async (editor, dropzone, e) => {
|
||||
async function handleClipboardImages(editor, dropzone, images, e) {
|
||||
const uploadUrl = dropzone.getAttribute('data-upload-url');
|
||||
const filesContainer = dropzone.querySelector('.files');
|
||||
|
||||
if (!uploadUrl || !filesContainer) return;
|
||||
if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
|
||||
|
||||
const pastedImages = clipboardPastedImages(e);
|
||||
if (!pastedImages || pastedImages.length === 0) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
for (const img of pastedImages) {
|
||||
for (const img of images) {
|
||||
const name = img.name.slice(0, img.name.lastIndexOf('.'));
|
||||
|
||||
const placeholder = `![${name}](uploading ...)`;
|
||||
|
@ -131,18 +118,37 @@ const uploadClipboardImage = async (editor, dropzone, e) => {
|
|||
input.value = uuid;
|
||||
filesContainer.append(input);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function initEasyMDEImagePaste(easyMDE, dropzone) {
|
||||
if (!dropzone) return;
|
||||
easyMDE.codemirror.on('paste', async (_, e) => {
|
||||
return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
|
||||
function handleClipboardText(textarea, text, e) {
|
||||
// when pasting links over selected text, turn it into [text](link), except when shift key is held
|
||||
const {value, selectionStart, selectionEnd, _shiftDown} = textarea;
|
||||
if (_shiftDown) return;
|
||||
const selectedText = value.substring(selectionStart, selectionEnd);
|
||||
const trimmedText = text.trim();
|
||||
if (selectedText && isUrl(trimmedText)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
|
||||
}
|
||||
}
|
||||
|
||||
export function initEasyMDEPaste(easyMDE, dropzone) {
|
||||
easyMDE.codemirror.on('paste', (_, e) => {
|
||||
const {images} = getPastedContent(e);
|
||||
if (images.length) {
|
||||
handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initTextareaImagePaste(textarea, dropzone) {
|
||||
if (!dropzone) return;
|
||||
textarea.addEventListener('paste', async (e) => {
|
||||
return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e);
|
||||
export function initTextareaPaste(textarea, dropzone) {
|
||||
textarea.addEventListener('paste', (e) => {
|
||||
const {images, text} = getPastedContent(e);
|
||||
if (images.length) {
|
||||
handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
|
||||
} else if (text) {
|
||||
handleClipboardText(textarea, text, e);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -22,13 +22,16 @@ export function initCompWebHookEditor() {
|
|||
});
|
||||
}
|
||||
|
||||
const updateContentType = function () {
|
||||
const visible = document.getElementById('http_method').value === 'POST';
|
||||
toggleElem(document.getElementById('content_type').parentNode.parentNode, visible);
|
||||
};
|
||||
updateContentType();
|
||||
|
||||
document.getElementById('http_method').addEventListener('change', updateContentType);
|
||||
// some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
|
||||
const httpMethodInput = document.getElementById('http_method');
|
||||
if (httpMethodInput) {
|
||||
const updateContentType = function () {
|
||||
const visible = httpMethodInput.value === 'POST';
|
||||
toggleElem(document.getElementById('content_type').closest('.field'), visible);
|
||||
};
|
||||
updateContentType();
|
||||
httpMethodInput.addEventListener('change', updateContentType);
|
||||
}
|
||||
|
||||
// Test delivery
|
||||
document.getElementById('test-delivery')?.addEventListener('click', async function () {
|
||||
|
|
|
@ -39,7 +39,7 @@ export function initMarkupAnchors() {
|
|||
if (!href.startsWith('#user-content-')) continue;
|
||||
const originalId = href.replace(/^#user-content-/, '');
|
||||
a.setAttribute('href', `#${encodeURIComponent(originalId)}`);
|
||||
if (document.getElementsByName(originalId).length !== 1) {
|
||||
if (a.closest('.markup').querySelectorAll(`a[name="${originalId}"]`).length !== 1) {
|
||||
a.addEventListener('click', (e) => {
|
||||
scrollToAnchor(e.currentTarget.getAttribute('href'), false);
|
||||
});
|
||||
|
|
|
@ -243,3 +243,39 @@ export function isElemVisible(element) {
|
|||
|
||||
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
|
||||
}
|
||||
|
||||
// extract text and images from "paste" event
|
||||
export function getPastedContent(e) {
|
||||
const images = [];
|
||||
for (const item of e.clipboardData?.items ?? []) {
|
||||
if (item.type?.startsWith('image/')) {
|
||||
images.push(item.getAsFile());
|
||||
}
|
||||
}
|
||||
const text = e.clipboardData?.getData?.('text') ?? '';
|
||||
return {text, images};
|
||||
}
|
||||
|
||||
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
|
||||
export function replaceTextareaSelection(textarea, text) {
|
||||
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
|
||||
const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
|
||||
let success = true;
|
||||
|
||||
textarea.contentEditable = 'true';
|
||||
try {
|
||||
success = document.execCommand('insertText', false, text);
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
textarea.contentEditable = 'false';
|
||||
|
||||
if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) {
|
||||
success = false;
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
textarea.value = `${before}${text}${after}`;
|
||||
textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
export function pathEscapeSegments(s) {
|
||||
return s.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
|
||||
function stripSlash(url) {
|
||||
return url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
}
|
||||
|
||||
export function isUrl(url) {
|
||||
try {
|
||||
return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import {pathEscapeSegments} from './url.js';
|
||||
import {pathEscapeSegments, isUrl} from './url.js';
|
||||
|
||||
test('pathEscapeSegments', () => {
|
||||
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
|
||||
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
|
||||
});
|
||||
|
||||
test('isUrl', () => {
|
||||
expect(isUrl('https://example.com')).toEqual(true);
|
||||
expect(isUrl('https://example.com/')).toEqual(true);
|
||||
expect(isUrl('https://example.com/index.html')).toEqual(true);
|
||||
expect(isUrl('/index.html')).toEqual(false);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue
Block a user