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>
This commit is contained in:
parent
ff9a8a2231
commit
c5c88f2f18
17 changed files with 766 additions and 8 deletions
206
routers/web/repo/issue_content_history.go
Normal file
206
routers/web/repo/issue_content_history.go
Normal file
|
@ -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())
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue