]> source.dussan.org Git - gitea.git/commitdiff
Save and view issue/comment content history (#16909)
authorwxiaoguang <wxiaoguang@gmail.com>
Sun, 10 Oct 2021 22:40:03 +0000 (06:40 +0800)
committerGitHub <noreply@github.com>
Sun, 10 Oct 2021 22:40:03 +0000 (18:40 -0400)
* 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>
17 files changed:
models/db/unit_tests.go
models/issue.go
models/issue_comment.go
models/issues/content_history.go [new file with mode: 0644]
models/issues/content_history_test.go [new file with mode: 0644]
models/issues/main_test.go [new file with mode: 0644]
models/migrations/migrations.go
models/migrations/v198.go [new file with mode: 0644]
options/locale/locale_en-US.ini
routers/web/repo/issue_content_history.go [new file with mode: 0644]
routers/web/web.go
services/comments/comments.go
templates/repo/issue/view_content.tmpl
templates/repo/issue/view_content/sidebar.tmpl
web_src/js/features/issue-content-history.js [new file with mode: 0644]
web_src/js/index.js
web_src/js/svg.js

index d81610df6b6bac00c9f32e7dc2335a920d80e44e..6f079c8676ce4a766493d6cc83315fbf9b621656 100644 (file)
@@ -54,7 +54,9 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) {
                opts.Dir = fixturesDir
        } else {
                for _, f := range fixtureFiles {
-                       opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
+                       if len(f) != 0 {
+                               opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
+                       }
                }
        }
 
index b62394919ce9a26e420baa5a952b70d151aa0b61..823d82a765a79b9610640771690e192bb334ea97 100644 (file)
@@ -14,6 +14,7 @@ import (
        "strings"
 
        "code.gitea.io/gitea/models/db"
+       "code.gitea.io/gitea/models/issues"
        "code.gitea.io/gitea/modules/base"
        "code.gitea.io/gitea/modules/log"
        "code.gitea.io/gitea/modules/references"
@@ -803,8 +804,13 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
                return fmt.Errorf("UpdateIssueCols: %v", err)
        }
 
-       if err = issue.addCrossReferences(db.GetEngine(ctx), doer, true); err != nil {
-               return err
+       if err = issues.SaveIssueContentHistory(db.GetEngine(ctx), issue.PosterID, issue.ID, 0,
+               timeutil.TimeStampNow(), issue.Content, false); err != nil {
+               return fmt.Errorf("SaveIssueContentHistory: %v", err)
+       }
+
+       if err = issue.addCrossReferences(ctx.Engine(), doer, true); err != nil {
+               return fmt.Errorf("addCrossReferences: %v", err)
        }
 
        return committer.Commit()
@@ -972,6 +978,12 @@ func newIssue(e db.Engine, doer *User, opts NewIssueOptions) (err error) {
        if err = opts.Issue.loadAttributes(e); err != nil {
                return err
        }
+
+       if err = issues.SaveIssueContentHistory(e, opts.Issue.PosterID, opts.Issue.ID, 0,
+               timeutil.TimeStampNow(), opts.Issue.Content, true); err != nil {
+               return err
+       }
+
        return opts.Issue.addCrossReferences(e, doer, false)
 }
 
@@ -2132,6 +2144,12 @@ func UpdateReactionsMigrationsByType(gitServiceType structs.GitServiceType, orig
 func deleteIssuesByRepoID(sess db.Engine, repoID int64) (attachmentPaths []string, err error) {
        deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"issue.repo_id": repoID})
 
+       // Delete content histories
+       if _, err = sess.In("issue_id", deleteCond).
+               Delete(&issues.ContentHistory{}); err != nil {
+               return
+       }
+
        // Delete comments and attachments
        if _, err = sess.In("issue_id", deleteCond).
                Delete(&Comment{}); err != nil {
index 01e41814a47445cb9ded072de8de1f69f7935952..0ae9140f0cc7b1e64f247a46f4cf13ba4a5c6b96 100644 (file)
@@ -14,6 +14,7 @@ import (
        "unicode/utf8"
 
        "code.gitea.io/gitea/models/db"
+       "code.gitea.io/gitea/models/issues"
        "code.gitea.io/gitea/modules/git"
        "code.gitea.io/gitea/modules/json"
        "code.gitea.io/gitea/modules/log"
@@ -1083,6 +1084,12 @@ func deleteComment(e db.Engine, comment *Comment) error {
                return err
        }
 
+       if _, err := e.Delete(&issues.ContentHistory{
+               CommentID: comment.ID,
+       }); err != nil {
+               return err
+       }
+
        if comment.Type == CommentTypeComment {
                if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil {
                        return err
diff --git a/models/issues/content_history.go b/models/issues/content_history.go
new file mode 100644 (file)
index 0000000..697d54b
--- /dev/null
@@ -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
+}
diff --git a/models/issues/content_history_test.go b/models/issues/content_history_test.go
new file mode 100644 (file)
index 0000000..dadeb48
--- /dev/null
@@ -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)
+}
diff --git a/models/issues/main_test.go b/models/issues/main_test.go
new file mode 100644 (file)
index 0000000..61a15c5
--- /dev/null
@@ -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("..", ".."), "")
+}
index 6f6296dabfb92b50130d6c094fda64a89707cd26..ef0c0714176539d83bb59a9c2ca6cd0f00e5b928 100644 (file)
@@ -348,6 +348,8 @@ var migrations = []Migration{
        NewMigration("Add Color to ProjectBoard table", addColorColToProjectBoard),
        // v197 -> v198
        NewMigration("Add renamed_branch table", addRenamedBranchTable),
+       // v198 -> v199
+       NewMigration("Add issue content history table", addTableIssueContentHistory),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v198.go b/models/migrations/v198.go
new file mode 100644 (file)
index 0000000..e3c3146
--- /dev/null
@@ -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()
+}
index d5af933f4041ad060a502558a09a23b9f900fa88..2d522acb982f49a11b5514e1dc0e1d543a52c140 100644 (file)
@@ -1377,6 +1377,12 @@ issues.review.un_resolve_conversation = Unresolve conversation
 issues.review.resolved_by = marked this conversation as resolved
 issues.assignee.error = Not all assignees was added due to an unexpected error.
 issues.reference_issue.body = Body
