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