feat: media duplicate purge, part 1 of 3: persisted purge settings and the purge API
This commit is contained in:
1 parent
161ddc0f7d
commit
e82d0ce254
25 files changed
+2459
-41
No files matched your search
@@ -0,0 +1,162 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type purgeGroup struct {
|
||||
kept image.Image
|
||||
keptUsage int
|
||||
mergedIds []uint32
|
||||
}
|
||||
|
||||
type purgePlan struct {
|
||||
groups []purgeGroup
|
||||
copies int
|
||||
articles int
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type purgePlanner struct {
|
||||
imageRepository image.Repository
|
||||
articleRepository article.Repository
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
duplicates, err := p.imageRepository.ListDuplicates()
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
articles, err := p.articleRepository.FindByFilters(article.Filters{})
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
groups := []purgeGroup{}
|
||||
copies := 0
|
||||
touched := map[uint32]bool{}
|
||||
for _, members := range groupByHash(duplicates) {
|
||||
group, planned := planGroup(members, articles)
|
||||
if !planned {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, group)
|
||||
copies += len(group.mergedIds)
|
||||
for _, id := range articlesUsingCopies(group, members, articles) {
|
||||
touched[id] = true
|
||||
}
|
||||
}
|
||||
slices.SortFunc(groups, func(a, b purgeGroup) int { return int(a.kept.ID) - int(b.kept.ID) })
|
||||
|
||||
return purgePlan{groups: groups, copies: copies, articles: len(touched), fingerprint: fingerprintOf(groups)}, nil
|
||||
}
|
||||
|
||||
func groupByHash(images []image.Image) [][]image.Image {
|
||||
byHash := map[string][]image.Image{}
|
||||
hashes := []string{}
|
||||
for _, img := range images {
|
||||
if img.SHA256 == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := byHash[img.SHA256]; !seen {
|
||||
hashes = append(hashes, img.SHA256)
|
||||
}
|
||||
byHash[img.SHA256] = append(byHash[img.SHA256], img)
|
||||
}
|
||||
groups := make([][]image.Image, 0, len(hashes))
|
||||
for _, hash := range hashes {
|
||||
groups = append(groups, byHash[hash])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func usageOf(img image.Image, articles []article.Article) int {
|
||||
storedName := image.StoredName(img.Path)
|
||||
used := 0
|
||||
for _, a := range articles {
|
||||
if strings.Contains(a.Body, storedName) {
|
||||
used++
|
||||
}
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func planGroup(members []image.Image, articles []article.Article) (purgeGroup, bool) {
|
||||
usage := make(map[uint32]int, len(members))
|
||||
anyUsed := false
|
||||
for _, member := range members {
|
||||
usage[member.ID] = usageOf(member, articles)
|
||||
anyUsed = anyUsed || usage[member.ID] > 0
|
||||
}
|
||||
if !anyUsed {
|
||||
return purgeGroup{}, false
|
||||
}
|
||||
|
||||
kept := members[0]
|
||||
for _, member := range members[1:] {
|
||||
if isBetterToKeep(member, kept, usage) {
|
||||
kept = member
|
||||
}
|
||||
}
|
||||
|
||||
mergedIds := []uint32{}
|
||||
for _, member := range members {
|
||||
if member.ID != kept.ID {
|
||||
mergedIds = append(mergedIds, member.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(mergedIds)
|
||||
return purgeGroup{kept: kept, keptUsage: usage[kept.ID], mergedIds: mergedIds}, true
|
||||
}
|
||||
|
||||
func isBetterToKeep(candidate, current image.Image, usage map[uint32]int) bool {
|
||||
if usage[candidate.ID] != usage[current.ID] {
|
||||
return usage[candidate.ID] > usage[current.ID]
|
||||
}
|
||||
if !candidate.CreatedAt.Equal(current.CreatedAt) {
|
||||
return candidate.CreatedAt.Before(current.CreatedAt)
|
||||
}
|
||||
return candidate.ID < current.ID
|
||||
}
|
||||
|
||||
func articlesUsingCopies(group purgeGroup, members []image.Image, articles []article.Article) []uint32 {
|
||||
mergedNames := []string{}
|
||||
for _, member := range members {
|
||||
if member.ID != group.kept.ID {
|
||||
mergedNames = append(mergedNames, image.StoredName(member.Path))
|
||||
}
|
||||
}
|
||||
ids := []uint32{}
|
||||
for _, a := range articles {
|
||||
for _, name := range mergedNames {
|
||||
if strings.Contains(a.Body, name) {
|
||||
ids = append(ids, a.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func fingerprintOf(groups []purgeGroup) string {
|
||||
var builder strings.Builder
|
||||
for _, group := range groups {
|
||||
builder.WriteString(strconv.FormatUint(uint64(group.kept.ID), 10))
|
||||
builder.WriteString(":")
|
||||
for position, id := range group.mergedIds {
|
||||
if position > 0 {
|
||||
builder.WriteString(",")
|
||||
}
|
||||
builder.WriteString(strconv.FormatUint(uint64(id), 10))
|
||||
}
|
||||
builder.WriteString(";")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(builder.String()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in new issue
Block a user