+issues.content_history.deleted = deleted
+issues.content_history.edited = edited
+issues.content_history.created = created
+issues.content_history.delete_from_history = Delete from history
+issues.content_history.delete_from_history_confirm = Delete from history?
+issues.content_history.options = Options
 
 compare.compare_base = base
 compare.compare_head = compare
diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go
new file mode 100644 (file)
index 0000000..c0e9582
--- /dev/null
@@ -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,
+       })
+}
index b4103ccad3f1429cf8eaed43c002ad23d0c02d15..caec0676714c4e1ba3b70d92fb133ddda9233cc5 100644 (file)
@@ -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())
index d65c66aef26bd554772d3be68431d01ec5d72389..d1e5ea4d88a73b5a9df68b111e2fc85e5eec70f9 100644 (file)
@@ -7,7 +7,9 @@ package comments
 import (
        "code.gitea.io/gitea/models"
        "code.gitea.io/gitea/models/db"
+       "code.gitea.io/gitea/models/issues"
        "code.gitea.io/gitea/modules/notification"
+       "code.gitea.io/gitea/modules/timeutil"
 )
 
 // CreateIssueComment creates a plain issue comment.
@@ -23,10 +25,16 @@ func CreateIssueComment(doer *models.User, repo *models.Repository, issue *model
        if err != nil {
                return nil, err
        }
+       err = issues.SaveIssueContentHistory(db.GetEngine(db.DefaultContext), doer.ID, issue.ID, comment.ID, timeutil.TimeStampNow(), comment.Content, true)
+       if err != nil {
+               return nil, err
+       }
+
        mentions, err := issue.FindAndUpdateIssueMentions(db.DefaultContext, doer, comment.Content)
        if err != nil {
                return nil, err
        }
+
        notification.NotifyCreateIssueComment(doer, repo, issue, comment, mentions)
 
        return comment, nil
@@ -38,6 +46,13 @@ func UpdateComment(c *models.Comment, doer *models.User, oldContent string) erro
                return err
        }
 
