summaryrefslogtreecommitdiffstats
path: root/models/issues/content_history.go
blob: cd3e217b21de035f5ab469f17f27cb726cca1c95 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package issues

import (
	"context"
	"fmt"

	"code.gitea.io/gitea/models/avatars"
	"code.gitea.io/gitea/models/db"
	"code.gitea.io/gitea/modules/log"
	"code.gitea.io/gitea/modules/timeutil"
	"code.gitea.io/gitea/modules/util"

	"xorm.io/builder"
)

// ContentHistory save issue/comment content history revisions.
type ContentHistory struct {
	ID             int64 `xorm:"pk autoincr"`
	PosterID       int64
	IssueID        int64              `xorm:"INDEX"`
	CommentID      int64              `xorm:"INDEX"`
	EditedUnix     timeutil.TimeStamp `xorm:"INDEX"`
	ContentText    string             `xorm:"LONGTEXT"`
	IsFirstCreated bool
	IsDeleted      bool
}

// TableName provides the real table name
func (m *ContentHistory) TableName() string {
	return "issue_content_history"
}

func init() {
	db.RegisterModel(new(ContentHistory))
}

// SaveIssueContentHistory save history
func SaveIssueContentHistory(ctx context.Context, posterID, issueID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error {
	ch := &ContentHistory{
		PosterID:       posterID,
		IssueID:        issueID,
		CommentID:      commentID,
		ContentText:    contentText,
		EditedUnix:     editTime,
		IsFirstCreated: isFirstCreated,
	}
	if err := db.Insert(ctx, ch); err != nil {
		log.Error("can not save issue content history. err=%v", err)
		return err
	}
	// We only keep at most 20 history revisions now. It is enough in most cases.
	// If there is a special requirement to keep more, we can consider introducing a new setting option then, but not now.
	KeepLimitedContentHistory(ctx, issueID, commentID, 20)
	return nil
}

// KeepLimitedContentHistory keeps at most `limit` history revisions, it will hard delete out-dated revisions, sorting by revision interval
// we can ignore all errors in this function, so we just log them
func KeepLimitedContentHistory(ctx context.Context, issueID, commentID int64, limit int) {
	type IDEditTime struct {
		ID         int64
		EditedUnix timeutil.TimeStamp
	}

	var res []*IDEditTime
	err := db.GetEngine(ctx).Select("id, edited_unix").Table("issue_content_history").
		Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
		OrderBy("edited_unix ASC").
		Find(&res)
	if err != nil {
		log.Error("can not query content history for deletion, err=%v", err)
		return
	}
	if len(res) <= 2 {
		return
	}

	outDatedCount := len(res) - limit
	for outDatedCount > 0 {
		var indexToDelete int
		minEditedInterval := -1
		// find a history revision with minimal edited interval to delete, the first and the last should never be deleted
		for i := 1; i < len(res)-1; i++ {
			editedInterval := int(res[i].EditedUnix - res[i-1].EditedUnix)
			if minEditedInterval == -1 || editedInterval < minEditedInterval {
				minEditedInterval = editedInterval
				indexToDelete = i
			}
		}
		if indexToDelete == 0 {
			break
		}

		// hard delete the found one
		_, err = db.GetEngine(ctx).Delete(&ContentHistory{ID: res[indexToDelete].ID})
		if err != nil {
			log.Error("can not delete out-dated content history, err=%v", err)
			break
		}
		res = append(res[:indexToDelete], res[indexToDelete+1:]...)
		outDatedCount--
	}
}

// QueryIssueContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main issue)
// only return the count map for "edited" (history revision count > 1) issues or comments.
func QueryIssueContentHistoryEditedCountMap(dbCtx context.Context, issueID int64) (map[int64]int, error) {
	type HistoryCountRecord struct {
		CommentID    int64
		HistoryCount int
	}
	records := make([]*HistoryCountRecord, 0)

	err := db.GetEngine(dbCtx).Select("comment_id, COUNT(1) as history_count").
		Table("issue_content_history").
		Where(builder.Eq{"issue_id": issueID}).
		GroupBy("comment_id").
		Having("count(1) > 1").
		Find(&records)
	if err != nil {
		log.Error("can not query issue content history count map. err=%v", err)
		return nil, err
	}

	res := map[int64]int{}
	for _, r := range records {
		res[r.CommentID] = r.HistoryCount
	}
	return res, nil
}

// IssueContentListItem the list for web ui
type IssueContentListItem struct {
	UserID         int64
	UserName       string
	UserFullName   string
	UserAvatarLink string

	HistoryID      int64
	EditedUnix     timeutil.TimeStamp
	IsFirstCreated bool
	IsDeleted      bool
}

// FetchIssueContentHistoryList fetch list
func FetchIssueContentHistoryList(dbCtx context.Context, issueID, commentID int64) ([]*IssueContentListItem, error) {
	res := make([]*IssueContentListItem, 0)
	err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name, u.full_name as user_full_name,"+
		"h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted").
		Table([]string{"issue_content_history", "h"}).
		Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id").
		Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
		OrderBy("edited_unix DESC").
		Find(&res)
	if err != nil {
		log.Error("can not fetch issue content history list. err=%v", err)
		return nil, err
	}

	for _, item := range res {
		if item.UserID > 0 {
			item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0)
		} else {
			item.UserAvatarLink = avatars.DefaultAvatarLink()
		}
	}
	return res, nil
}

