* issue content history * Use timeutil.TimeStampNow() for content history time instead of issue/comment.UpdatedUnix (which are not updated in time) * i18n for frontend * refactor * clean up * fix refactor * re-format * temp refactor * follow db refactor * rename IssueContentHistory to ContentHistory, remove empty model tags * fix html * use avatar refactor to generate avatar url * add unit test, keep at most 20 history revisions. * re-format * syntax nit * Add issue content history table * Update models/migrations/v197.go Co-authored-by: 6543 <6543@obermui.de> * fix merge Co-authored-by: zeripath <art27@cantab.net> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: Lauris BH <lauris@nix.lv>tags/v1.16.0-rc1
@@ -54,7 +54,9 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) { | |||
opts.Dir = fixturesDir | |||
} else { | |||
for _, f := range fixtureFiles { | |||
opts.Files = append(opts.Files, filepath.Join(fixturesDir, f)) | |||
if len(f) != 0 { | |||
opts.Files = append(opts.Files, filepath.Join(fixturesDir, f)) | |||
} | |||
} | |||
} | |||
@@ -14,6 +14,7 @@ import ( | |||
"strings" | |||
"code.gitea.io/gitea/models/db" | |||
"code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/base" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/references" | |||
@@ -803,8 +804,13 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) { | |||
return fmt.Errorf("UpdateIssueCols: %v", err) | |||
} | |||
if err = issue.addCrossReferences(db.GetEngine(ctx), doer, true); err != nil { | |||
return err | |||
if err = issues.SaveIssueContentHistory(db.GetEngine(ctx), issue.PosterID, issue.ID, 0, | |||
timeutil.TimeStampNow(), issue.Content, false); err != nil { | |||
return fmt.Errorf("SaveIssueContentHistory: %v", err) | |||
} | |||
if err = issue.addCrossReferences(ctx.Engine(), doer, true); err != nil { | |||
return fmt.Errorf("addCrossReferences: %v", err) | |||
} | |||
return committer.Commit() | |||
@@ -972,6 +978,12 @@ func newIssue(e db.Engine, doer *User, opts NewIssueOptions) (err error) { | |||
if err = opts.Issue.loadAttributes(e); err != nil { | |||
return err | |||
} | |||
if err = issues.SaveIssueContentHistory(e, opts.Issue.PosterID, opts.Issue.ID, 0, | |||
timeutil.TimeStampNow(), opts.Issue.Content, true); err != nil { | |||
return err | |||
} | |||
return opts.Issue.addCrossReferences(e, doer, false) | |||
} | |||
@@ -2132,6 +2144,12 @@ func UpdateReactionsMigrationsByType(gitServiceType structs.GitServiceType, orig | |||
func deleteIssuesByRepoID(sess db.Engine, repoID int64) (attachmentPaths []string, err error) { | |||
deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"issue.repo_id": repoID}) | |||
// Delete content histories | |||
if _, err = sess.In("issue_id", deleteCond). | |||
Delete(&issues.ContentHistory{}); err != nil { | |||
return | |||
} | |||
// Delete comments and attachments | |||
if _, err = sess.In("issue_id", deleteCond). | |||
Delete(&Comment{}); err != nil { |
@@ -14,6 +14,7 @@ import ( | |||
"unicode/utf8" | |||
"code.gitea.io/gitea/models/db" | |||
"code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/git" | |||
"code.gitea.io/gitea/modules/json" | |||
"code.gitea.io/gitea/modules/log" | |||
@@ -1083,6 +1084,12 @@ func deleteComment(e db.Engine, comment *Comment) error { | |||
return err | |||
} | |||
if _, err := e.Delete(&issues.ContentHistory{ | |||
CommentID: comment.ID, | |||
}); err != nil { | |||
return err | |||
} | |||
if comment.Type == CommentTypeComment { | |||
if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil { | |||
return err |
@@ -0,0 +1,230 @@ | |||
// Copyright 2021 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 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" | |||
"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(e db.Engine, 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, | |||
} | |||
_, err := e.Insert(ch) | |||
if 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(e, 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(e db.Engine, issueID, commentID int64, limit int) { | |||
type IDEditTime struct { | |||
ID int64 | |||
EditedUnix timeutil.TimeStamp | |||
} | |||
var res []*IDEditTime | |||
err := e.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) <= 1 { | |||
return | |||
} | |||
outDatedCount := len(res) - limit | |||
for outDatedCount > 0 { | |||
var indexToDelete int | |||
minEditedInterval := -1 | |||
// find a history revision with minimal edited interval to delete | |||
for i := 1; i < len(res); 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 = e.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("history_count > 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 | |||
UserAvatarLink string | |||
HistoryID int64 | |||
EditedUnix timeutil.TimeStamp | |||
IsFirstCreated bool | |||
IsDeleted bool | |||
} | |||
// FetchIssueContentHistoryList fetch list | |||
func FetchIssueContentHistoryList(dbCtx context.Context, issueID int64, commentID int64) ([]*IssueContentListItem, error) { | |||
res := make([]*IssueContentListItem, 0) | |||
err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_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 { | |||
item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0) | |||
} | |||
return res, nil | |||
} | |||
//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) | |||
} | |||
// 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, id int64) (history, prevHistory *ContentHistory, err error) { | |||
history = &ContentHistory{} | |||
has, err := db.GetEngine(dbCtx).ID(id).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 | |||
} |
@@ -0,0 +1,74 @@ | |||
// Copyright 2021 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 issues | |||
import ( | |||
"testing" | |||
"code.gitea.io/gitea/models/db" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"github.com/stretchr/testify/assert" | |||
) | |||
func TestContentHistory(t *testing.T) { | |||
assert.NoError(t, db.PrepareTestDatabase()) | |||
dbCtx := db.DefaultContext | |||
dbEngine := db.GetEngine(dbCtx) | |||
timeStampNow := timeutil.TimeStampNow() | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow, "i-a", true) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow.Add(2), "i-b", false) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow.Add(7), "i-c", false) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow, "c-a", true) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(5), "c-b", false) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(20), "c-c", false) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(50), "c-d", false) | |||
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(51), "c-e", false) | |||
h1, _ := GetIssueContentHistoryByID(dbCtx, 1) | |||
assert.EqualValues(t, 1, h1.ID) | |||
m, _ := QueryIssueContentHistoryEditedCountMap(dbCtx, 10) | |||
assert.Equal(t, 3, m[0]) | |||
assert.Equal(t, 5, m[100]) | |||
/* | |||
we can not have this test with real `User` now, because we can not depend on `User` model (circle-import), so there is no `user` table | |||
when the refactor of models are done, this test will be possible to be run then with a real `User` model. | |||
*/ | |||
type User struct { | |||
ID int64 | |||
Name string | |||
} | |||
_ = dbEngine.Sync2(&User{}) | |||
list1, _ := FetchIssueContentHistoryList(dbCtx, 10, 0) | |||
assert.Len(t, list1, 3) | |||
list2, _ := FetchIssueContentHistoryList(dbCtx, 10, 100) | |||
assert.Len(t, list2, 5) | |||
h6, h6Prev, _ := GetIssueContentHistoryAndPrev(dbCtx, 6) | |||
assert.EqualValues(t, 6, h6.ID) | |||
assert.EqualValues(t, 5, h6Prev.ID) | |||
// soft-delete | |||
_ = SoftDeleteIssueContentHistory(dbCtx, 5) | |||
h6, h6Prev, _ = GetIssueContentHistoryAndPrev(dbCtx, 6) | |||
assert.EqualValues(t, 6, h6.ID) | |||
assert.EqualValues(t, 4, h6Prev.ID) | |||
// only keep 3 history revisions for comment_id=100 | |||
keepLimitedContentHistory(dbEngine, 10, 100, 3) | |||
list1, _ = FetchIssueContentHistoryList(dbCtx, 10, 0) | |||
assert.Len(t, list1, 3) | |||
list2, _ = FetchIssueContentHistoryList(dbCtx, 10, 100) | |||
assert.Len(t, list2, 3) | |||
assert.EqualValues(t, 7, list2[0].HistoryID) | |||
assert.EqualValues(t, 6, list2[1].HistoryID) | |||
assert.EqualValues(t, 4, list2[2].HistoryID) | |||
} |
@@ -0,0 +1,16 @@ | |||
// Copyright 2020 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 issues | |||
import ( | |||
"path/filepath" | |||
"testing" | |||
"code.gitea.io/gitea/models/db" | |||
) | |||
func TestMain(m *testing.M) { | |||
db.MainTest(m, filepath.Join("..", ".."), "") | |||
} |
@@ -348,6 +348,8 @@ var migrations = []Migration{ | |||
NewMigration("Add Color to ProjectBoard table", addColorColToProjectBoard), | |||
// v197 -> v198 | |||
NewMigration("Add renamed_branch table", addRenamedBranchTable), | |||
// v198 -> v199 | |||
NewMigration("Add issue content history table", addTableIssueContentHistory), | |||
} | |||
// GetCurrentDBVersion returns the current db version |
@@ -0,0 +1,33 @@ | |||
// Copyright 2021 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 migrations | |||
import ( | |||
"fmt" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"xorm.io/xorm" | |||
) | |||
func addTableIssueContentHistory(x *xorm.Engine) error { | |||
type IssueContentHistory 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 | |||
} | |||
sess := x.NewSession() | |||
defer sess.Close() | |||
if err := sess.Sync2(new(IssueContentHistory)); err != nil { | |||
return fmt.Errorf("Sync2: %v", err) | |||
} | |||
return sess.Commit() | |||
} |
@@ -1377,6 +1377,12 @@ issues.review.un_resolve_conversation = Unresolve conversation | |||
issues.review.resolved_by = marked this conversation as resolved | |||
issues.assignee.error = Not all assignees was added due to an unexpected error. | |||
issues.reference_issue.body = Body | |||
issues.content_history.deleted = deleted | |||
issues.content_history.edited = edited | |||
issues.content_history.created = created | |||
issues.content_history.delete_from_history = Delete from history | |||
issues.content_history.delete_from_history_confirm = Delete from history? | |||
issues.content_history.options = Options | |||
compare.compare_base = base | |||
compare.compare_head = compare |
@@ -0,0 +1,206 @@ | |||
// Copyright 2021 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 ( | |||
"bytes" | |||
"fmt" | |||
"html" | |||
"net/http" | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/models/db" | |||
issuesModel "code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/context" | |||
"code.gitea.io/gitea/modules/log" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
"github.com/sergi/go-diff/diffmatchpatch" | |||
"github.com/unknwon/i18n" | |||
) | |||
// GetContentHistoryOverview get overview | |||
func GetContentHistoryOverview(ctx *context.Context) { | |||
issue := GetActionIssue(ctx) | |||
if issue == nil { | |||
return | |||
} | |||
lang := ctx.Data["Lang"].(string) | |||
editedHistoryCountMap, _ := issuesModel.QueryIssueContentHistoryEditedCountMap(db.DefaultContext, issue.ID) | |||
ctx.JSON(http.StatusOK, map[string]interface{}{ | |||
"i18n": map[string]interface{}{ | |||
"textEdited": i18n.Tr(lang, "repo.issues.content_history.edited"), | |||
"textDeleteFromHistory": i18n.Tr(lang, "repo.issues.content_history.delete_from_history"), | |||
"textDeleteFromHistoryConfirm": i18n.Tr(lang, "repo.issues.content_history.delete_from_history_confirm"), | |||
"textOptions": i18n.Tr(lang, "repo.issues.content_history.options"), | |||
}, | |||
"editedHistoryCountMap": editedHistoryCountMap, | |||
}) | |||
} | |||
// GetContentHistoryList get list | |||
func GetContentHistoryList(ctx *context.Context) { | |||
issue := GetActionIssue(ctx) | |||
commentID := ctx.FormInt64("comment_id") | |||
if issue == nil { | |||
return | |||
} | |||
items, _ := issuesModel.FetchIssueContentHistoryList(db.DefaultContext, issue.ID, commentID) | |||
// render history list to HTML for frontend dropdown items: (name, value) | |||
// name is HTML of "avatar + userName + userAction + timeSince" | |||
// value is historyId | |||
lang := ctx.Data["Lang"].(string) | |||
var results []map[string]interface{} | |||
for _, item := range items { | |||
var actionText string | |||
if item.IsDeleted { | |||
actionTextDeleted := i18n.Tr(lang, "repo.issues.content_history.deleted") | |||
actionText = "<i data-history-is-deleted='1'>" + actionTextDeleted + "</i>" | |||
} else if item.IsFirstCreated { | |||
actionText = i18n.Tr(lang, "repo.issues.content_history.created") | |||
} else { | |||
actionText = i18n.Tr(lang, "repo.issues.content_history.edited") | |||
} | |||
timeSinceText := timeutil.TimeSinceUnix(item.EditedUnix, lang) | |||
results = append(results, map[string]interface{}{ | |||
"name": fmt.Sprintf("<img class='ui avatar image' src='%s'><strong>%s</strong> %s %s", | |||
html.EscapeString(item.UserAvatarLink), html.EscapeString(item.UserName), actionText, timeSinceText), | |||
"value": item.HistoryID, | |||
}) | |||
} | |||
ctx.JSON(http.StatusOK, map[string]interface{}{ | |||
"results": results, | |||
}) | |||
} | |||
// canSoftDeleteContentHistory checks whether current user can soft-delete a history revision | |||
// Admins or owners can always delete history revisions. Normal users can only delete own history revisions. | |||
func canSoftDeleteContentHistory(ctx *context.Context, issue *models.Issue, comment *models.Comment, | |||
history *issuesModel.ContentHistory) bool { | |||
canSoftDelete := false | |||
if ctx.Repo.IsOwner() { | |||
canSoftDelete = true | |||
} else if ctx.Repo.CanWrite(models.UnitTypeIssues) { | |||
canSoftDelete = ctx.User.ID == history.PosterID | |||
if comment == nil { | |||
canSoftDelete = canSoftDelete && (ctx.User.ID == issue.PosterID) | |||
canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) | |||
} else { | |||
canSoftDelete = canSoftDelete && (ctx.User.ID == comment.PosterID) | |||
canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) | |||
canSoftDelete = canSoftDelete && (history.CommentID == comment.ID) | |||
} | |||
} | |||
return canSoftDelete | |||
} | |||
//GetContentHistoryDetail get detail | |||
func GetContentHistoryDetail(ctx *context.Context) { | |||
issue := GetActionIssue(ctx) | |||
if issue == nil { | |||
return | |||
} | |||
historyID := ctx.FormInt64("history_id") | |||
history, prevHistory, err := issuesModel.GetIssueContentHistoryAndPrev(db.DefaultContext, historyID) | |||
if err != nil { | |||
ctx.JSON(http.StatusNotFound, map[string]interface{}{ | |||
"message": "Can not find the content history", | |||
}) | |||
return | |||
} | |||
// get the related comment if this history revision is for a comment, otherwise the history revision is for an issue. | |||
var comment *models.Comment | |||
if history.CommentID != 0 { | |||
var err error | |||
if comment, err = models.GetCommentByID(history.CommentID); err != nil { | |||
log.Error("can not get comment for issue content history %v. err=%v", historyID, err) | |||
return | |||
} | |||
} | |||
// get the previous history revision (if exists) | |||
var prevHistoryID int64 | |||
var prevHistoryContentText string | |||
if prevHistory != nil { | |||
prevHistoryID = prevHistory.ID | |||
prevHistoryContentText = prevHistory.ContentText | |||
} | |||
// compare the current history revision with the previous one | |||
dmp := diffmatchpatch.New() | |||
diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, true) | |||
diff = dmp.DiffCleanupEfficiency(diff) | |||
// use chroma to render the diff html | |||
diffHTMLBuf := bytes.Buffer{} | |||
diffHTMLBuf.WriteString("<pre class='chroma' style='tab-size: 4'>") | |||
for _, it := range diff { | |||
if it.Type == diffmatchpatch.DiffInsert { | |||
diffHTMLBuf.WriteString("<span class='gi'>") | |||
diffHTMLBuf.WriteString(html.EscapeString(it.Text)) | |||
diffHTMLBuf.WriteString("</span>") | |||
} else if it.Type == diffmatchpatch.DiffDelete { | |||
diffHTMLBuf.WriteString("<span class='gd'>") | |||
diffHTMLBuf.WriteString(html.EscapeString(it.Text)) | |||
diffHTMLBuf.WriteString("</span>") | |||
} else { | |||
diffHTMLBuf.WriteString(html.EscapeString(it.Text)) | |||
} | |||
} | |||
diffHTMLBuf.WriteString("</pre>") | |||
ctx.JSON(http.StatusOK, map[string]interface{}{ | |||
"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history), | |||
"historyId": historyID, | |||
"prevHistoryId": prevHistoryID, | |||
"diffHtml": diffHTMLBuf.String(), | |||
}) | |||
} | |||
//SoftDeleteContentHistory soft delete | |||
func SoftDeleteContentHistory(ctx *context.Context) { | |||
issue := GetActionIssue(ctx) | |||
if issue == nil { | |||
return | |||
} | |||
commentID := ctx.FormInt64("comment_id") | |||
historyID := ctx.FormInt64("history_id") | |||
var comment *models.Comment | |||
var history *issuesModel.ContentHistory | |||
var err error | |||
if commentID != 0 { | |||
if comment, err = models.GetCommentByID(commentID); err != nil { | |||
log.Error("can not get comment for issue content history %v. err=%v", historyID, err) | |||
return | |||
} | |||
} | |||
if history, err = issuesModel.GetIssueContentHistoryByID(db.DefaultContext, historyID); err != nil { | |||
log.Error("can not get issue content history %v. err=%v", historyID, err) | |||
return | |||
} | |||
canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history) | |||
if !canSoftDelete { | |||
ctx.JSON(http.StatusForbidden, map[string]interface{}{ | |||
"message": "Can not delete the content history", | |||
}) | |||
return | |||
} | |||
err = issuesModel.SoftDeleteIssueContentHistory(db.DefaultContext, historyID) | |||
log.Debug("soft delete issue content history. issue=%d, comment=%d, history=%d", issue.ID, commentID, historyID) | |||
ctx.JSON(http.StatusOK, map[string]interface{}{ | |||
"ok": err == nil, | |||
}) | |||
} |
@@ -732,6 +732,9 @@ func RegisterRoutes(m *web.Route) { | |||
m.Get("/attachments", repo.GetIssueAttachments) | |||
m.Get("/attachments/{uuid}", repo.GetAttachment) | |||
}) | |||
m.Group("/{index}", func() { | |||
m.Post("/content-history/soft-delete", repo.SoftDeleteContentHistory) | |||
}) | |||
m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) | |||
m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) | |||
@@ -853,6 +856,11 @@ func RegisterRoutes(m *web.Route) { | |||
m.Group("", func() { | |||
m.Get("/{type:issues|pulls}", repo.Issues) | |||
m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue) | |||
m.Group("/{type:issues|pulls}/{index}/content-history", func() { | |||
m.Get("/overview", repo.GetContentHistoryOverview) | |||
m.Get("/list", repo.GetContentHistoryList) | |||
m.Get("/detail", repo.GetContentHistoryDetail) | |||
}) | |||
m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) | |||
m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) | |||
}, context.RepoRef()) |
@@ -7,7 +7,9 @@ package comments | |||
import ( | |||
"code.gitea.io/gitea/models" | |||
"code.gitea.io/gitea/models/db" | |||
"code.gitea.io/gitea/models/issues" | |||
"code.gitea.io/gitea/modules/notification" | |||
"code.gitea.io/gitea/modules/timeutil" | |||
) | |||
// CreateIssueComment creates a plain issue comment. | |||
@@ -23,10 +25,16 @@ func CreateIssueComment(doer *models.User, repo *models.Repository, issue *model | |||
if err != nil { | |||
return nil, err | |||
} | |||
err = issues.SaveIssueContentHistory(db.GetEngine(db.DefaultContext), doer.ID, issue.ID, comment.ID, timeutil.TimeStampNow(), comment.Content, true) | |||
if err != nil { | |||
return nil, err | |||
} | |||
mentions, err := issue.FindAndUpdateIssueMentions(db.DefaultContext, doer, comment.Content) | |||
if err != nil { | |||
return nil, err | |||
} | |||
notification.NotifyCreateIssueComment(doer, repo, issue, comment, mentions) | |||
return comment, nil | |||
@@ -38,6 +46,13 @@ func UpdateComment(c *models.Comment, doer *models.User, oldContent string) erro | |||
return err | |||
} | |||
if c.Type == models.CommentTypeComment && c.Content != oldContent { | |||
err := issues.SaveIssueContentHistory(db.GetEngine(db.DefaultContext), doer.ID, c.IssueID, c.ID, timeutil.TimeStampNow(), c.Content, false) | |||
if err != nil { | |||
return err | |||
} | |||
} | |||
notification.NotifyUpdateComment(doer, c, oldContent) | |||
return nil |
@@ -8,6 +8,13 @@ | |||
{{template "repo/issue/view_title" .}} | |||
{{end}} | |||
<!-- I know, there is probably a better way to do this (moved from sidebar.tmpl, original author: 6543 @ 2021-02-28) --> | |||
<!-- Agree, there should be a better way, eg: introduce window.config.PageData (original author: wxiaoguang @ 2021-09-05) --> | |||
<input type="hidden" id="repolink" value="{{$.RepoRelPath}}"> | |||
<input type="hidden" id="repoId" value="{{.Repository.ID}}"> | |||
<input type="hidden" id="issueIndex" value="{{.Issue.Index}}"/> | |||
<input type="hidden" id="type" value="{{.IssueType}}"> | |||
{{ $createdStr:= TimeSinceUnix .Issue.CreatedUnix $.Lang }} | |||
<div class="twelve wide column comment-list prevent-before-timeline"> | |||
<ui class="ui timeline"> |
@@ -535,12 +535,7 @@ | |||
</div> | |||
{{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}} | |||
<input type="hidden" id="repolink" value="{{$.RepoRelPath}}"> | |||
<input type="hidden" id="repoId" value="{{.Repository.ID}}"> | |||
<input type="hidden" id="crossRepoSearch" value="{{.AllowCrossRepositoryDependencies}}"> | |||
<input type="hidden" id="type" value="{{.IssueType}}"> | |||
<!-- I know, there is probably a better way to do this --> | |||
<input type="hidden" id="issueIndex" value="{{.Issue.Index}}"/> | |||
<div class="ui basic modal remove-dependency"> | |||
<div class="ui icon header"> |
@@ -0,0 +1,135 @@ | |||
import {svg} from '../svg.js'; | |||
const {AppSubUrl, csrf} = window.config; | |||
let i18nTextEdited; | |||
let i18nTextOptions; | |||
let i18nTextDeleteFromHistory; | |||
let i18nTextDeleteFromHistoryConfirm; | |||
function showContentHistoryDetail(issueBaseUrl, commentId, historyId, itemTitleHtml) { | |||
let $dialog = $('.content-history-detail-dialog'); | |||
if ($dialog.length) return; | |||
$dialog = $(` | |||
<div class="ui modal content-history-detail-dialog" style="min-height: 50%;"> | |||
<i class="close icon inside"></i> | |||
<div class="header"> | |||
${itemTitleHtml} | |||
<div class="ui dropdown right dialog-header-options" style="display: none; margin-right: 50px;"> | |||
${i18nTextOptions} <i class="dropdown icon"></i> | |||
<div class="menu"> | |||
<div class="item red text" data-option-item="delete">${i18nTextDeleteFromHistory}</div> | |||
</div> | |||
</div> | |||
</div> | |||
<!-- ".modal .content" style was polluted in "_base.less": "&.modal > .content" --> | |||
<div class="scrolling content" style="text-align: left;"> | |||
<div class="ui loader active"></div> | |||
</div> | |||
</div>`); | |||
$dialog.appendTo($('body')); | |||
$dialog.find('.dialog-header-options').dropdown({ | |||
showOnFocus: false, | |||
allowReselection: true, | |||
onChange(_value, _text, $item) { | |||
const optionItem = $item.data('option-item'); | |||
if (optionItem === 'delete') { | |||
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) { | |||
$.post(`${issueBaseUrl}/content-history/soft-delete?comment_id=${commentId}&history_id=${historyId}`, { | |||
_csrf: csrf, | |||
}).done((resp) => { | |||
if (resp.ok) { | |||
$dialog.modal('hide'); | |||
} else { | |||
alert(resp.message); | |||
} | |||
}); | |||
} | |||
} else { // required by eslint | |||
window.alert(`unknown option item: ${optionItem}`); | |||
} | |||
}, | |||
onHide() { | |||
$(this).dropdown('clear', true); | |||
} | |||
}); | |||
$dialog.modal({ | |||
onShow() { | |||
$.ajax({ | |||
url: `${issueBaseUrl}/content-history/detail?comment_id=${commentId}&history_id=${historyId}`, | |||
data: { | |||
_csrf: csrf, | |||
}, | |||
}).done((resp) => { | |||
$dialog.find('.content').html(resp.diffHtml); | |||
// there is only one option "item[data-option-item=delete]", so the dropdown can be entirely shown/hidden. | |||
if (resp.canSoftDelete) { | |||
$dialog.find('.dialog-header-options').show(); | |||
} | |||
}); | |||
}, | |||
onHidden() { | |||
$dialog.remove(); | |||
}, | |||
}).modal('show'); | |||
} | |||
function showContentHistoryMenu(issueBaseUrl, $item, commentId) { | |||
const $headerLeft = $item.find('.comment-header-left'); | |||
const menuHtml = ` | |||
<div class="ui pointing dropdown top left content-history-menu" data-comment-id="${commentId}"> | |||
<a>• ${i18nTextEdited} ${svg('octicon-triangle-down', 17)}</a> | |||
<div class="menu"> | |||
</div> | |||
</div>`; | |||
$headerLeft.find(`.content-history-menu`).remove(); | |||
$headerLeft.append($(menuHtml)); | |||
$headerLeft.find('.dropdown').dropdown({ | |||
action: 'hide', | |||
apiSettings: { | |||
cache: false, | |||
url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`, | |||
}, | |||
saveRemoteData: false, | |||
onHide() { | |||
$(this).dropdown('change values', null); | |||
}, | |||
onChange(value, itemHtml, $item) { | |||
if (value && !$item.find('[data-history-is-deleted=1]').length) { | |||
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml); | |||
} | |||
}, | |||
}); | |||
} | |||
export function initIssueContentHistory() { | |||
const issueIndex = $('#issueIndex').val(); | |||
const $itemIssue = $('.timeline-item.comment.first'); | |||
if (!issueIndex || !$itemIssue.length) return; | |||
const repoLink = $('#repolink').val(); | |||
const issueBaseUrl = `${AppSubUrl}/${repoLink}/issues/${issueIndex}`; | |||
$.ajax({ | |||
url: `${issueBaseUrl}/content-history/overview`, | |||
data: { | |||
_csrf: csrf, | |||
}, | |||
}).done((resp) => { | |||
i18nTextEdited = resp.i18n.textEdited; | |||
i18nTextDeleteFromHistory = resp.i18n.textDeleteFromHistory; | |||
i18nTextDeleteFromHistoryConfirm = resp.i18n.textDeleteFromHistoryConfirm; | |||
i18nTextOptions = resp.i18n.textOptions; | |||
if (resp.editedHistoryCountMap[0]) { | |||
showContentHistoryMenu(issueBaseUrl, $itemIssue, '0'); | |||
} | |||
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) { | |||
if (commentId === '0') continue; | |||
const $itemComment = $(`#issuecomment-${commentId}`); | |||
showContentHistoryMenu(issueBaseUrl, $itemComment, commentId); | |||
} | |||
}); | |||
} |
@@ -21,6 +21,7 @@ import {createCodeEditor, createMonaco} from './features/codeeditor.js'; | |||
import {initMarkupAnchors} from './markup/anchors.js'; | |||
import {initNotificationsTable, initNotificationCount} from './features/notification.js'; | |||
import {initLastCommitLoader} from './features/lastcommitloader.js'; | |||
import {initIssueContentHistory} from './features/issue-content-history.js'; | |||
import {initStopwatch} from './features/stopwatch.js'; | |||
import {showLineButton} from './code/linebutton.js'; | |||
import {initMarkupContent, initCommentContent} from './markup/content.js'; | |||
@@ -2873,6 +2874,7 @@ $(document).ready(async () => { | |||
initFileViewToggle(); | |||
initReleaseEditor(); | |||
initRelease(); | |||
initIssueContentHistory(); | |||
const routes = { | |||
'div.user.settings': initUserSettings, |
@@ -13,6 +13,7 @@ import octiconProject from '../../public/img/svg/octicon-project.svg'; | |||
import octiconRepo from '../../public/img/svg/octicon-repo.svg'; | |||
import octiconRepoForked from '../../public/img/svg/octicon-repo-forked.svg'; | |||
import octiconRepoTemplate from '../../public/img/svg/octicon-repo-template.svg'; | |||
import octiconTriangleDown from '../../public/img/svg/octicon-triangle-down.svg'; | |||
import Vue from 'vue'; | |||
@@ -32,6 +33,7 @@ export const svgs = { | |||
'octicon-repo': octiconRepo, | |||
'octicon-repo-forked': octiconRepoForked, | |||
'octicon-repo-template': octiconRepoTemplate, | |||
'octicon-triangle-down': octiconTriangleDown, | |||
}; | |||
const parser = new DOMParser(); |