mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-11-03 20:36:07 +01:00 
			
		
		
		
	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:
		@@ -54,7 +54,9 @@ 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 {
 | 
				
			||||||
			opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
 | 
								if len(f) != 0 {
 | 
				
			||||||
 | 
									opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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 {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										230
									
								
								models/issues/content_history.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										230
									
								
								models/issues/content_history.go
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										74
									
								
								models/issues/content_history_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								models/issues/content_history_test.go
									
									
									
									
									
										Normal 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)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										16
									
								
								models/issues/main_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								models/issues/main_test.go
									
									
									
									
									
										Normal 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("..", ".."), "")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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
									
								
							
							
						
						
									
										33
									
								
								models/migrations/v198.go
									
									
									
									
									
										Normal 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()
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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", 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())
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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">
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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">
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										135
									
								
								web_src/js/features/issue-content-history.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								web_src/js/features/issue-content-history.js
									
									
									
									
									
										Normal 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>• ${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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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();
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user