You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

topic.go 9.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. // Copyright 2018 The Gitea Authors. All rights reserved.
  2. // SPDX-License-Identifier: MIT
  3. package repo
  4. import (
  5. "context"
  6. "fmt"
  7. "regexp"
  8. "strings"
  9. "code.gitea.io/gitea/models/db"
  10. "code.gitea.io/gitea/modules/container"
  11. "code.gitea.io/gitea/modules/timeutil"
  12. "code.gitea.io/gitea/modules/util"
  13. "xorm.io/builder"
  14. )
  15. func init() {
  16. db.RegisterModel(new(Topic))
  17. db.RegisterModel(new(RepoTopic))
  18. }
  19. var topicPattern = regexp.MustCompile(`^[a-z0-9][-.a-z0-9]*$`)
  20. // Topic represents a topic of repositories
  21. type Topic struct {
  22. ID int64 `xorm:"pk autoincr"`
  23. Name string `xorm:"UNIQUE VARCHAR(50)"`
  24. RepoCount int
  25. CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
  26. UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
  27. }
  28. // RepoTopic represents associated repositories and topics
  29. type RepoTopic struct { //revive:disable-line:exported
  30. RepoID int64 `xorm:"pk"`
  31. TopicID int64 `xorm:"pk"`
  32. }
  33. // ErrTopicNotExist represents an error that a topic is not exist
  34. type ErrTopicNotExist struct {
  35. Name string
  36. }
  37. // IsErrTopicNotExist checks if an error is an ErrTopicNotExist.
  38. func IsErrTopicNotExist(err error) bool {
  39. _, ok := err.(ErrTopicNotExist)
  40. return ok
  41. }
  42. // Error implements error interface
  43. func (err ErrTopicNotExist) Error() string {
  44. return fmt.Sprintf("topic is not exist [name: %s]", err.Name)
  45. }
  46. func (err ErrTopicNotExist) Unwrap() error {
  47. return util.ErrNotExist
  48. }
  49. // ValidateTopic checks a topic by length and match pattern rules
  50. func ValidateTopic(topic string) bool {
  51. return len(topic) <= 35 && topicPattern.MatchString(topic)
  52. }
  53. // SanitizeAndValidateTopics sanitizes and checks an array or topics
  54. func SanitizeAndValidateTopics(topics []string) (validTopics, invalidTopics []string) {
  55. validTopics = make([]string, 0)
  56. mValidTopics := make(container.Set[string])
  57. invalidTopics = make([]string, 0)
  58. for _, topic := range topics {
  59. topic = strings.TrimSpace(strings.ToLower(topic))
  60. // ignore empty string
  61. if len(topic) == 0 {
  62. continue
  63. }
  64. // ignore same topic twice
  65. if mValidTopics.Contains(topic) {
  66. continue
  67. }
  68. if ValidateTopic(topic) {
  69. validTopics = append(validTopics, topic)
  70. mValidTopics.Add(topic)
  71. } else {
  72. invalidTopics = append(invalidTopics, topic)
  73. }
  74. }
  75. return validTopics, invalidTopics
  76. }
  77. // GetTopicByName retrieves topic by name
  78. func GetTopicByName(ctx context.Context, name string) (*Topic, error) {
  79. var topic Topic
  80. if has, err := db.GetEngine(ctx).Where("name = ?", name).Get(&topic); err != nil {
  81. return nil, err
  82. } else if !has {
  83. return nil, ErrTopicNotExist{name}
  84. }
  85. return &topic, nil
  86. }
  87. // addTopicByNameToRepo adds a topic name to a repo and increments the topic count.
  88. // Returns topic after the addition
  89. func addTopicByNameToRepo(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
  90. var topic Topic
  91. e := db.GetEngine(ctx)
  92. has, err := e.Where("name = ?", topicName).Get(&topic)
  93. if err != nil {
  94. return nil, err
  95. }
  96. if !has {
  97. topic.Name = topicName
  98. topic.RepoCount = 1
  99. if err := db.Insert(ctx, &topic); err != nil {
  100. return nil, err
  101. }
  102. } else {
  103. topic.RepoCount++
  104. if _, err := e.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
  105. return nil, err
  106. }
  107. }
  108. if err := db.Insert(ctx, &RepoTopic{
  109. RepoID: repoID,
  110. TopicID: topic.ID,
  111. }); err != nil {
  112. return nil, err
  113. }
  114. return &topic, nil
  115. }
  116. // removeTopicFromRepo remove a topic from a repo and decrements the topic repo count
  117. func removeTopicFromRepo(ctx context.Context, repoID int64, topic *Topic) error {
  118. topic.RepoCount--
  119. e := db.GetEngine(ctx)
  120. if _, err := e.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
  121. return err
  122. }
  123. if _, err := e.Delete(&RepoTopic{
  124. RepoID: repoID,
  125. TopicID: topic.ID,
  126. }); err != nil {
  127. return err
  128. }
  129. return nil
  130. }
  131. // RemoveTopicsFromRepo remove all topics from the repo and decrements respective topics repo count
  132. func RemoveTopicsFromRepo(ctx context.Context, repoID int64) error {
  133. e := db.GetEngine(ctx)
  134. _, err := e.Where(
  135. builder.In("id",
  136. builder.Select("topic_id").From("repo_topic").Where(builder.Eq{"repo_id": repoID}),
  137. ),
  138. ).Cols("repo_count").SetExpr("repo_count", "repo_count-1").Update(&Topic{})
  139. if err != nil {
  140. return err
  141. }
  142. if _, err = e.Delete(&RepoTopic{RepoID: repoID}); err != nil {
  143. return err
  144. }
  145. return nil
  146. }
  147. // FindTopicOptions represents the options when fdin topics
  148. type FindTopicOptions struct {
  149. db.ListOptions
  150. RepoID int64
  151. Keyword string
  152. }
  153. func (opts *FindTopicOptions) toConds() builder.Cond {
  154. cond := builder.NewCond()
  155. if opts.RepoID > 0 {
  156. cond = cond.And(builder.Eq{"repo_topic.repo_id": opts.RepoID})
  157. }
  158. if opts.Keyword != "" {
  159. cond = cond.And(builder.Like{"topic.name", opts.Keyword})
  160. }
  161. return cond
  162. }
  163. // FindTopics retrieves the topics via FindTopicOptions
  164. func FindTopics(ctx context.Context, opts *FindTopicOptions) ([]*Topic, int64, error) {
  165. sess := db.GetEngine(ctx).Select("topic.*").Where(opts.toConds())
  166. orderBy := "topic.repo_count DESC"
  167. if opts.RepoID > 0 {
  168. sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
  169. orderBy = "topic.name" // when render topics for a repo, it's better to sort them by name, to get consistent result
  170. }
  171. if opts.PageSize != 0 && opts.Page != 0 {
  172. sess = db.SetSessionPagination(sess, opts)
  173. }
  174. topics := make([]*Topic, 0, 10)
  175. total, err := sess.OrderBy(orderBy).FindAndCount(&topics)
  176. return topics, total, err
  177. }
  178. // CountTopics counts the number of topics matching the FindTopicOptions
  179. func CountTopics(ctx context.Context, opts *FindTopicOptions) (int64, error) {
  180. sess := db.GetEngine(ctx).Where(opts.toConds())
  181. if opts.RepoID > 0 {
  182. sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
  183. }
  184. return sess.Count(new(Topic))
  185. }
  186. // GetRepoTopicByName retrieves topic from name for a repo if it exist
  187. func GetRepoTopicByName(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
  188. cond := builder.NewCond()
  189. var topic Topic
  190. cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.name": topicName})
  191. sess := db.GetEngine(ctx).Table("topic").Where(cond)
  192. sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
  193. has, err := sess.Select("topic.*").Get(&topic)
  194. if has {
  195. return &topic, err
  196. }
  197. return nil, err
  198. }
  199. // AddTopic adds a topic name to a repository (if it does not already have it)
  200. func AddTopic(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
  201. ctx, committer, err := db.TxContext(ctx)
  202. if err != nil {
  203. return nil, err
  204. }
  205. defer committer.Close()
  206. sess := db.GetEngine(ctx)
  207. topic, err := GetRepoTopicByName(ctx, repoID, topicName)
  208. if err != nil {
  209. return nil, err
  210. }
  211. if topic != nil {
  212. // Repo already have topic
  213. return topic, nil
  214. }
  215. topic, err = addTopicByNameToRepo(ctx, repoID, topicName)
  216. if err != nil {
  217. return nil, err
  218. }
  219. if err = syncTopicsInRepository(sess, repoID); err != nil {
  220. return nil, err
  221. }
  222. return topic, committer.Commit()
  223. }
  224. // DeleteTopic removes a topic name from a repository (if it has it)
  225. func DeleteTopic(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
  226. topic, err := GetRepoTopicByName(ctx, repoID, topicName)
  227. if err != nil {
  228. return nil, err
  229. }
  230. if topic == nil {
  231. // Repo doesn't have topic, can't be removed
  232. return nil, nil
  233. }
  234. err = removeTopicFromRepo(ctx, repoID, topic)
  235. if err != nil {
  236. return nil, err
  237. }
  238. err = syncTopicsInRepository(db.GetEngine(ctx), repoID)
  239. return topic, err
  240. }
  241. // SaveTopics save topics to a repository
  242. func SaveTopics(ctx context.Context, repoID int64, topicNames ...string) error {
  243. topics, _, err := FindTopics(ctx, &FindTopicOptions{
  244. RepoID: repoID,
  245. })
  246. if err != nil {
  247. return err
  248. }
  249. ctx, committer, err := db.TxContext(ctx)
  250. if err != nil {
  251. return err
  252. }
  253. defer committer.Close()
  254. sess := db.GetEngine(ctx)
  255. var addedTopicNames []string
  256. for _, topicName := range topicNames {
  257. if strings.TrimSpace(topicName) == "" {
  258. continue
  259. }
  260. var found bool
  261. for _, t := range topics {
  262. if strings.EqualFold(topicName, t.Name) {
  263. found = true
  264. break
  265. }
  266. }
  267. if !found {
  268. addedTopicNames = append(addedTopicNames, topicName)
  269. }
  270. }
  271. var removeTopics []*Topic
  272. for _, t := range topics {
  273. var found bool
  274. for _, topicName := range topicNames {
  275. if strings.EqualFold(topicName, t.Name) {
  276. found = true
  277. break
  278. }
  279. }
  280. if !found {
  281. removeTopics = append(removeTopics, t)
  282. }
  283. }
  284. for _, topicName := range addedTopicNames {
  285. _, err := addTopicByNameToRepo(ctx, repoID, topicName)
  286. if err != nil {
  287. return err
  288. }
  289. }
  290. for _, topic := range removeTopics {
  291. err := removeTopicFromRepo(ctx, repoID, topic)
  292. if err != nil {
  293. return err
  294. }
  295. }
  296. if err := syncTopicsInRepository(sess, repoID); err != nil {
  297. return err
  298. }
  299. return committer.Commit()
  300. }
  301. // GenerateTopics generates topics from a template repository
  302. func GenerateTopics(ctx context.Context, templateRepo, generateRepo *Repository) error {
  303. for _, topic := range templateRepo.Topics {
  304. if _, err := addTopicByNameToRepo(ctx, generateRepo.ID, topic); err != nil {
  305. return err
  306. }
  307. }
  308. return syncTopicsInRepository(db.GetEngine(ctx), generateRepo.ID)
  309. }
  310. // syncTopicsInRepository makes sure topics in the topics table are copied into the topics field of the repository
  311. func syncTopicsInRepository(sess db.Engine, repoID int64) error {
  312. topicNames := make([]string, 0, 25)
  313. if err := sess.Table("topic").Cols("name").
  314. Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id").
  315. Where("repo_topic.repo_id = ?", repoID).Desc("topic.repo_count").Find(&topicNames); err != nil {
  316. return err
  317. }
  318. if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{
  319. Topics: topicNames,
  320. }); err != nil {
  321. return err
  322. }
  323. return nil
  324. }