Save and view issue/comment content history (#16909)
* issue content history * Use timeutil.TimeStampNow() for content history time instead of issue/comment.UpdatedUnix (which are not updated in time) * i18n for frontend * refactor * clean up * fix refactor * re-format * temp refactor * follow db refactor * rename IssueContentHistory to ContentHistory, remove empty model tags * fix html * use avatar refactor to generate avatar url * add unit test, keep at most 20 history revisions. * re-format * syntax nit * Add issue content history table * Update models/migrations/v197.go Co-authored-by: 6543 <6543@obermui.de> * fix merge Co-authored-by: zeripath <art27@cantab.net> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: Lauris BH <lauris@nix.lv>
This commit is contained in:
		
							parent
							
								
									ff9a8a2231
								
							
						
					
					
						commit
						c5c88f2f18
					
				| @ -54,9 +54,11 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) { | ||||
| 		opts.Dir = fixturesDir | ||||
| 	} else { | ||||
| 		for _, f := range fixtureFiles { | ||||
| 			if len(f) != 0 { | ||||
| 				opts.Files = append(opts.Files, filepath.Join(fixturesDir, f)) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = CreateTestEngine(opts); err != nil { | ||||
| 		fatalTestError("Error creating test engine: %v\n", err) | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
							
								
								
									
										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), | ||||
| 	// v197 -> v198
 | ||||
| 	NewMigration("Add renamed_branch table", addRenamedBranchTable), | ||||
| 	// v198 -> v199
 | ||||
| 	NewMigration("Add issue content history table", addTableIssueContentHistory), | ||||
| } | ||||
| 
 | ||||
| // 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.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 | ||||
|  | ||||
							
								
								
									
										206
									
								
								routers/web/repo/issue_content_history.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								routers/web/repo/issue_content_history.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,206 @@ | ||||
| // Copyright 2021 The Gitea Authors. All rights reserved.
 | ||||
| // Use of this source code is governed by a MIT-style
 | ||||
| // license that can be found in the LICENSE file.
 | ||||
| 
 | ||||
| package repo | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"html" | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"code.gitea.io/gitea/models" | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| 	issuesModel "code.gitea.io/gitea/models/issues" | ||||
| 	"code.gitea.io/gitea/modules/context" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 
 | ||||
| 	"github.com/sergi/go-diff/diffmatchpatch" | ||||
| 	"github.com/unknwon/i18n" | ||||
| ) | ||||
| 
 | ||||
| // GetContentHistoryOverview get overview
 | ||||
| func GetContentHistoryOverview(ctx *context.Context) { | ||||
| 	issue := GetActionIssue(ctx) | ||||
| 	if issue == nil { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	lang := ctx.Data["Lang"].(string) | ||||
| 	editedHistoryCountMap, _ := issuesModel.QueryIssueContentHistoryEditedCountMap(db.DefaultContext, issue.ID) | ||||
| 	ctx.JSON(http.StatusOK, map[string]interface{}{ | ||||
| 		"i18n": map[string]interface{}{ | ||||
| 			"textEdited":                   i18n.Tr(lang, "repo.issues.content_history.edited"), | ||||
| 			"textDeleteFromHistory":        i18n.Tr(lang, "repo.issues.content_history.delete_from_history"), | ||||
| 			"textDeleteFromHistoryConfirm": i18n.Tr(lang, "repo.issues.content_history.delete_from_history_confirm"), | ||||
| 			"textOptions":                  i18n.Tr(lang, "repo.issues.content_history.options"), | ||||
| 		}, | ||||
| 		"editedHistoryCountMap": editedHistoryCountMap, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| // GetContentHistoryList  get list
 | ||||
| func GetContentHistoryList(ctx *context.Context) { | ||||
| 	issue := GetActionIssue(ctx) | ||||
| 	commentID := ctx.FormInt64("comment_id") | ||||
| 	if issue == nil { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	items, _ := issuesModel.FetchIssueContentHistoryList(db.DefaultContext, issue.ID, commentID) | ||||
| 
 | ||||
| 	// render history list to HTML for frontend dropdown items: (name, value)
 | ||||
| 	// name is HTML of "avatar + userName + userAction + timeSince"
 | ||||
| 	// value is historyId
 | ||||
| 	lang := ctx.Data["Lang"].(string) | ||||
| 	var results []map[string]interface{} | ||||
| 	for _, item := range items { | ||||
| 		var actionText string | ||||
| 		if item.IsDeleted { | ||||
| 			actionTextDeleted := i18n.Tr(lang, "repo.issues.content_history.deleted") | ||||
| 			actionText = "<i data-history-is-deleted='1'>" + actionTextDeleted + "</i>" | ||||
| 		} else if item.IsFirstCreated { | ||||
| 			actionText = i18n.Tr(lang, "repo.issues.content_history.created") | ||||
| 		} else { | ||||
| 			actionText = i18n.Tr(lang, "repo.issues.content_history.edited") | ||||
| 		} | ||||
| 		timeSinceText := timeutil.TimeSinceUnix(item.EditedUnix, lang) | ||||
| 		results = append(results, map[string]interface{}{ | ||||
| 			"name": fmt.Sprintf("<img class='ui avatar image' src='%s'><strong>%s</strong> %s %s", | ||||
| 				html.EscapeString(item.UserAvatarLink), html.EscapeString(item.UserName), actionText, timeSinceText), | ||||
| 			"value": item.HistoryID, | ||||
| 		}) | ||||
| 	} | ||||
| 
 | ||||
| 	ctx.JSON(http.StatusOK, map[string]interface{}{ | ||||
| 		"results": results, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| // canSoftDeleteContentHistory checks whether current user can soft-delete a history revision
 | ||||
| // Admins or owners can always delete history revisions. Normal users can only delete own history revisions.
 | ||||
| func canSoftDeleteContentHistory(ctx *context.Context, issue *models.Issue, comment *models.Comment, | ||||
| 	history *issuesModel.ContentHistory) bool { | ||||
| 
 | ||||
| 	canSoftDelete := false | ||||
| 	if ctx.Repo.IsOwner() { | ||||
| 		canSoftDelete = true | ||||
| 	} else if ctx.Repo.CanWrite(models.UnitTypeIssues) { | ||||
| 		canSoftDelete = ctx.User.ID == history.PosterID | ||||
| 		if comment == nil { | ||||
| 			canSoftDelete = canSoftDelete && (ctx.User.ID == issue.PosterID) | ||||
| 			canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) | ||||
| 		} else { | ||||
| 			canSoftDelete = canSoftDelete && (ctx.User.ID == comment.PosterID) | ||||
| 			canSoftDelete = canSoftDelete && (history.IssueID == issue.ID) | ||||
| 			canSoftDelete = canSoftDelete && (history.CommentID == comment.ID) | ||||
| 		} | ||||
| 	} | ||||
| 	return canSoftDelete | ||||
| } | ||||
| 
 | ||||
| //GetContentHistoryDetail get detail
 | ||||
| func GetContentHistoryDetail(ctx *context.Context) { | ||||
| 	issue := GetActionIssue(ctx) | ||||
| 	if issue == nil { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	historyID := ctx.FormInt64("history_id") | ||||
| 	history, prevHistory, err := issuesModel.GetIssueContentHistoryAndPrev(db.DefaultContext, historyID) | ||||
| 	if err != nil { | ||||
| 		ctx.JSON(http.StatusNotFound, map[string]interface{}{ | ||||
| 			"message": "Can not find the content history", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// get the related comment if this history revision is for a comment, otherwise the history revision is for an issue.
 | ||||
| 	var comment *models.Comment | ||||
| 	if history.CommentID != 0 { | ||||
| 		var err error | ||||
| 		if comment, err = models.GetCommentByID(history.CommentID); err != nil { | ||||
| 			log.Error("can not get comment for issue content history %v. err=%v", historyID, err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// get the previous history revision (if exists)
 | ||||
| 	var prevHistoryID int64 | ||||
| 	var prevHistoryContentText string | ||||
| 	if prevHistory != nil { | ||||
| 		prevHistoryID = prevHistory.ID | ||||
| 		prevHistoryContentText = prevHistory.ContentText | ||||
| 	} | ||||
| 
 | ||||
| 	// compare the current history revision with the previous one
 | ||||
| 	dmp := diffmatchpatch.New() | ||||
| 	diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, true) | ||||
| 	diff = dmp.DiffCleanupEfficiency(diff) | ||||
| 
 | ||||
| 	// use chroma to render the diff html
 | ||||
| 	diffHTMLBuf := bytes.Buffer{} | ||||
| 	diffHTMLBuf.WriteString("<pre class='chroma' style='tab-size: 4'>") | ||||
| 	for _, it := range diff { | ||||
| 		if it.Type == diffmatchpatch.DiffInsert { | ||||
| 			diffHTMLBuf.WriteString("<span class='gi'>") | ||||
| 			diffHTMLBuf.WriteString(html.EscapeString(it.Text)) | ||||
| 			diffHTMLBuf.WriteString("</span>") | ||||
| 		} else if it.Type == diffmatchpatch.DiffDelete { | ||||
| 			diffHTMLBuf.WriteString("<span class='gd'>") | ||||
| 			diffHTMLBuf.WriteString(html.EscapeString(it.Text)) | ||||
| 			diffHTMLBuf.WriteString("</span>") | ||||
| 		} else { | ||||
| 			diffHTMLBuf.WriteString(html.EscapeString(it.Text)) | ||||
| 		} | ||||
| 	} | ||||
| 	diffHTMLBuf.WriteString("</pre>") | ||||
| 
 | ||||
| 	ctx.JSON(http.StatusOK, map[string]interface{}{ | ||||
| 		"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history), | ||||
| 		"historyId":     historyID, | ||||
| 		"prevHistoryId": prevHistoryID, | ||||
| 		"diffHtml":      diffHTMLBuf.String(), | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| //SoftDeleteContentHistory soft delete
 | ||||
| func SoftDeleteContentHistory(ctx *context.Context) { | ||||
| 	issue := GetActionIssue(ctx) | ||||
| 	if issue == nil { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	commentID := ctx.FormInt64("comment_id") | ||||
| 	historyID := ctx.FormInt64("history_id") | ||||
| 
 | ||||
| 	var comment *models.Comment | ||||
| 	var history *issuesModel.ContentHistory | ||||
| 	var err error | ||||
| 	if commentID != 0 { | ||||
| 		if comment, err = models.GetCommentByID(commentID); err != nil { | ||||
| 			log.Error("can not get comment for issue content history %v. err=%v", historyID, err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
| 	if history, err = issuesModel.GetIssueContentHistoryByID(db.DefaultContext, historyID); err != nil { | ||||
| 		log.Error("can not get issue content history %v. err=%v", historyID, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history) | ||||
| 	if !canSoftDelete { | ||||
| 		ctx.JSON(http.StatusForbidden, map[string]interface{}{ | ||||
| 			"message": "Can not delete the content history", | ||||
| 		}) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	err = issuesModel.SoftDeleteIssueContentHistory(db.DefaultContext, historyID) | ||||
| 	log.Debug("soft delete issue content history. issue=%d, comment=%d, history=%d", issue.ID, commentID, historyID) | ||||
| 	ctx.JSON(http.StatusOK, map[string]interface{}{ | ||||
| 		"ok": err == nil, | ||||
| 	}) | ||||
| } | ||||
| @ -732,6 +732,9 @@ func RegisterRoutes(m *web.Route) { | ||||
| 				m.Get("/attachments", repo.GetIssueAttachments) | ||||
| 				m.Get("/attachments/{uuid}", repo.GetAttachment) | ||||
| 			}) | ||||
| 			m.Group("/{index}", func() { | ||||
| 				m.Post("/content-history/soft-delete", repo.SoftDeleteContentHistory) | ||||
| 			}) | ||||
| 
 | ||||
| 			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) | ||||
| 			m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) | ||||
| @ -853,6 +856,11 @@ func RegisterRoutes(m *web.Route) { | ||||
| 		m.Group("", func() { | ||||
| 			m.Get("/{type:issues|pulls}", repo.Issues) | ||||
| 			m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue) | ||||
| 			m.Group("/{type:issues|pulls}/{index}/content-history", func() { | ||||
| 				m.Get("/overview", repo.GetContentHistoryOverview) | ||||
| 				m.Get("/list", repo.GetContentHistoryList) | ||||
| 				m.Get("/detail", repo.GetContentHistoryDetail) | ||||
| 			}) | ||||
| 			m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels) | ||||
| 			m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones) | ||||
| 		}, context.RepoRef()) | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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"> | ||||
|  | ||||
| @ -535,12 +535,7 @@ | ||||
| 			</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"> | ||||
|  | ||||
							
								
								
									
										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 {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, | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user