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:
wxiaoguang 2021-10-11 06:40:03 +08:00 committed by GitHub
parent ff9a8a2231
commit c5c88f2f18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 766 additions and 8 deletions

View file

@ -54,9 +54,11 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) {
opts.Dir = fixturesDir opts.Dir = fixturesDir
} else { } else {
for _, f := range fixtureFiles { for _, f := range fixtureFiles {
if len(f) != 0 {
opts.Files = append(opts.Files, filepath.Join(fixturesDir, f)) opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
} }
} }
}
if err = CreateTestEngine(opts); err != nil { if err = CreateTestEngine(opts); err != nil {
fatalTestError("Error creating test engine: %v\n", err) fatalTestError("Error creating test engine: %v\n", err)

View file

@ -14,6 +14,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references" "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) return fmt.Errorf("UpdateIssueCols: %v", err)
} }
if err = issue.addCrossReferences(db.GetEngine(ctx), doer, true); err != nil { if err = issues.SaveIssueContentHistory(db.GetEngine(ctx), issue.PosterID, issue.ID, 0,
return err 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() 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 { if err = opts.Issue.loadAttributes(e); err != nil {
return err 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) 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) { func deleteIssuesByRepoID(sess db.Engine, repoID int64) (attachmentPaths []string, err error) {
deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"issue.repo_id": repoID}) 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 // Delete comments and attachments
if _, err = sess.In("issue_id", deleteCond). if _, err = sess.In("issue_id", deleteCond).
Delete(&Comment{}); err != nil { Delete(&Comment{}); err != nil {

View file

@ -14,6 +14,7 @@ import (
"unicode/utf8" "unicode/utf8"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@ -1083,6 +1084,12 @@ func deleteComment(e db.Engine, comment *Comment) error {
return err return err
} }
if _, err := e.Delete(&issues.ContentHistory{
CommentID: comment.ID,
}); err != nil {
return err
}
if comment.Type == CommentTypeComment { if comment.Type == CommentTypeComment {
if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil { if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil {
return err return err

View file

@ -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
}

View file

@ -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)
}

View file

@ -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("..", ".."), "")
}

View file

@ -348,6 +348,8 @@ var migrations = []Migration{
NewMigration("Add Color to ProjectBoard table", addColorColToProjectBoard), NewMigration("Add Color to ProjectBoard table", addColorColToProjectBoard),
// v197 -> v198 // v197 -> v198
NewMigration("Add renamed_branch table", addRenamedBranchTable), NewMigration("Add renamed_branch table", addRenamedBranchTable),
// v198 -> v199
NewMigration("Add issue content history table", addTableIssueContentHistory),
} }
// GetCurrentDBVersion returns the current db version // GetCurrentDBVersion returns the current db version

33
models/migrations/v198.go Normal file
View file

@ -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()
}

View file

@ -1377,6 +1377,12 @@ issues.review.un_resolve_conversation = Unresolve conversation
issues.review.resolved_by = marked this conversation as resolved issues.review.resolved_by = marked this conversation as resolved
issues.assignee.error = Not all assignees was added due to an unexpected error. issues.assignee.error = Not all assignees was added due to an unexpected error.
issues.reference_issue.body = Body 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_base = base
compare.compare_head = compare compare.compare_head = compare

View 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,
})
}

View file