// HasIssueContentHistory check if a ContentHistory entry exists
func HasIssueContentHistory(dbCtx context.Context, issueID, commentID int64) (bool, error) {
	return db.GetEngine(dbCtx).Where("issue_id = ? AND comment_id = ?", issueID, commentID).Exist(new(ContentHistory))
}

// SoftDeleteIssueContentHistory soft delete
func SoftDeleteIssueContentHistory(dbCtx context.Context, historyID int64) error {
	if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ContentHistory{
		IsDeleted:   true,
		ContentText: "",
	}); err != nil {
		log.Error("failed to soft delete issue content history. err=%v", err)
		return err
	}
	return nil
}

// ErrIssueContentHistoryNotExist not exist error
type ErrIssueContentHistoryNotExist struct {
	ID int64
}

// Error error string
func (err ErrIssueContentHistoryNotExist) Error() string {
	return fmt.Sprintf("issue content history does not exist [id: %d]", err.ID)
}

func (err ErrIssueContentHistoryNotExist) Unwrap() error {
	return util.ErrNotExist
}

// GetIssueContentHistoryByID get issue content history
func GetIssueContentHistoryByID(dbCtx context.Context, id int64) (*ContentHistory, error) {
	h := &ContentHistory{}
	has, err := db.GetEngine(dbCtx).ID(id).Get(h)
	if err != nil {
		return nil, err
	} else if !has {
		return nil, ErrIssueContentHistoryNotExist{id}
	}
	return h, nil
}

// GetIssueContentHistoryAndPrev get a history and the previous non-deleted history (to compare)
func GetIssueContentHistoryAndPrev(dbCtx context.Context, issueID, id int64) (history, prevHistory *ContentHistory, err error) {
	history = &ContentHistory{}
	has, err := db.GetEngine(dbCtx).Where("id=? AND issue_id=?", id, issueID).Get(history)
	if err != nil {
		log.Error("failed to get issue content history %v. err=%v", id, err)
		return nil, nil, err
	} else if !has {
		log.Error("issue content history does not exist. id=%v. err=%v", id, err)
		return nil, nil, &ErrIssueContentHistoryNotExist{id}
	}

	prevHistory = &ContentHistory{}
	has, err = db.GetEngine(dbCtx).Where(builder.Eq{"issue_id": history.IssueID, "comment_id": history.CommentID, "is_deleted": false}).
		And(builder.Lt{"edited_unix": history.EditedUnix}).
		OrderBy("edited_unix DESC").Limit(1).
		Get(prevHistory)

	if err != nil {
		log.Error("failed to get issue content history %v. err=%v", id, err)
		return nil, nil, err
	} else if !has {
		return history, nil, nil
	}

	return history, prevHistory, nil
}