+       if c.Type == models.CommentTypeComment && c.Content != oldContent {
+               err := issues.SaveIssueContentHistory(db.GetEngine(db.DefaultContext), doer.ID, c.IssueID, c.ID, timeutil.TimeStampNow(), c.Content, false)
+               if err != nil {
+                       return err
+               }
+       }
+
        notification.NotifyUpdateComment(doer, c, oldContent)
 
        return nil
index 872c3b620b135a7f505489ef7be45d142066deda..95c929617442cc3d79c1ad11c2b0269dbcb9d4eb 100644 (file)
@@ -8,6 +8,13 @@
                {{template "repo/issue/view_title" .}}
        {{end}}
 
+       <!-- I know, there is probably a better way to do this (moved from sidebar.tmpl, original author: 6543 @ 2021-02-28) -->
+       <!-- Agree, there should be a better way, eg: introduce window.config.PageData (original author: wxiaoguang @ 2021-09-05) -->
+       <input type="hidden" id="repolink" value="{{$.RepoRelPath}}">
+       <input type="hidden" id="repoId" value="{{.Repository.ID}}">
+       <input type="hidden" id="issueIndex" value="{{.Issue.Index}}"/>
+       <input type="hidden" id="type" value="{{.IssueType}}">
+
        {{ $createdStr:= TimeSinceUnix .Issue.CreatedUnix $.Lang }}
        <div class="twelve wide column comment-list prevent-before-timeline">
                <ui class="ui timeline">
index 2ebb02d8a9771a61658c36603b1d659f9b17fdda..ed700617ea32869ba135dac38a482d66c2f291a7 100644 (file)
                        </div>
 
                        {{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}}
-                               <input type="hidden" id="repolink" value="{{$.RepoRelPath}}">
-                               <input type="hidden" id="repoId" value="{{.Repository.ID}}">
                                <input type="hidden" id="crossRepoSearch" value="{{.AllowCrossRepositoryDependencies}}">
-                               <input type="hidden" id="type" value="{{.IssueType}}">
-                               <!-- I know, there is probably a better way to do this -->
-                               <input type="hidden" id="issueIndex" value="{{.Issue.Index}}"/>
 
                                <div class="ui basic modal remove-dependency">
                                        <div class="ui icon header">
diff --git a/web_src/js/features/issue-content-history.js b/web_src/js/features/issue-content-history.js
new file mode 100644 (file)
index 0000000..6ead067
--- /dev/null
@@ -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);
+    }
+  });
+}
index 868b3ad0493878c3e993950f0fe1598989cdbf2b..b6070158001d70d8871b625653425211d4057f13 100644 (file)
@@ -21,6 +21,7 @@ import {createCodeEditor, createMonaco} from './features/codeeditor.js';
 import {initMarkupAnchors} from './markup/anchors.js';
 import {initNotificationsTable, initNotificationCount} from './features/notification.js';
 import {initLastCommitLoader} from './features/lastcommitloader.js';
+import {initIssueContentHistory} from './features/issue-content-history.js';
 import {initStopwatch} from './features/stopwatch.js';
 import {showLineButton} from './code/linebutton.js';
 import {initMarkupContent, initCommentContent} from './markup/content.js';
@@ -2873,6 +2874,7 @@ $(document).ready(async () => {
   initFileViewToggle();
   initReleaseEditor();
   initRelease();
+  initIssueContentHistory();
 
   const routes = {
     'div.user.settings': initUserSettings,
index 185c23c245498414e734e1efc9119bda26baefc4..11be6b476c711b42fbd0929318a21a0545641f95 100644 (file)
@@ -13,6 +13,7 @@ import octiconProject from '../../public/img/svg/octicon-project.svg';
 import octiconRepo from '../../public/img/svg/octicon-repo.svg';
 import octiconRepoForked from '../../public/img/svg/octicon-repo-forked.svg';
 import octiconRepoTemplate from '../../public/img/svg/octicon-repo-template.svg';
+import octiconTriangleDown from '../../public/img/svg/octicon-triangle-down.svg';
 
 import Vue from 'vue';
 
@@ -32,6 +33,7 @@ export const svgs = {
   'octicon-repo': octiconRepo,
   'octicon-repo-forked': octiconRepoForked,
   'octicon-repo-template': octiconRepoTemplate,
+  'octicon-triangle-down': octiconTriangleDown,
 };
 
 const parser = new DOMParser();