feat(API): media field type
This commit is contained in:
1 parent
ad5faf3d19
commit
a2d596f4ca
63 files changed
+2567
-259
No files matched your search
@@ -25,6 +25,7 @@ const (
|
||||
TypeBoolean FieldType = "boolean"
|
||||
TypeDate FieldType = "date"
|
||||
TypeSelect FieldType = "select"
|
||||
TypeMedia FieldType = "media"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -20,6 +20,36 @@ var kinds = map[FieldType]kind{
|
||||
TypeBoolean: {accepts: isBoolean},
|
||||
TypeDate: {accepts: isDate, measure: dateDays, bound: dateDays, unique: true},
|
||||
TypeSelect: {accepts: isString, unique: true},
|
||||
TypeMedia: {accepts: isMediaID},
|
||||
}
|
||||
|
||||
func MediaID(value any) (uint32, bool) {
|
||||
switch typed := value.(type) {
|
||||
case uint32:
|
||||
return typed, typed >= 1
|
||||
case int:
|
||||
return mediaIDFromInt(int64(typed))
|
||||
case int64:
|
||||
return mediaIDFromInt(typed)
|
||||
case float64:
|
||||
if typed != math.Trunc(typed) || typed < 1 || typed > math.MaxUint32 {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(typed), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func mediaIDFromInt(number int64) (uint32, bool) {
|
||||
if number < 1 || number > math.MaxUint32 {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(number), true
|
||||
}
|
||||
|
||||
func isMediaID(value any) bool {
|
||||
_, ok := MediaID(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func isString(value any) bool {
|
||||
|
||||
@@ -109,5 +109,18 @@ func (f Field) validDefault() bool {
|
||||
if f.Default == nil {
|
||||
return true
|
||||
}
|
||||
if f.Type == TypeMedia {
|
||||
return false
|
||||
}
|
||||
return !f.Unique && f.Violation(f.Default) == ""
|
||||
}
|
||||
|
||||
func MediaFields(fields []Field) []Field {
|
||||
var media []Field
|
||||
for _, field := range fields {
|
||||
if field.Type == TypeMedia {
|
||||
media = append(media, field)
|
||||
}
|
||||
}
|
||||
return media
|
||||
}
|
||||
@@ -88,3 +88,13 @@ func UniqueValues(fields []contenttype.Field, data map[string]any) []UniqueValue
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func MediaIDs(fields []contenttype.Field, data map[string]any) map[string]uint32 {
|
||||
ids := map[string]uint32{}
|
||||
for _, field := range contenttype.MediaFields(fields) {
|
||||
if id, ok := contenttype.MediaID(data[field.Key]); ok {
|
||||
ids[field.Key] = id
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -59,6 +59,10 @@ func invalidField(key string, rule string) error {
|
||||
return FieldError{Key: key, Rule: rule, Cause: ErrInvalidEntry}
|
||||
}
|
||||
|
||||
func InvalidField(key string, rule string) error {
|
||||
return invalidField(key, rule)
|
||||
}
|
||||
|
||||
func UniqueViolation(key string) error {
|
||||
return FieldError{Key: key, Rule: RuleUnique, Cause: ErrUniqueViolation}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreateEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
images image.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -18,8 +20,8 @@ type CreateEntryCommand struct {
|
||||
UserID uint32
|
||||
}
|
||||
|
||||
func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *CreateEntryUseCase {
|
||||
return &CreateEntryUseCase{contentTypes, entries, now}
|
||||
func NewCreateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository, now func() time.Time) *CreateEntryUseCase {
|
||||
return &CreateEntryUseCase{contentTypes, entries, images, now}
|
||||
}
|
||||
|
||||
func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, error) {
|
||||
@@ -31,6 +33,9 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
|
||||
if err := entry.Validate(owner.Fields, data); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := ensureMediaExists(u.images, owner, data, nil); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := ensureUnique(u.entries, owner, data, 0); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
@@ -46,5 +51,5 @@ func (u *CreateEntryUseCase) Create(command CreateEntryCommand) (TypedEntry, err
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return typedEntry(owner, stored), nil
|
||||
return typedEntry(owner, stored, newMediaResolver(u.images))
|
||||
}
|
||||
@@ -3,15 +3,17 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
)
|
||||
|
||||
type GetEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
images image.Repository
|
||||
}
|
||||
|
||||
func NewGetEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository) *GetEntryUseCase {
|
||||
return &GetEntryUseCase{contentTypes, entries}
|
||||
func NewGetEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository) *GetEntryUseCase {
|
||||
return &GetEntryUseCase{contentTypes, entries, images}
|
||||
}
|
||||
|
||||
func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, error) {
|
||||
@@ -23,5 +25,5 @@ func (u *GetEntryUseCase) Get(contentTypeKey string, id uint32) (TypedEntry, err
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return typedEntry(owner, found), nil
|
||||
return typedEntry(owner, found, newMediaResolver(u.images))
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"slices"
|
||||
@@ -13,6 +15,7 @@ const (
|
||||
UsageTypeArticle = "article"
|
||||
UsageTypeAuthor = "author"
|
||||
UsageTypeSite = "site"
|
||||
UsageTypeEntry = "entry"
|
||||
|
||||
UsageFieldBody = "body"
|
||||
UsageFieldCover = "cover"
|
||||
@@ -27,6 +30,16 @@ type MediaUsage struct {
|
||||
Title string
|
||||
Published *bool
|
||||
Fields []string
|
||||
|
||||
ContentTypeKey string
|
||||
ContentTypeName string
|
||||
FirstField *UsageFirstField
|
||||
}
|
||||
|
||||
type UsageFirstField struct {
|
||||
Key string
|
||||
Type contenttype.FieldType
|
||||
Value any
|
||||
}
|
||||
|
||||
type GetMediaUsageUseCase struct {
|
||||
@@ -34,10 +47,11 @@ type GetMediaUsageUseCase struct {
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
entries *entryMediaScanner
|
||||
}
|
||||
|
||||
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *GetMediaUsageUseCase {
|
||||
return &GetMediaUsageUseCase{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}
|
||||
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository, contentTypes contenttype.Repository, entries entry.Repository) *GetMediaUsageUseCase {
|
||||
return &GetMediaUsageUseCase{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors, entries: newEntryMediaScanner(contentTypes, entries)}
|
||||
}
|
||||
|
||||
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]MediaUsage, error) {
|
||||
@@ -57,7 +71,32 @@ func (u *GetMediaUsageUseCase) Usage(id uint32) ([]MediaUsage, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.Concat(usages, authorUsages, siteUsages), nil
|
||||
entryUsages, err := u.entryUsages(img)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return slices.Concat(usages, authorUsages, siteUsages, entryUsages), nil
|
||||
}
|
||||
|
||||
func (u *GetMediaUsageUseCase) entryUsages(img image.Image) ([]MediaUsage, error) {
|
||||
referencing, err := u.entries.referencing(img.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets := map[uint32]bool{img.ID: true}
|
||||
usages := make([]MediaUsage, 0, len(referencing))
|
||||
for _, found := range referencing {
|
||||
first := found.owner.Fields[0]
|
||||
usages = append(usages, MediaUsage{
|
||||
Type: UsageTypeEntry,
|
||||
ID: found.record.ID,
|
||||
Fields: found.keysPointingAt(targets),
|
||||
ContentTypeKey: found.owner.Key,
|
||||
ContentTypeName: found.owner.Name,
|
||||
FirstField: &UsageFirstField{Key: first.Key, Type: first.Type, Value: found.record.Data[first.Key]},
|
||||
})
|
||||
}
|
||||
return usages, nil
|
||||
}
|
||||
|
||||
func (u *GetMediaUsageUseCase) articleUsages(img image.Image) ([]MediaUsage, error) {
|
||||
|
||||
@@ -3,11 +3,13 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
)
|
||||
|
||||
type ListEntriesUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
images image.Repository
|
||||
}
|
||||
|
||||
type ListEntriesCommand struct {
|
||||
@@ -24,8 +26,8 @@ type EntryPage struct {
|
||||
Size int
|
||||
}
|
||||
|
||||
func NewListEntriesUseCase(contentTypes contenttype.Repository, entries entry.Repository) *ListEntriesUseCase {
|
||||
return &ListEntriesUseCase{contentTypes, entries}
|
||||
func NewListEntriesUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository) *ListEntriesUseCase {
|
||||
return &ListEntriesUseCase{contentTypes, entries, images}
|
||||
}
|
||||
|
||||
func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, error) {
|
||||
@@ -44,8 +46,13 @@ func (u *ListEntriesUseCase) ListPage(command ListEntriesCommand) (EntryPage, er
|
||||
return EntryPage{}, err
|
||||
}
|
||||
items := make([]TypedEntry, 0, len(found.Items))
|
||||
resolver := newMediaResolver(u.images)
|
||||
for _, record := range found.Items {
|
||||
items = append(items, typedEntry(owner, record))
|
||||
typed, err := typedEntry(owner, record, resolver)
|
||||
if err != nil {
|
||||
return EntryPage{}, err
|
||||
}
|
||||
items = append(items, typed)
|
||||
}
|
||||
return EntryPage{Items: items, Total: found.Total, Page: command.Page, Size: command.Size}, nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/user"
|
||||
@@ -41,10 +43,11 @@ type ListMediaUseCase struct {
|
||||
variantFiles image.VariantFileRepository
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
entries *entryMediaScanner
|
||||
}
|
||||
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository, authors author.Repository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites, authors: authors}
|
||||
func NewListMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, userRepository user.Repository, variantFiles image.VariantFileRepository, sites site.Repository, authors author.Repository, contentTypes contenttype.Repository, entries entry.Repository) *ListMediaUseCase {
|
||||
return &ListMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, userRepository: userRepository, variantFiles: variantFiles, sites: sites, authors: authors, entries: newEntryMediaScanner(contentTypes, entries)}
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
@@ -118,7 +121,7 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
siteUsage, err := imageUsageCount(l.sites, l.authors)
|
||||
siteUsage, err := imageUsageCount(l.sites, l.authors, l.entries)
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
@@ -169,7 +172,7 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
siteUsage, err := imageUsageCount(l.sites, l.authors)
|
||||
siteUsage, err := imageUsageCount(l.sites, l.authors, l.entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"cmp"
|
||||
@@ -19,6 +21,7 @@ type MergeMediaResult struct {
|
||||
Merged int
|
||||
SitesUpdated int
|
||||
AuthorsUpdated int
|
||||
EntriesUpdated int
|
||||
UpdatedArticleIDs []uint32
|
||||
}
|
||||
|
||||
@@ -29,10 +32,11 @@ type MergeMediaUseCase struct {
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
entries *entryMediaScanner
|
||||
}
|
||||
|
||||
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 NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository, contentTypes contenttype.Repository, entries entry.Repository) *MergeMediaUseCase {
|
||||
return &MergeMediaUseCase{imageRepository: imageRepository, articleRepository: articleRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors, entries: newEntryMediaScanner(contentTypes, entries)}
|
||||
}
|
||||
|
||||
func copyIdsOf(copies []image.Image) []uint32 {
|
||||
@@ -118,6 +122,12 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
return MergeMediaResult{}, err
|
||||
}
|
||||
|
||||
repointedEntries, err := u.entries.repoint(copyIdsOf(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
|
||||
@@ -125,7 +135,7 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
|
||||
}
|
||||
|
||||
updatedIds := updatedArticleIds(rewrites, repointedArticles)
|
||||
return MergeMediaResult{ArticlesUpdated: len(updatedIds), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: updatedIds}, nil
|
||||
return MergeMediaResult{ArticlesUpdated: len(updatedIds), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, EntriesUpdated: len(repointedEntries), UpdatedArticleIDs: updatedIds}, nil
|
||||
}
|
||||
|
||||
func updatedArticleIds(rewrites []bodyRewrite, repointed []uint32) []uint32 {
|
||||
|
||||
@@ -3,6 +3,8 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
@@ -30,8 +32,8 @@ type PreviewPurgeUseCase struct {
|
||||
planner *purgePlanner
|
||||
}
|
||||
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors}}
|
||||
func NewPreviewPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, sites site.Repository, authors author.Repository, contentTypes contenttype.Repository, entries entry.Repository) *PreviewPurgeUseCase {
|
||||
return &PreviewPurgeUseCase{&purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors, entries: newEntryMediaScanner(contentTypes, entries)}}
|
||||
}
|
||||
|
||||
func (u *PreviewPurgeUseCase) Preview() (PurgePreview, error) {
|
||||
|
||||
@@ -26,7 +26,7 @@ type RunPurgeUseCase struct {
|
||||
|
||||
func NewRunPurgeUseCase(imageRepository image.Repository, articleRepository article.Repository, merge *MergeMediaUseCase, repository purge.Repository, now func() time.Time, logger *log.Logger, recorder audit.Recorder, sites site.Repository, authors author.Repository) *RunPurgeUseCase {
|
||||
return &RunPurgeUseCase{
|
||||
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors},
|
||||
planner: &purgePlanner{imageRepository: imageRepository, articleRepository: articleRepository, sites: sites, authors: authors, entries: merge.entries},
|
||||
merge: merge,
|
||||
repository: repository,
|
||||
now: now,
|
||||
|
||||
@@ -3,12 +3,14 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateEntryUseCase struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
images image.Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@@ -19,8 +21,8 @@ type UpdateEntryCommand struct {
|
||||
UserID uint32
|
||||
}
|
||||
|
||||
func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, now func() time.Time) *UpdateEntryUseCase {
|
||||
return &UpdateEntryUseCase{contentTypes, entries, now}
|
||||
func NewUpdateEntryUseCase(contentTypes contenttype.Repository, entries entry.Repository, images image.Repository, now func() time.Time) *UpdateEntryUseCase {
|
||||
return &UpdateEntryUseCase{contentTypes, entries, images, now}
|
||||
}
|
||||
|
||||
func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, error) {
|
||||
@@ -35,6 +37,9 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
|
||||
if err := entry.Validate(owner.Fields, command.Data); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := ensureMediaExists(u.images, owner, command.Data, current.Data); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
if err := ensureUnique(u.entries, owner, command.Data, current.ID); err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
@@ -45,5 +50,5 @@ func (u *UpdateEntryUseCase) Update(command UpdateEntryCommand) (TypedEntry, err
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return typedEntry(owner, stored), nil
|
||||
return typedEntry(owner, stored, newMediaResolver(u.images))
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"cmp"
|
||||
"errors"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type EntryMedia struct {
|
||||
ID uint32
|
||||
Missing bool
|
||||
Filename string
|
||||
Width *int
|
||||
Height *int
|
||||
Size int64
|
||||
ThumbnailURL string
|
||||
}
|
||||
|
||||
type mediaResolver struct {
|
||||
images image.Repository
|
||||
cache map[uint32]EntryMedia
|
||||
}
|
||||
|
||||
func newMediaResolver(images image.Repository) *mediaResolver {
|
||||
return &mediaResolver{images: images, cache: map[uint32]EntryMedia{}}
|
||||
}
|
||||
|
||||
func (r *mediaResolver) resolve(owner contenttype.ContentType, record entry.Entry) (map[string]EntryMedia, error) {
|
||||
resolved := map[string]EntryMedia{}
|
||||
for _, field := range contenttype.MediaFields(owner.Fields) {
|
||||
id, set := contenttype.MediaID(record.Data[field.Key])
|
||||
if !set {
|
||||
continue
|
||||
}
|
||||
media, err := r.lookup(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved[field.Key] = media
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (r *mediaResolver) lookup(id uint32) (EntryMedia, error) {
|
||||
if cached, found := r.cache[id]; found {
|
||||
return cached, nil
|
||||
}
|
||||
found, err := r.images.Get(id)
|
||||
if errors.Is(err, image.ErrImageNotFound) {
|
||||
r.cache[id] = EntryMedia{ID: id, Missing: true}
|
||||
return r.cache[id], nil
|
||||
}
|
||||
if err != nil {
|
||||
return EntryMedia{}, err
|
||||
}
|
||||
media := EntryMedia{ID: found.ID, Filename: found.Filename, Width: knownDimension(found.Width), Height: knownDimension(found.Height), Size: found.Size, ThumbnailURL: found.Path}
|
||||
r.cache[id] = media
|
||||
return media, nil
|
||||
}
|
||||
|
||||
func knownDimension(value int) *int {
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
type mediaEntry struct {
|
||||
owner contenttype.ContentType
|
||||
record entry.Entry
|
||||
}
|
||||
|
||||
type entryMediaScanner struct {
|
||||
contentTypes contenttype.Repository
|
||||
entries entry.Repository
|
||||
}
|
||||
|
||||
func newEntryMediaScanner(contentTypes contenttype.Repository, entries entry.Repository) *entryMediaScanner {
|
||||
return &entryMediaScanner{contentTypes: contentTypes, entries: entries}
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) scan() ([]mediaEntry, error) {
|
||||
types, err := s.contentTypes.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var found []mediaEntry
|
||||
for _, owner := range types {
|
||||
if len(contenttype.MediaFields(owner.Fields)) == 0 {
|
||||
continue
|
||||
}
|
||||
page, err := s.entries.ListPage(entry.Filters{ContentTypeID: owner.ID, Unpaged: true, Sort: entry.DefaultSort()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, record := range page.Items {
|
||||
found = append(found, mediaEntry{owner: owner, record: record})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(found, func(a, b mediaEntry) int {
|
||||
return cmp.Or(cmp.Compare(a.owner.Name, b.owner.Name), cmp.Compare(a.owner.ID, b.owner.ID), cmp.Compare(a.record.ID, b.record.ID))
|
||||
})
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m mediaEntry) keysPointingAt(ids map[uint32]bool) []string {
|
||||
keys := []string{}
|
||||
for _, field := range contenttype.MediaFields(m.owner.Fields) {
|
||||
if id, set := contenttype.MediaID(m.record.Data[field.Key]); set && ids[id] {
|
||||
keys = append(keys, field.Key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) usageCounts() (map[uint32]int, error) {
|
||||
found, err := s.scan()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := map[uint32]int{}
|
||||
for _, candidate := range found {
|
||||
distinct := map[uint32]bool{}
|
||||
for _, id := range entry.MediaIDs(candidate.owner.Fields, candidate.record.Data) {
|
||||
distinct[id] = true
|
||||
}
|
||||
for id := range distinct {
|
||||
counts[id]++
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) referencing(imageID uint32) ([]mediaEntry, error) {
|
||||
found, err := s.scan()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets := map[uint32]bool{imageID: true}
|
||||
return slices.DeleteFunc(found, func(candidate mediaEntry) bool {
|
||||
return len(candidate.keysPointingAt(targets)) == 0
|
||||
}), nil
|
||||
}
|
||||
|
||||
type entryRepoint struct {
|
||||
original entry.Entry
|
||||
updated entry.Entry
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) repoint(copyIDs []uint32, keptID uint32) ([]entryRepoint, error) {
|
||||
found, err := s.scan()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copies := map[uint32]bool{}
|
||||
for _, id := range copyIDs {
|
||||
copies[id] = true
|
||||
}
|
||||
applied := []entryRepoint{}
|
||||
for _, candidate := range found {
|
||||
keys := candidate.keysPointingAt(copies)
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
change := entryRepoint{original: candidate.record, updated: candidate.record}
|
||||
change.updated.Data = make(map[string]any, len(candidate.record.Data))
|
||||
for key, value := range candidate.record.Data {
|
||||
change.updated.Data[key] = value
|
||||
}
|
||||
for _, key := range keys {
|
||||
change.updated.Data[key] = float64(keptID)
|
||||
}
|
||||
if _, err := s.entries.Update(change.updated); err != nil {
|
||||
s.restore(applied)
|
||||
return nil, err
|
||||
}
|
||||
applied = append(applied, change)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func (s *entryMediaScanner) restore(applied []entryRepoint) {
|
||||
for _, change := range applied {
|
||||
_, err := s.entries.Update(change.original)
|
||||
logCompensation("restore entry media", err)
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,57 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/contenttype"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type TypedEntry struct {
|
||||
entry.Entry
|
||||
ContentTypeKey string
|
||||
Media map[string]EntryMedia
|
||||
}
|
||||
|
||||
func typedEntry(owner contenttype.ContentType, record entry.Entry) TypedEntry {
|
||||
func typedEntry(owner contenttype.ContentType, record entry.Entry, resolver *mediaResolver) (TypedEntry, error) {
|
||||
record.Data = entry.Project(owner.Fields, record.Data)
|
||||
return TypedEntry{Entry: record, ContentTypeKey: owner.Key}
|
||||
media, err := resolver.resolve(owner, record)
|
||||
if err != nil {
|
||||
return TypedEntry{}, err
|
||||
}
|
||||
return TypedEntry{Entry: record, ContentTypeKey: owner.Key, Media: media}, nil
|
||||
}
|
||||
|
||||
func ensureMediaExists(images image.Repository, owner contenttype.ContentType, data map[string]any, stored map[string]any) error {
|
||||
storedIDs := entry.MediaIDs(owner.Fields, stored)
|
||||
for _, field := range contenttype.MediaFields(owner.Fields) {
|
||||
id, set := contenttype.MediaID(data[field.Key])
|
||||
if !set {
|
||||
continue
|
||||
}
|
||||
unchanged := stored != nil && storedIDs[field.Key] == id
|
||||
if unchanged && !field.Required {
|
||||
continue
|
||||
}
|
||||
exists, err := imageExists(images, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if unchanged {
|
||||
return entry.InvalidField(field.Key, entry.RuleRequired)
|
||||
}
|
||||
return entry.InvalidField(field.Key, contenttype.RuleType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageExists(images image.Repository, id uint32) (bool, error) {
|
||||
_, err := images.Get(id)
|
||||
if errors.Is(err, image.ErrImageNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func ensureUnique(entries entry.Repository, owner contenttype.ContentType, data map[string]any, excludeID uint32) error {
|
||||
|
||||
@@ -30,6 +30,7 @@ type purgePlanner struct {
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
entries *entryMediaScanner
|
||||
}
|
||||
|
||||
func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
@@ -42,7 +43,7 @@ func (p *purgePlanner) plan() (purgePlan, error) {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
siteUsage, err := imageUsageCount(p.sites, p.authors)
|
||||
siteUsage, err := imageUsageCount(p.sites, p.authors, p.entries)
|
||||
if err != nil {
|
||||
return purgePlan{}, err
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func siteImageUsage(sites site.Repository) (map[uint32]int, error) {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func imageUsageCount(sites site.Repository, authors author.Repository) (map[uint32]int, error) {
|
||||
func imageUsageCount(sites site.Repository, authors author.Repository, entries *entryMediaScanner) (map[uint32]int, error) {
|
||||
usage, err := siteImageUsage(sites)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -31,5 +31,12 @@ func imageUsageCount(sites site.Repository, authors author.Repository) (map[uint
|
||||
for imageID, total := range avatars {
|
||||
usage[imageID] += total
|
||||
}
|
||||
entryCounts, err := entries.usageCounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for imageID, total := range entryCounts {
|
||||
usage[imageID] += total
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
Reference in new issue
Block a user