@ -732,6 +732,9 @@ func RegisterRoutes(m *web.Route) {
m.Get("/attachments", repo.GetIssueAttachments) m.Get("/attachments", repo.GetIssueAttachments)
m.Get("/attachments/{uuid}", repo.GetAttachment) 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("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone)
@ -853,6 +856,11 @@ func RegisterRoutes(m *web.Route) {
m.Group("", func() { m.Group("", func() {
m.Get("/{type:issues|pulls}", repo.Issues) m.Get("/{type:issues|pulls}", repo.Issues)
m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue) 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("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels)
m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones)
}, context.RepoRef()) }, context.RepoRef())

View file

@ -7,7 +7,9 @@ package comments
import ( import (
"code.gitea.io/gitea/models" "code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/notification"
"code.gitea.io/gitea/modules/timeutil"
) )
// CreateIssueComment creates a plain issue comment. // CreateIssueComment creates a plain issue comment.
@ -23,10 +25,16 @@ func CreateIssueComment(doer *models.User, repo *models.Repository, issue *model
if err != nil { if err != nil {
return nil, err 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) mentions, err := issue.FindAndUpdateIssueMentions(db.DefaultContext, doer, comment.Content)
if err != nil { if err != nil {
return nil, err return nil, err
} }
notification.NotifyCreateIssueComment(doer, repo, issue, comment, mentions) notification.NotifyCreateIssueComment(doer, repo, issue, comment, mentions)
return comment, nil return comment, nil
@ -38,6 +46,13 @@ func UpdateComment(c *models.Comment, doer *models.User, oldContent string) erro
return err 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) notification.NotifyUpdateComment(doer, c, oldContent)
return nil return nil

View file

@ -8,6 +8,13 @@
{{template "repo/issue/view_title" .}} {{template "repo/issue/view_title" .}}
{{end}} {{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 }} {{ $createdStr:= TimeSinceUnix .Issue.CreatedUnix $.Lang }}
<div class="twelve wide column comment-list prevent-before-timeline"> <div class="twelve wide column comment-list prevent-before-timeline">
<ui class="ui timeline"> <ui class="ui timeline">

View file

@ -535,12 +535,7 @@
</div> </div>
{{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}} {{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="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 basic modal remove-dependency">
<div class="ui icon header"> <div class="ui icon header">

View file

@ -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>&bull; ${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);
}
});
}

View file

@ -21,6 +21,7 @@ import {createCodeEditor, createMonaco} from './features/codeeditor.js';
import {initMarkupAnchors} from './markup/anchors.js'; import {initMarkupAnchors} from './markup/anchors.js';
import {initNotificationsTable, initNotificationCount} from './features/notification.js'; import {initNotificationsTable, initNotificationCount} from './features/notification.js';
import {initLastCommitLoader} from './features/lastcommitloader.js'; import {initLastCommitLoader} from './features/lastcommitloader.js';
import {initIssueContentHistory} from './features/issue-content-history.js';
import {initStopwatch} from './features/stopwatch.js'; import {initStopwatch} from './features/stopwatch.js';
import {showLineButton} from './code/linebutton.js'; import {showLineButton} from './code/linebutton.js';
import {initMarkupContent, initCommentContent} from './markup/content.js'; import {initMarkupContent, initCommentContent} from './markup/content.js';
@ -2873,6 +2874,7 @@ $(document).ready(async () => {
initFileViewToggle(); initFileViewToggle();
initReleaseEditor(); initReleaseEditor();
initRelease(); initRelease();
initIssueContentHistory();
const routes = { const routes = {
'div.user.settings': initUserSettings, 'div.user.settings': initUserSettings,

View file

@ -13,6 +13,7 @@ import octiconProject from '../../public/img/svg/octicon-project.svg';
import octiconRepo from '../../public/img/svg/octicon-repo.svg'; import octiconRepo from '../../public/img/svg/octicon-repo.svg';
import octiconRepoForked from '../../public/img/svg/octicon-repo-forked.svg'; import octiconRepoForked from '../../public/img/svg/octicon-repo-forked.svg';
import octiconRepoTemplate from '../../public/img/svg/octicon-repo-template.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'; import Vue from 'vue';
@ -32,6 +33,7 @@ export const svgs = {
'octicon-repo': octiconRepo, 'octicon-repo': octiconRepo,
'octicon-repo-forked': octiconRepoForked, 'octicon-repo-forked': octiconRepoForked,
'octicon-repo-template': octiconRepoTemplate, 'octicon-repo-template': octiconRepoTemplate,
'octicon-triangle-down': octiconTriangleDown,
}; };
const parser = new DOMParser(); const parser = new DOMParser();