mirror of https://github.com/go-gitea/gitea.git
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>pull/17287/head
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