package useCases import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/image" "RenewCMS/internal/domain/site" "cmp" "context" "errors" "slices" "strings" ) type MergeMediaResult struct { ArticlesUpdated int Merged int SitesUpdated int AuthorsUpdated int UpdatedArticleIDs []uint32 } type MergeMediaUseCase struct { imageRepository image.Repository articleRepository article.Repository remover mediaRemover recorder audit.Recorder sites site.Repository authors author.Repository } func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *MergeMediaUseCase { return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors} } func copyIdsOf(copies []image.Image) []uint32 { copyIds := make([]uint32, 0, len(copies)) for _, duplicate := range copies { copyIds = append(copyIds, duplicate.ID) } return copyIds } func (u *MergeMediaUseCase) repointSites(copies []image.Image, keptId uint32) (int, error) { return u.sites.RepointImage(copyIdsOf(copies), keptId) } func (u *MergeMediaUseCase) repointAuthors(copies []image.Image, keptId uint32) (int, error) { return u.authors.RepointAvatar(copyIdsOf(copies), keptId) } type bodyRewrite struct { articleId uint32 original string rewritten string } func (u *MergeMediaUseCase) Merge(ctx context.Context, keepId uint32, mergeIds []uint32) (MergeMediaResult, error) { result, err := u.merge(keepId, mergeIds) event := audit.NewEvent(audit.ActionMediaMerge, audit.CategoryMedia) if kept, found := u.keptImage(keepId); found { event.Target = imageTarget(kept) } if err != nil { recordFailure(ctx, u.recorder, event, err) return MergeMediaResult{}, err } event.Params = map[string]any{audit.ParamMerged: result.Merged, audit.ParamArticlesUpdated: result.ArticlesUpdated} u.recorder.Record(ctx, event) return result, nil } func (u *MergeMediaUseCase) keptImage(keepId uint32) (image.Image, bool) { if keepId == 0 { return image.Image{}, false } kept, err := u.imageRepository.Get(keepId) return kept, err == nil } func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaResult, error) { if err := validateMergeRequest(keepId, mergeIds); err != nil { return MergeMediaResult{}, err } kept, err := u.imageRepository.Get(keepId) if err != nil { return MergeMediaResult{}, err } copies, err := u.loadCopies(kept, mergeIds) if err != nil { return MergeMediaResult{}, err } rewrites, err := u.planRewrites(kept, copies) if err != nil { return MergeMediaResult{}, err } if err := u.applyRewrites(rewrites); err != nil { return MergeMediaResult{}, err } sitesUpdated, err := u.repointSites(copies, kept.ID) if err != nil { u.restoreBodies(rewrites) return MergeMediaResult{}, err } authorsUpdated, err := u.repointAuthors(copies, kept.ID) if err != nil { u.restoreBodies(rewrites) return MergeMediaResult{}, err } for _, duplicate := range copies { if err := u.remover.remove(duplicate); err != nil { return MergeMediaResult{}, err } } return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil } func rewrittenIds(rewrites []bodyRewrite) []uint32 { ids := make([]uint32, 0, len(rewrites)) for _, plan := range rewrites { ids = append(ids, plan.articleId) } return ids } func validateMergeRequest(keepId uint32, mergeIds []uint32) error { if keepId == 0 || len(mergeIds) == 0 { return image.ErrInvalidMergeRequest } seen := map[uint32]bool{keepId: true} for _, id := range mergeIds { if id == 0 || seen[id] { return image.ErrInvalidMergeRequest } seen[id] = true } return nil } func (u *MergeMediaUseCase) loadCopies(kept image.Image, mergeIds []uint32) ([]image.Image, error) { copies := make([]image.Image, 0, len(mergeIds)) for _, id := range mergeIds { duplicate, err := u.imageRepository.Get(id) if errors.Is(err, image.ErrImageNotFound) { return nil, image.ErrMergeCopyMissing } if err != nil { return nil, err } if duplicate.SHA256 == "" || duplicate.SHA256 != kept.SHA256 { return nil, image.ErrMergeHashMismatch } copies = append(copies, duplicate) } return copies, nil } func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image) ([]bodyRewrite, error) { keptName := image.StoredName(kept.Path) rewritten := map[uint32]*bodyRewrite{} for _, duplicate := range copies { copyName := image.StoredName(duplicate.Path) articles, err := u.articleRepository.FindByBodyContaining(copyName) if err != nil { return nil, err } for _, a := range articles { plan, ok := rewritten[a.ID] if !ok { plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body} rewritten[a.ID] = plan } plan.rewritten = strings.ReplaceAll(plan.rewritten, copyName, keptName) } } plans := make([]bodyRewrite, 0, len(rewritten)) for _, plan := range rewritten { plans = append(plans, *plan) } slices.SortFunc(plans, func(a, b bodyRewrite) int { return cmp.Compare(a.articleId, b.articleId) }) return plans, nil } func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error { for applied, plan := range rewrites { if _, err := u.articleRepository.UpdateBody(plan.articleId, plan.rewritten); err != nil { u.restoreBodies(rewrites[:applied]) return err } } return nil } func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) { for _, plan := range applied { _, _ = u.articleRepository.UpdateBody(plan.articleId, plan.original) } }