// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations 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" "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) // ConversationContentHistory save conversation/comment content history revisions. type ConversationContentHistory struct { ID int64 `xorm:"pk autoincr"` PosterID int64 ConversationID 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 *ConversationContentHistory) TableName() string { return "conversation_content_history" } func init() { db.RegisterModel(new(ConversationContentHistory)) } // SaveConversationContentHistory save history func SaveConversationContentHistory(ctx context.Context, posterID, conversationID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error { ch := &ConversationContentHistory{ PosterID: posterID, ConversationID: conversationID, CommentID: commentID, ContentText: contentText, EditedUnix: editTime, IsFirstCreated: isFirstCreated, } if err := db.Insert(ctx, ch); err != nil { log.Error("can not save conversation 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(ctx, conversationID, 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(ctx context.Context, conversationID, commentID int64, limit int) { type IDEditTime struct { ID int64 EditedUnix timeutil.TimeStamp } var res []*IDEditTime err := db.GetEngine(ctx).Select("id, edited_unix").Table("conversation_content_history"). Where(builder.Eq{"conversation_id": conversationID, "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) <= 2 { return } outDatedCount := len(res) - limit for outDatedCount > 0 { var indexToDelete int minEditedInterval := -1 // find a history revision with minimal edited interval to delete, the first and the last should never be deleted for i := 1; i < len(res)-1; 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 = db.GetEngine(ctx).Delete(&ConversationContentHistory{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-- } } // QueryConversationContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main conversation) // only return the count map for "edited" (history revision count > 1) conversations or comments. func QueryConversationContentHistoryEditedCountMap(dbCtx context.Context, conversationID 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("conversation_content_history"). Where(builder.Eq{"conversation_id": conversationID}). GroupBy("comment_id"). Having("count(1) > 1"). Find(&records) if err != nil { log.Error("can not query conversation 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 } // ConversationContentListItem the list for web ui type ConversationContentListItem struct { UserID int64 UserName string UserFullName string UserAvatarLink string HistoryID int64 EditedUnix timeutil.TimeStamp IsFirstCreated bool IsDeleted bool } // FetchConversationContentHistoryList fetch list func FetchConversationContentHistoryList(dbCtx context.Context, conversationID, commentID int64) ([]*ConversationContentListItem, error) { res := make([]*ConversationContentListItem, 0) err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name, u.full_name as user_full_name,"+ "h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted"). Table([]string{"conversation_content_history", "h"}). Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id"). Where(builder.Eq{"conversation_id": conversationID, "comment_id": commentID}). OrderBy("edited_unix DESC"). Find(&res) if err != nil { log.Error("can not fetch conversation content history list. err=%v", err) return nil, err } for _, item := range res { if item.UserID > 0 { item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0) } else { item.UserAvatarLink = avatars.DefaultAvatarLink() } } return res, nil } // HasConversationContentHistory check if a ContentHistory entry exists func HasConversationContentHistory(dbCtx context.Context, conversationID, commentID int64) (bool, error) { exists, err := db.GetEngine(dbCtx).Where(builder.Eq{"conversation_id": conversationID, "comment_id": commentID}).Exist(&ConversationContentHistory{}) if err != nil { return false, fmt.Errorf("can not check conversation content history. err: %w", err) } return exists, err } // SoftDeleteConversationContentHistory soft delete func SoftDeleteConversationContentHistory(dbCtx context.Context, historyID int64) error { if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ConversationContentHistory{ IsDeleted: true, ContentText: "", }); err != nil { log.Error("failed to soft delete conversation content history. err=%v", err) return err } return nil } // ErrConversationContentHistoryNotExist not exist error type ErrConversationContentHistoryNotExist struct { ID int64 } // Error error string func (err ErrConversationContentHistoryNotExist) Error() string { return fmt.Sprintf("conversation content history does not exist [id: %d]", err.ID) } func (err ErrConversationContentHistoryNotExist) Unwrap() error { return util.ErrNotExist } // GetConversationContentHistoryByID get conversation content history func GetConversationContentHistoryByID(dbCtx context.Context, id int64) (*ConversationContentHistory, error) { h := &ConversationContentHistory{} has, err := db.GetEngine(dbCtx).ID(id).Get(h) if err != nil { return nil, err } else if !has { return nil, ErrConversationContentHistoryNotExist{id} } return h, nil } // GetConversationContentHistoryAndPrev get a history and the previous non-deleted history (to compare) func GetConversationContentHistoryAndPrev(dbCtx context.Context, conversationID, id int64) (history, prevHistory *ConversationContentHistory, err error) { history = &ConversationContentHistory{} has, err := db.GetEngine(dbCtx).Where("id=? AND conversation_id=?", id, conversationID).Get(history) if err != nil { log.Error("failed to get conversation content history %v. err=%v", id, err) return nil, nil, err } else if !has { log.Error("conversation content history does not exist. id=%v. err=%v", id, err) return nil, nil, &ErrConversationContentHistoryNotExist{id} } prevHistory = &ConversationContentHistory{} has, err = db.GetEngine(dbCtx).Where(builder.Eq{"conversation_id": history.ConversationID, "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 conversation content history %v. err=%v", id, err) return nil, nil, err } else if !has { return history, nil, nil } return history, prevHistory, nil }