|
| 1 | +// Copyright 2021 The Gitea Authors. All rights reserved. |
| 2 | +// Use of this source code is governed by a MIT-style |
| 3 | +// license that can be found in the LICENSE file. |
| 4 | + |
| 5 | +package issues |
| 6 | + |
| 7 | +import ( |
| 8 | + "context" |
| 9 | + "fmt" |
| 10 | + |
| 11 | + "code.gitea.io/gitea/models/avatars" |
| 12 | + "code.gitea.io/gitea/models/db" |
| 13 | + "code.gitea.io/gitea/modules/log" |
| 14 | + "code.gitea.io/gitea/modules/timeutil" |
| 15 | + |
| 16 | + "xorm.io/builder" |
| 17 | +) |
| 18 | + |
| 19 | +// ContentHistory save issue/comment content history revisions. |
| 20 | +type ContentHistory struct { |
| 21 | + ID int64 `xorm:"pk autoincr"` |
| 22 | + PosterID int64 |
| 23 | + IssueID int64 `xorm:"INDEX"` |
| 24 | + CommentID int64 `xorm:"INDEX"` |
| 25 | + EditedUnix timeutil.TimeStamp `xorm:"INDEX"` |
| 26 | + ContentText string `xorm:"LONGTEXT"` |
| 27 | + IsFirstCreated bool |
| 28 | + IsDeleted bool |
| 29 | +} |
| 30 | + |
| 31 | +// TableName provides the real table name |
| 32 | +func (m *ContentHistory) TableName() string { |
| 33 | + return "issue_content_history" |
| 34 | +} |
| 35 | + |
| 36 | +func init() { |
| 37 | + db.RegisterModel(new(ContentHistory)) |
| 38 | +} |
| 39 | + |
| 40 | +// SaveIssueContentHistory save history |
| 41 | +func SaveIssueContentHistory(e db.Engine, posterID, issueID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error { |
| 42 | + ch := &ContentHistory{ |
| 43 | + PosterID: posterID, |
| 44 | + IssueID: issueID, |
| 45 | + CommentID: commentID, |
| 46 | + ContentText: contentText, |
| 47 | + EditedUnix: editTime, |
| 48 | + IsFirstCreated: isFirstCreated, |
| 49 | + } |
| 50 | + _, err := e.Insert(ch) |
| 51 | + if err != nil { |
| 52 | + log.Error("can not save issue content history. err=%v", err) |
| 53 | + return err |
| 54 | + } |
| 55 | + // We only keep at most 20 history revisions now. It is enough in most cases. |
| 56 | + // If there is a special requirement to keep more, we can consider introducing a new setting option then, but not now. |
| 57 | + keepLimitedContentHistory(e, issueID, commentID, 20) |
| 58 | + return nil |
| 59 | +} |
| 60 | + |
| 61 | +// keepLimitedContentHistory keeps at most `limit` history revisions, it will hard delete out-dated revisions, sorting by revision interval |
| 62 | +// we can ignore all errors in this function, so we just log them |
| 63 | +func keepLimitedContentHistory(e db.Engine, issueID, commentID int64, limit int) { |
| 64 | + type IDEditTime struct { |
| 65 | + ID int64 |
| 66 | + EditedUnix timeutil.TimeStamp |
| 67 | + } |
| 68 | + |
| 69 | + var res []*IDEditTime |
| 70 | + err := e.Select("id, edited_unix").Table("issue_content_history"). |
| 71 | + Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}). |
| 72 | + OrderBy("edited_unix ASC"). |
| 73 | + Find(&res) |
| 74 | + if err != nil { |
| 75 | + log.Error("can not query content history for deletion, err=%v", err) |
| 76 | + return |
| 77 | + } |
| 78 | + if len(res) <= 1 { |
| 79 | + return |
| 80 | + } |
| 81 | + |
| 82 | + outDatedCount := len(res) - limit |
| 83 | + for outDatedCount > 0 { |
| 84 | + var indexToDelete int |
| 85 | + minEditedInterval := -1 |
| 86 | + // find a history revision with minimal edited interval to delete |
| 87 | + for i := 1; i < len(res); i++ { |
| 88 | + editedInterval := int(res[i].EditedUnix - res[i-1].EditedUnix) |
| 89 | + if minEditedInterval == -1 || editedInterval < minEditedInterval { |
| 90 | + minEditedInterval = editedInterval |
| 91 | + indexToDelete = i |
| 92 | + } |
| 93 | + } |
| 94 | + if indexToDelete == 0 { |
| 95 | + break |
| 96 | + } |
| 97 | + |
| 98 | + // hard delete the found one |
| 99 | + _, err = e.Delete(&ContentHistory{ID: res[indexToDelete].ID}) |
| 100 | + if err != nil { |
| 101 | + log.Error("can not delete out-dated content history, err=%v", err) |
| 102 | + break |
| 103 | + } |
| 104 | + res = append(res[:indexToDelete], res[indexToDelete+1:]...) |
| 105 | + outDatedCount-- |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +// QueryIssueContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main issue) |
| 110 | +// only return the count map for "edited" (history revision count > 1) issues or comments. |
| 111 | +func QueryIssueContentHistoryEditedCountMap(dbCtx context.Context, issueID int64) (map[int64]int, error) { |
| 112 | + type HistoryCountRecord struct { |
| 113 | + CommentID int64 |
| 114 | + HistoryCount int |
| 115 | + } |
| 116 | + records := make([]*HistoryCountRecord, 0) |
| 117 | + |
| 118 | + err := db.GetEngine(dbCtx).Select("comment_id, COUNT(1) as history_count"). |
| 119 | + Table("issue_content_history"). |
| 120 | + Where(builder.Eq{"issue_id": issueID}). |
| 121 | + GroupBy("comment_id"). |
| 122 | + Having("history_count > 1"). |
| 123 | + Find(&records) |
| 124 | + if err != nil { |
| 125 | + log.Error("can not query issue content history count map. err=%v", err) |
| 126 | + return nil, err |
| 127 | + } |
| 128 | + |
| 129 | + res := map[int64]int{} |
| 130 | + for _, r := range records { |
| 131 | + res[r.CommentID] = r.HistoryCount |
| 132 | + } |
| 133 | + return res, nil |
| 134 | +} |
| 135 | + |
| 136 | +// IssueContentListItem the list for web ui |
| 137 | +type IssueContentListItem struct { |
| 138 | + UserID int64 |
| 139 | + UserName string |
| 140 | + UserAvatarLink string |
| 141 | + |
| 142 | + HistoryID int64 |
| 143 | + EditedUnix timeutil.TimeStamp |
| 144 | + IsFirstCreated bool |
| 145 | + IsDeleted bool |
| 146 | +} |
| 147 | + |
| 148 | +// FetchIssueContentHistoryList fetch list |
| 149 | +func FetchIssueContentHistoryList(dbCtx context.Context, issueID int64, commentID int64) ([]*IssueContentListItem, error) { |
| 150 | + res := make([]*IssueContentListItem, 0) |
| 151 | + err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name,"+ |
| 152 | + "h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted"). |
| 153 | + Table([]string{"issue_content_history", "h"}). |
| 154 | + Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id"). |
| 155 | + Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}). |
| 156 | + OrderBy("edited_unix DESC"). |
| 157 | + Find(&res) |
| 158 | + |
| 159 | + if err != nil { |
| 160 | + log.Error("can not fetch issue content history list. err=%v", err) |
| 161 | + return nil, err |
| 162 | + } |
| 163 | + |
| 164 | + for _, item := range res { |
| 165 | + item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0) |
| 166 | + } |
| 167 | + return res, nil |
| 168 | +} |
| 169 | + |
| 170 | +//SoftDeleteIssueContentHistory soft delete |
| 171 | +func SoftDeleteIssueContentHistory(dbCtx context.Context, historyID int64) error { |
| 172 | + if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ContentHistory{ |
| 173 | + IsDeleted: true, |
| 174 | + ContentText: "", |
| 175 | + }); err != nil { |
| 176 | + log.Error("failed to soft delete issue content history. err=%v", err) |
| 177 | + return err |
| 178 | + } |
| 179 | + return nil |
| 180 | +} |
| 181 | + |
| 182 | +// ErrIssueContentHistoryNotExist not exist error |
| 183 | +type ErrIssueContentHistoryNotExist struct { |
| 184 | + ID int64 |
| 185 | +} |
| 186 | + |
| 187 | +// Error error string |
| 188 | +func (err ErrIssueContentHistoryNotExist) Error() string { |
| 189 | + return fmt.Sprintf("issue content history does not exist [id: %d]", err.ID) |
| 190 | +} |
| 191 | + |
| 192 | +// GetIssueContentHistoryByID get issue content history |
| 193 | +func GetIssueContentHistoryByID(dbCtx context.Context, id int64) (*ContentHistory, error) { |
| 194 | + h := &ContentHistory{} |
| 195 | + has, err := db.GetEngine(dbCtx).ID(id).Get(h) |
| 196 | + if err != nil { |
| 197 | + return nil, err |
| 198 | + } else if !has { |
| 199 | + return nil, ErrIssueContentHistoryNotExist{id} |
| 200 | + } |
| 201 | + return h, nil |
| 202 | +} |
| 203 | + |
| 204 | +// GetIssueContentHistoryAndPrev get a history and the previous non-deleted history (to compare) |
| 205 | +func GetIssueContentHistoryAndPrev(dbCtx context.Context, id int64) (history, prevHistory *ContentHistory, err error) { |
| 206 | + history = &ContentHistory{} |
| 207 | + has, err := db.GetEngine(dbCtx).ID(id).Get(history) |
| 208 | + if err != nil { |
| 209 | + log.Error("failed to get issue content history %v. err=%v", id, err) |
| 210 | + return nil, nil, err |
| 211 | + } else if !has { |
| 212 | + log.Error("issue content history does not exist. id=%v. err=%v", id, err) |
| 213 | + return nil, nil, &ErrIssueContentHistoryNotExist{id} |
| 214 | + } |
| 215 | + |
| 216 | + prevHistory = &ContentHistory{} |
| 217 | + has, err = db.GetEngine(dbCtx).Where(builder.Eq{"issue_id": history.IssueID, "comment_id": history.CommentID, "is_deleted": false}). |
| 218 | + And(builder.Lt{"edited_unix": history.EditedUnix}). |
| 219 | + OrderBy("edited_unix DESC").Limit(1). |
| 220 | + Get(prevHistory) |
| 221 | + |
| 222 | + if err != nil { |
| 223 | + log.Error("failed to get issue content history %v. err=%v", id, err) |
| 224 | + return nil, nil, err |
| 225 | + } else if !has { |
| 226 | + return history, nil, nil |
| 227 | + } |
| 228 | + |
| 229 | + return history, prevHistory, nil |
| 230 | +} |
0 commit comments