* add topic models and unit tests * fix comments * fix comment * add the UI to show or add topics for a repo * show topics on repositories list * fix test * don't show manage topics link when no permission * use green basic as topic label * fix topic label color * remove trace content * remove debug functiontags/v1.21.12.1
| @@ -0,0 +1,11 @@ | |||
| - | |||
| repo_id: 1 | |||
| topic_id: 1 | |||
| - | |||
| repo_id: 1 | |||
| topic_id: 2 | |||
| - | |||
| repo_id: 1 | |||
| topic_id: 3 | |||
| @@ -0,0 +1,13 @@ | |||
| - | |||
| id: 1 | |||
| name: golang | |||
| repo_count: 1 | |||
| - | |||
| id: 2 | |||
| name: database | |||
| repo_count: 1 | |||
| - id: 3 | |||
| name: SQL | |||
| repo_count: 1 | |||
| @@ -199,6 +199,7 @@ type Repository struct { | |||
| Size int64 `xorm:"NOT NULL DEFAULT 0"` | |||
| IndexerStatus *RepoIndexerStatus `xorm:"-"` | |||
| IsFsckEnabled bool `xorm:"NOT NULL DEFAULT true"` | |||
| Topics []string `xorm:"TEXT JSON"` | |||
| CreatedUnix util.TimeStamp `xorm:"INDEX created"` | |||
| UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | |||
| @@ -0,0 +1,192 @@ | |||
| // Copyright 2018 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package models | |||
| import ( | |||
| "fmt" | |||
| "strings" | |||
| "code.gitea.io/gitea/modules/util" | |||
| "github.com/go-xorm/builder" | |||
| ) | |||
| func init() { | |||
| tables = append(tables, | |||
| new(Topic), | |||
| new(RepoTopic), | |||
| ) | |||
| } | |||
| // Topic represents a topic of repositories | |||
| type Topic struct { | |||
| ID int64 | |||
| Name string `xorm:"unique"` | |||
| RepoCount int | |||
| CreatedUnix util.TimeStamp `xorm:"INDEX created"` | |||
| UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | |||
| } | |||
| // RepoTopic represents associated repositories and topics | |||
| type RepoTopic struct { | |||
| RepoID int64 `xorm:"unique(s)"` | |||
| TopicID int64 `xorm:"unique(s)"` | |||
| } | |||
| // ErrTopicNotExist represents an error that a topic is not exist | |||
| type ErrTopicNotExist struct { | |||
| Name string | |||
| } | |||
| // IsErrTopicNotExist checks if an error is an ErrTopicNotExist. | |||
| func IsErrTopicNotExist(err error) bool { | |||
| _, ok := err.(ErrTopicNotExist) | |||
| return ok | |||
| } | |||
| // Error implements error interface | |||
| func (err ErrTopicNotExist) Error() string { | |||
| return fmt.Sprintf("topic is not exist [name: %s]", err.Name) | |||
| } | |||
| // GetTopicByName retrieves topic by name | |||
| func GetTopicByName(name string) (*Topic, error) { | |||
| var topic Topic | |||
| if has, err := x.Where("name = ?", name).Get(&topic); err != nil { | |||
| return nil, err | |||
| } else if !has { | |||
| return nil, ErrTopicNotExist{name} | |||
| } | |||
| return &topic, nil | |||
| } | |||
| // FindTopicOptions represents the options when fdin topics | |||
| type FindTopicOptions struct { | |||
| RepoID int64 | |||
| Keyword string | |||
| Limit int | |||
| Page int | |||
| } | |||
| func (opts *FindTopicOptions) toConds() builder.Cond { | |||
| var cond = builder.NewCond() | |||
| if opts.RepoID > 0 { | |||
| cond = cond.And(builder.Eq{"repo_topic.repo_id": opts.RepoID}) | |||
| } | |||
| if opts.Keyword != "" { | |||
| cond = cond.And(builder.Like{"topic.name", opts.Keyword}) | |||
| } | |||
| return cond | |||
| } | |||
| // FindTopics retrieves the topics via FindTopicOptions | |||
| func FindTopics(opts *FindTopicOptions) (topics []*Topic, err error) { | |||
| sess := x.Select("topic.*").Where(opts.toConds()) | |||
| if opts.RepoID > 0 { | |||
| sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id") | |||
| } | |||
| if opts.Limit > 0 { | |||
| sess.Limit(opts.Limit, opts.Page*opts.Limit) | |||
| } | |||
| return topics, sess.Desc("topic.repo_count").Find(&topics) | |||
| } | |||
| // SaveTopics save topics to a repository | |||
| func SaveTopics(repoID int64, topicNames ...string) error { | |||
| topics, err := FindTopics(&FindTopicOptions{ | |||
| RepoID: repoID, | |||
| }) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| sess := x.NewSession() | |||
| defer sess.Close() | |||
| if err := sess.Begin(); err != nil { | |||
| return err | |||
| } | |||
| var addedTopicNames []string | |||
| for _, topicName := range topicNames { | |||
| if strings.TrimSpace(topicName) == "" { | |||
| continue | |||
| } | |||
| var found bool | |||
| for _, t := range topics { | |||
| if strings.EqualFold(topicName, t.Name) { | |||
| found = true | |||
| break | |||
| } | |||
| } | |||
| if !found { | |||
| addedTopicNames = append(addedTopicNames, topicName) | |||
| } | |||
| } | |||
| var removeTopics []*Topic | |||
| for _, t := range topics { | |||
| var found bool | |||
| for _, topicName := range topicNames { | |||
| if strings.EqualFold(topicName, t.Name) { | |||
| found = true | |||
| break | |||
| } | |||
| } | |||
| if !found { | |||
| removeTopics = append(removeTopics, t) | |||
| } | |||
| } | |||
| for _, topicName := range addedTopicNames { | |||
| var topic Topic | |||
| if has, err := sess.Where("name = ?", topicName).Get(&topic); err != nil { | |||
| return err | |||
| } else if !has { | |||
| topic.Name = topicName | |||
| topic.RepoCount = 1 | |||
| if _, err := sess.Insert(&topic); err != nil { | |||
| return err | |||
| } | |||
| } else { | |||
| topic.RepoCount++ | |||
| if _, err := sess.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| if _, err := sess.Insert(&RepoTopic{ | |||
| RepoID: repoID, | |||
| TopicID: topic.ID, | |||
| }); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| for _, topic := range removeTopics { | |||
| topic.RepoCount-- | |||
| if _, err := sess.ID(topic.ID).Cols("repo_count").Update(topic); err != nil { | |||
| return err | |||
| } | |||
| if _, err := sess.Delete(&RepoTopic{ | |||
| RepoID: repoID, | |||
| TopicID: topic.ID, | |||
| }); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{ | |||
| Topics: topicNames, | |||
| }); err != nil { | |||
| return err | |||
| } | |||
| return sess.Commit() | |||
| } | |||
| @@ -0,0 +1,57 @@ | |||
| // Copyright 2018 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package models | |||
| import ( | |||
| "testing" | |||
| "github.com/stretchr/testify/assert" | |||
| ) | |||
| func TestAddTopic(t *testing.T) { | |||
| assert.NoError(t, PrepareTestDatabase()) | |||
| topics, err := FindTopics(&FindTopicOptions{}) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 3, len(topics)) | |||
| topics, err = FindTopics(&FindTopicOptions{ | |||
| Limit: 2, | |||
| }) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 2, len(topics)) | |||
| topics, err = FindTopics(&FindTopicOptions{ | |||
| RepoID: 1, | |||
| }) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 3, len(topics)) | |||
| assert.NoError(t, SaveTopics(2, "golang")) | |||
| topics, err = FindTopics(&FindTopicOptions{}) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 3, len(topics)) | |||
| topics, err = FindTopics(&FindTopicOptions{ | |||
| RepoID: 2, | |||
| }) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 1, len(topics)) | |||
| assert.NoError(t, SaveTopics(2, "golang", "gitea")) | |||
| topic, err := GetTopicByName("gitea") | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 1, topic.RepoCount) | |||
| topics, err = FindTopics(&FindTopicOptions{}) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 4, len(topics)) | |||
| topics, err = FindTopics(&FindTopicOptions{ | |||
| RepoID: 2, | |||
| }) | |||
| assert.NoError(t, err) | |||
| assert.EqualValues(t, 2, len(topics)) | |||
| } | |||
| @@ -516,3 +516,8 @@ type AddTimeManuallyForm struct { | |||
| func (f *AddTimeManuallyForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { | |||
| return validate(errs, ctx.Data, f, ctx.Locale) | |||
| } | |||
| // SaveTopicForm form for save topics for repository | |||
| type SaveTopicForm struct { | |||
| Topics []string `binding:"topics;Required;"` | |||
| } | |||
| @@ -1114,6 +1114,9 @@ branch.restore_success = %s successfully restored | |||
| branch.restore_failed = Failed to restore branch %s. | |||
| branch.protected_deletion_failed = It's not possible to delete protected branch %s. | |||
| topic.manage_topics = Manage Topics | |||
| topic.done = Done | |||
| [org] | |||
| org_name_holder = Organization Name | |||
| org_full_name_holder = Organization Full Name | |||
| @@ -1591,6 +1591,7 @@ $(document).ready(function () { | |||
| initTeamSettings(); | |||
| initCtrlEnterSubmit(); | |||
| initNavbarContentToggle(); | |||
| initTopicbar(); | |||
| // Repo clone url. | |||
| if ($('#repo-clone-url').length > 0) { | |||
| @@ -2122,3 +2123,74 @@ function initNavbarContentToggle() { | |||
| } | |||
| }); | |||
| } | |||
| function initTopicbar() { | |||
| var mgrBtn = $("#manage_topic") | |||
| var editDiv = $("#topic_edit") | |||
| var viewDiv = $("#repo-topic") | |||
| var saveBtn = $("#save_topic") | |||
| mgrBtn.click(function() { | |||
| viewDiv.hide(); | |||
| editDiv.show(); | |||
| }) | |||
| saveBtn.click(function() { | |||
| var topics = $("input[name=topics]").val(); | |||
| $.post($(this).data('link'), { | |||
| "_csrf": csrf, | |||
| "topics": topics | |||
| }).success(function(res){ | |||
| if (res["status"] != "ok") { | |||
| alert(res.message); | |||
| } else { | |||
| viewDiv.children(".topic").remove(); | |||
| var topicArray = topics.split(","); | |||
| var last = viewDiv.children("a").last(); | |||
| for (var i=0;i < topicArray.length; i++) { | |||
| $('<div class="ui green basic label topic" style="cursor:pointer;">'+topicArray[i]+'</div>').insertBefore(last) | |||
| } | |||
| } | |||
| }).done(function() { | |||
| editDiv.hide(); | |||
| viewDiv.show(); | |||
| }) | |||
| }) | |||
| $('#topic_edit .dropdown').dropdown({ | |||
| allowAdditions: true, | |||
| fields: { name: "description", value: "data-value" }, | |||
| saveRemoteData: false, | |||
| label: { | |||
| transition : 'horizontal flip', | |||
| duration : 200, | |||
| variation : false, | |||
| blue : true, | |||
| basic: true, | |||
| }, | |||
| className: { | |||
| label: 'ui green basic label' | |||
| }, | |||
| apiSettings: { | |||
| url: suburl + '/api/v1/topics/search?q={query}', | |||
| throttle: 500, | |||
| cache: false, | |||
| onResponse: function(res) { | |||
| var formattedResponse = { | |||
| success: false, | |||
| results: new Array(), | |||
| }; | |||
| if (res.topics) { | |||
| formattedResponse.success = true; | |||
| for (var i=0;i < res.topics.length;i++) { | |||
| formattedResponse.results.push({"description": res.topics[i].Name, "data-value":res.topics[i].Name}) | |||
| } | |||
| } | |||
| return formattedResponse; | |||
| }, | |||
| }, | |||
| }); | |||
| } | |||
| @@ -1733,3 +1733,12 @@ tbody.commit-list { | |||
| } | |||
| } | |||
| } | |||
| #topic_edit { | |||
| margin-top:5px; | |||
| display: none; | |||
| } | |||
| #repo-topic { | |||
| margin-top: 5px; | |||
| } | |||
| @@ -571,5 +571,9 @@ func RegisterRoutes(m *macaron.Macaron) { | |||
| }) | |||
| }) | |||
| }, reqAdmin()) | |||
| m.Group("/topics", func() { | |||
| m.Get("/search", repo.TopicSearch) | |||
| }) | |||
| }, context.APIContexter()) | |||
| } | |||
| @@ -501,3 +501,45 @@ func MirrorSync(ctx *context.APIContext) { | |||
| go models.MirrorQueue.Add(repo.ID) | |||
| ctx.Status(200) | |||
| } | |||
| // TopicSearch search for creating topic | |||
| func TopicSearch(ctx *context.Context) { | |||
| // swagger:operation GET /topics/search repository topicSearch | |||
| // --- | |||
| // summary: search topics via keyword | |||
| // produces: | |||
| // - application/json | |||
| // parameters: | |||
| // - name: keyword | |||
| // in: path | |||
| // description: id of the repo to get | |||
| // type: integer | |||
| // required: true | |||
| // responses: | |||
| // "200": | |||
| // "$ref": "#/responses/Repository" | |||
| if ctx.User == nil { | |||
| ctx.JSON(403, map[string]interface{}{ | |||
| "message": "Only owners could change the topics.", | |||
| }) | |||
| return | |||
| } | |||
| kw := ctx.Query("q") | |||
| topics, err := models.FindTopics(&models.FindTopicOptions{ | |||
| Keyword: kw, | |||
| Limit: 10, | |||
| }) | |||
| if err != nil { | |||
| log.Error(2, "SearchTopics failed: %v", err) | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "message": "Search topics failed.", | |||
| }) | |||
| return | |||
| } | |||
| ctx.JSON(200, map[string]interface{}{ | |||
| "topics": topics, | |||
| }) | |||
| } | |||
| @@ -0,0 +1,38 @@ | |||
| // Copyright 2018 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package repo | |||
| import ( | |||
| "strings" | |||
| "code.gitea.io/gitea/models" | |||
| "code.gitea.io/gitea/modules/context" | |||
| "code.gitea.io/gitea/modules/log" | |||
| ) | |||
| // TopicPost response for creating repository | |||
| func TopicPost(ctx *context.Context) { | |||
| if ctx.User == nil { | |||
| ctx.JSON(403, map[string]interface{}{ | |||
| "message": "Only owners could change the topics.", | |||
| }) | |||
| return | |||
| } | |||
| topics := strings.Split(ctx.Query("topics"), ",") | |||
| err := models.SaveTopics(ctx.Repo.Repository.ID, topics...) | |||
| if err != nil { | |||
| log.Error(2, "SaveTopics failed: %v", err) | |||
| ctx.JSON(500, map[string]interface{}{ | |||
| "message": "Save topics failed.", | |||
| }) | |||
| return | |||
| } | |||
| ctx.JSON(200, map[string]interface{}{ | |||
| "status": "ok", | |||
| }) | |||
| } | |||
| @@ -314,6 +314,16 @@ func renderCode(ctx *context.Context) { | |||
| treeLink += "/" + ctx.Repo.TreePath | |||
| } | |||
| // Get Topics of this repo | |||
| topics, err := models.FindTopics(&models.FindTopicOptions{ | |||
| RepoID: ctx.Repo.Repository.ID, | |||
| }) | |||
| if err != nil { | |||
| ctx.ServerError("models.FindTopics", err) | |||
| return | |||
| } | |||
| ctx.Data["Topics"] = topics | |||
| // Get current entry user currently looking at. | |||
| entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) | |||
| if err != nil { | |||
| @@ -587,6 +587,10 @@ func RegisterRoutes(m *macaron.Macaron) { | |||
| }) | |||
| }, context.RepoAssignment(), context.UnitTypes(), context.LoadRepoUnits(), context.CheckUnit(models.UnitTypeReleases)) | |||
| m.Group("/:username/:reponame", func() { | |||
| m.Post("/topics", repo.TopicPost) | |||
| }, context.RepoAssignment(), reqRepoAdmin) | |||
| m.Group("/:username/:reponame", func() { | |||
| m.Group("", func() { | |||
| m.Get("/^:type(issues|pulls)$", repo.RetrieveLabels, repo.Issues) | |||
| @@ -17,6 +17,9 @@ | |||
| </div> | |||
| </div> | |||
| {{if .DescriptionHTML}}<p class="has-emoji">{{.DescriptionHTML}}</p>{{end}} | |||
| <div> | |||
| {{range .Topics}}<div class="ui green basic label topic">{{.}}</div>{{end}} | |||
| </div> | |||
| <p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSinceUnix .UpdatedUnix $.i18n.Lang}}</p> | |||
| </div> | |||
| {{else}} | |||
| @@ -5,7 +5,7 @@ | |||
| {{template "base/alert" .}} | |||
| <div class="ui repo-description"> | |||
| <div id="repo-desc"> | |||
| {{if .Repository.DescriptionHTML}}<span class="description has-emoji">{{.Repository.DescriptionHTML}}</span>{{else if .IsRepositoryAdmin}}<span class="no-description text-italic">{{.i18n.Tr "repo.no_desc"}}</span>{{end}} | |||
| {{if .Repository.DescriptionHTML}}<span class="description has-emoji">{{.Repository.DescriptionHTML}}</span>{{else}}<span class="no-description text-italic">{{.i18n.Tr "repo.no_desc"}}</span>{{end}} | |||
| <a class="link" href="{{.Repository.Website}}">{{.Repository.Website}}</a> | |||
| </div> | |||
| {{if .RepoSearchEnabled}} | |||
| @@ -23,6 +23,27 @@ | |||
| </div> | |||
| {{end}} | |||
| </div> | |||
| <div class="ui repo-topic" id="repo-topic"> | |||
| {{range .Topics}}<div class="ui green basic label topic" style="cursor:pointer;">{{.Name}}</div>{{end}} | |||
| {{if .IsRepositoryAdmin}}<a id="manage_topic" style="cursor:pointer;margin-left:10px;">{{.i18n.Tr "repo.topic.manage_topics"}}</a>{{end}} | |||
| </div> | |||
| {{if .IsRepositoryAdmin}} | |||
| <div class="ui repo-topic-edit grid" id="topic_edit" > | |||
| <div class="fourteen wide column"> | |||
| <div class="ui fluid multiple search selection dropdown"> | |||
| <input type="hidden" name="topics" value="{{range $i, $v := .Topics}}{{.Name}}{{if lt (Add $i 1) (len $.Topics)}},{{end}}{{end}}"> | |||
| {{range .Topics}} | |||
| <a class="ui green basic label topic transition visible" data-value="{{.Name}}" style="display: inline-block !important;">{{.Name}}<i class="delete icon"></i></a> | |||
| {{end}} | |||
| <div class="text"></div> | |||
| </div> | |||
| </div> | |||
| <div class="one wide column"> | |||
| <a class="ui compact button primary" href="javascript:;" id="save_topic" | |||
| data-link="{{.RepoLink}}/topics">{{.i18n.Tr "repo.topic.done"}}</a> | |||
| </div> | |||
| </div> | |||
| {{end}} | |||
| {{template "repo/sub_menu" .}} | |||
| <div class="ui stackable secondary menu mobile--margin-between-items mobile--no-negative-margins"> | |||
| {{if and .PullRequestCtx.Allowed .IsViewBranch}} | |||