Save and view issue/comment content history (#16909)
* 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>tokarchuk/v1.17
parent
ff9a8a2231
commit
c5c88f2f18
@ -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("..", ".."), "") |
||||||
|
} |
@ -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() |
||||||
|
} |
@ -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, |
||||||
|
}) |
||||||
|
} |
@ -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); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
Loading…
Reference in new issue