mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00: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,9 +54,11 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) { | |||||||
| 		opts.Dir = fixturesDir | 		opts.Dir = fixturesDir | ||||||
| 	} else { | 	} else { | ||||||
| 		for _, f := range fixtureFiles { | 		for _, f := range fixtureFiles { | ||||||
|  | 			if len(f) != 0 { | ||||||
| 				opts.Files = append(opts.Files, filepath.Join(fixturesDir, f)) | 				opts.Files = append(opts.Files, filepath.Join(fixturesDir, f)) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	if err = CreateTestEngine(opts); err != nil { | 	if err = CreateTestEngine(opts); err != nil { | ||||||
| 		fatalTestError("Error creating test engine: %v\n", err) | 		fatalTestError("Error creating test engine: %v\n", err) | ||||||
|   | |||||||
| @@ -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
	 wxiaoguang
					wxiaoguang