feat: SEO backend data (1/4)

This commit is contained in:
Florian Sylvain committed 2026-10-15 19:30:29 +02:00
1 parent cb1410f0b0
commit 0eb46b0d74
90 files changed
+6094 -350

No files matched your search

@@ -66,12 +66,13 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return article.Article{}, err
}
created, err := g.articleRepository.Create(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
Authors: creditedAuthors,
created, err := g.createWithFreeSlug(article.Article{
Title: createArticle.Title,
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
Authors: creditedAuthors,
SEODismissed: []string{},
})
if err != nil {
recordFailure(ctx, g.recorder, event, err)
@@ -83,6 +84,22 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
return renderArticle(g.bodyRenderer, created, createArticle.Format)
}
func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (article.Article, error) {
base := article.GenerateSlug(draft.Title)
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
slug, err := freeSlug(g.articleRepository, draft.SiteID, base, 0, firstSlugAttempt)
if err != nil {
return article.Article{}, err
}
draft.Slug = slug
created, err := g.articleRepository.Create(draft)
if !isSlugTakenError(err) {
return created, err
}
}
return article.Article{}, article.ErrSlugTaken
}
func articleTarget(a article.Article) audit.Target {
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
}
@@ -21,6 +21,9 @@ type CreateSiteCommand struct {
Locale *string
DefaultSocialImageID *uint32
IsDefault bool
SiteName string
TitleTemplate *string
DefaultDescription string
}
func NewCreateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *CreateSiteUseCase {
@@ -52,6 +55,10 @@ func (u *CreateSiteUseCase) create(command CreateSiteCommand) (site.Site, error)
if err := ensureImageExists(u.imageRepository, command.DefaultSocialImageID); err != nil {
return site.Site{}, err
}
titleTemplate, err := resolveTitleTemplate(command.TitleTemplate)
if err != nil {
return site.Site{}, err
}
taken, err := isKeyTaken(u.siteRepository, command.Key)
if err != nil {
return site.Site{}, err
@@ -71,5 +78,18 @@ func (u *CreateSiteUseCase) create(command CreateSiteCommand) (site.Site, error)
DefaultRobots: robots,
Locale: locale,
IsDefault: command.IsDefault,
SiteName: command.SiteName,
TitleTemplate: titleTemplate,
DefaultDescription: command.DefaultDescription,
})
}
func resolveTitleTemplate(requested *string) (string, error) {
if requested == nil {
return site.DefaultTitleTemplate, nil
}
if err := site.ValidateTitleTemplate(*requested); err != nil {
return "", err
}
return *requested, nil
}
@@ -1,11 +1,13 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
"log"
)
type DeleteMediaUseCase struct {
@@ -14,10 +16,17 @@ type DeleteMediaUseCase struct {
recorder audit.Recorder
sites site.Repository
authors author.Repository
articles article.Repository
}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors}
func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository, articles article.Repository) *DeleteMediaUseCase {
return &DeleteMediaUseCase{imageRepository: imageRepository, remover: newMediaRemover(imageRepository, storage, variantFiles), recorder: recorder, sites: sites, authors: authors, articles: articles}
}
func logCompensation(step string, err error) {
if err != nil {
log.Println("media compensation failed to", step+":", err)
}
}
func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
@@ -27,12 +36,19 @@ func (u *DeleteMediaUseCase) removeClearingSites(img image.Image) error {
}
clearedAuthors, err := u.authors.ClearAvatar(img.ID)
if err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
logCompensation("restore site images", u.sites.AssignImage(clearedSites, img.ID))
return err
}
clearedArticles, err := u.articles.ClearImage(img.ID)
if err != nil {
logCompensation("restore site images", u.sites.AssignImage(clearedSites, img.ID))
logCompensation("restore author avatars", u.authors.AssignAvatar(clearedAuthors, img.ID))
return err
}
if err := u.remover.remove(img); err != nil {
_ = u.sites.AssignImage(clearedSites, img.ID)
_ = u.authors.AssignAvatar(clearedAuthors, img.ID)
logCompensation("restore site images", u.sites.AssignImage(clearedSites, img.ID))
logCompensation("restore author avatars", u.authors.AssignAvatar(clearedAuthors, img.ID))
logCompensation("restore article images", u.articles.RestoreImage(clearedArticles, img.ID))
return err
}
return nil
@@ -2,22 +2,136 @@ package useCases
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"slices"
"strings"
)
const (
UsageTypeArticle = "article"
UsageTypeAuthor = "author"
UsageTypeSite = "site"
UsageFieldBody = "body"
UsageFieldCover = "cover"
UsageFieldSocial = "social"
UsageFieldAvatar = "avatar"
UsageFieldDefaultSocialImage = "default_social_image"
)
type MediaUsage struct {
Type string
ID uint32
Title string
Published *bool
Fields []string
}
type GetMediaUsageUseCase struct {
imageRepository image.Repository
articleRepository article.Repository
sites site.Repository
authors author.Repository
}
func NewGetMediaUsageUseCase(imageRepository image.Repository, articleRepository article.Repository) *GetMediaUsageUseCase {
return &GetMediaUsageUseCase{imageRepository, articleRepository}
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 (u *GetMediaUsageUseCase) Usage(id uint32) ([]article.Article, error) {
func (u *GetMediaUsageUseCase) Usage(id uint32) ([]MediaUsage, error) {
img, err := u.imageRepository.Get(id)
if err != nil {
return nil, err
}
return u.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
usages, err := u.articleUsages(img)
if err != nil {
return nil, err
}
authorUsages, err := u.authorUsages(img)
if err != nil {
return nil, err
}
siteUsages, err := u.siteUsages(img)
if err != nil {
return nil, err
}
return slices.Concat(usages, authorUsages, siteUsages), nil
}
func (u *GetMediaUsageUseCase) articleUsages(img image.Image) ([]MediaUsage, error) {
storedName := image.StoredName(img.Path)
inBody, err := u.articleRepository.FindByBodyContaining(storedName)
if err != nil {
return nil, err
}
referencing, err := u.articleRepository.FindByImage(img.ID)
if err != nil {
return nil, err
}
byID := map[uint32]*MediaUsage{}
for _, found := range inBody {
if !strings.Contains(found.Body, storedName) {
continue
}
entry := usageFor(byID, found)
entry.Fields = append(entry.Fields, UsageFieldBody)
}
for _, found := range referencing {
entry := usageFor(byID, found)
if found.CoverImageID != nil && *found.CoverImageID == img.ID {
entry.Fields = append(entry.Fields, UsageFieldCover)
}
if found.SocialImageID != nil && *found.SocialImageID == img.ID {
entry.Fields = append(entry.Fields, UsageFieldSocial)
}
}
usages := make([]MediaUsage, 0, len(byID))
for _, entry := range byID {
usages = append(usages, *entry)
}
slices.SortFunc(usages, func(a, b MediaUsage) int { return int(a.ID) - int(b.ID) })
return usages, nil
}
func usageFor(byID map[uint32]*MediaUsage, found article.Article) *MediaUsage {
if entry, ok := byID[found.ID]; ok {
return entry
}
published := found.IsOnline
entry := &MediaUsage{Type: UsageTypeArticle, ID: found.ID, Title: found.Title, Published: &published, Fields: []string{}}
byID[found.ID] = entry
return entry
}
func (u *GetMediaUsageUseCase) authorUsages(img image.Image) ([]MediaUsage, error) {
found, err := u.authors.FindByAvatar(img.ID)
if err != nil {
return nil, err
}
usages := make([]MediaUsage, 0, len(found))
for _, a := range found {
usages = append(usages, MediaUsage{Type: UsageTypeAuthor, ID: a.ID, Title: a.Name, Fields: []string{UsageFieldAvatar}})
}
return usages, nil
}
func (u *GetMediaUsageUseCase) siteUsages(img image.Image) ([]MediaUsage, error) {
all, err := u.sites.List()
if err != nil {
return nil, err
}
usages := []MediaUsage{}
for _, s := range all {
if s.DefaultSocialImageID != nil && *s.DefaultSocialImageID == img.ID {
usages = append(usages, MediaUsage{Type: UsageTypeSite, ID: s.ID, Title: s.Name, Fields: []string{UsageFieldDefaultSocialImage}})
}
}
slices.SortFunc(usages, func(a, b MediaUsage) int { return int(a.ID) - int(b.ID) })
return usages, nil
}
func articleMentionsImage(a article.Article, img image.Image) bool {
return strings.Contains(a.Body, image.StoredName(img.Path)) || a.UsesImage(img.ID)
}
+20 -4
View File
@@ -6,6 +6,7 @@ import (
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/user"
"slices"
"strings"
)
@@ -106,7 +107,7 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
}
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
articles, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
articleCount, err := l.articleUsageCount(img)
if err != nil {
return MediaItem{}, err
}
@@ -137,7 +138,23 @@ func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
variantSize += row.Size
}
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: len(articles) + siteUsage[img.ID], Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
return MediaItem{Image: img, UploadedBy: uploadedBy, UsedIn: articleCount + siteUsage[img.ID], Duplicate: duplicate, VariantCount: len(rows), VariantSize: variantSize}, nil
}
func (l *ListMediaUseCase) articleUsageCount(img image.Image) (int, error) {
inBody, err := l.articleRepository.FindByBodyContaining(image.StoredName(img.Path))
if err != nil {
return 0, err
}
referencing, err := l.articleRepository.FindByImage(img.ID)
if err != nil {
return 0, err
}
distinct := map[uint32]bool{}
for _, found := range slices.Concat(inBody, referencing) {
distinct[found.ID] = true
}
return len(distinct), nil
}
func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
@@ -150,10 +167,9 @@ func (l *ListMediaUseCase) usageCounter() (func(image.Image) int, error) {
return nil, err
}
return func(img image.Image) int {
storedName := image.StoredName(img.Path)
used := siteUsage[img.ID]
for _, a := range articles {
if strings.Contains(a.Body, storedName) {
if articleMentionsImage(a, img) {
used++
}
}
+17 -5
View File
@@ -10,6 +10,7 @@ import (
"context"
"errors"
"slices"
"strconv"
"strings"
)
@@ -100,6 +101,11 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
if err := u.applyRewrites(rewrites); err != nil {
return MergeMediaResult{}, err
}
repointedArticles, err := u.articleRepository.RepointImage(copyIdsOf(copies), kept.ID)
if err != nil {
u.restoreBodies(rewrites)
return MergeMediaResult{}, err
}
sitesUpdated, err := u.repointSites(copies, kept.ID)
if err != nil {
u.restoreBodies(rewrites)
@@ -118,15 +124,17 @@ func (u *MergeMediaUseCase) merge(keepId uint32, mergeIds []uint32) (MergeMediaR
}
}
return MergeMediaResult{ArticlesUpdated: len(rewrites), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: rewrittenIds(rewrites)}, nil
updatedIds := updatedArticleIds(rewrites, repointedArticles)
return MergeMediaResult{ArticlesUpdated: len(updatedIds), Merged: len(copies), SitesUpdated: sitesUpdated, AuthorsUpdated: authorsUpdated, UpdatedArticleIDs: updatedIds}, nil
}
func rewrittenIds(rewrites []bodyRewrite) []uint32 {
ids := make([]uint32, 0, len(rewrites))
func updatedArticleIds(rewrites []bodyRewrite, repointed []uint32) []uint32 {
ids := slices.Clone(repointed)
for _, plan := range rewrites {
ids = append(ids, plan.articleId)
}
return ids
slices.Sort(ids)
return slices.Compact(ids)
}
func validateMergeRequest(keepId uint32, mergeIds []uint32) error {
@@ -172,6 +180,9 @@ func (u *MergeMediaUseCase) planRewrites(kept image.Image, copies []image.Image)
return nil, err
}
for _, a := range articles {
if !strings.Contains(a.Body, copyName) {
continue
}
plan, ok := rewritten[a.ID]
if !ok {
plan = &bodyRewrite{articleId: a.ID, original: a.Body, rewritten: a.Body}
@@ -201,6 +212,7 @@ func (u *MergeMediaUseCase) applyRewrites(rewrites []bodyRewrite) error {
func (u *MergeMediaUseCase) restoreBodies(applied []bodyRewrite) {
for _, plan := range applied {
_, _ = u.articleRepository.UpdateBody(plan.articleId, plan.original)
_, err := u.articleRepository.UpdateBody(plan.articleId, plan.original)
logCompensation("restore article body "+strconv.FormatUint(uint64(plan.articleId), 10), err)
}
}
+136 -17
View File
@@ -4,6 +4,7 @@ import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"context"
"slices"
@@ -17,17 +18,30 @@ type UpdateArticleUseCase struct {
recorder audit.Recorder
sites site.Repository
authors author.Repository
images image.Repository
}
type UpdateArticleCommand struct {
Body *string
SiteID *uint32
AuthorIDs *[]uint32
Format article.BodyFormat
Body *string
SiteID *uint32
AuthorIDs *[]uint32
Slug *string
Excerpt *string
SEOTitle *string
SEODescription *string
CoverImageID article.Nullable[uint32]
CoverImageAlt *string
SocialTitle *string
SocialDescription *string
SocialImageID article.Nullable[uint32]
CanonicalURL *string
Noindex *bool
SEODismissed *[]string
Format article.BodyFormat
}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
func NewUpdateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository, images image.Repository) *UpdateArticleUseCase {
return &UpdateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors, images: images}
}
func (g *UpdateArticleUseCase) UpdateBody(ctx context.Context, id uint32, body string, format article.BodyFormat) (article.Article, error) {
@@ -46,7 +60,20 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
if err := g.ensureAuthorsExist(command.AuthorIDs); err != nil {
return article.Article{}, err
}
updated, err := g.articleRepository.Update(id, article.Update{Body: command.Body, SiteID: command.SiteID, AuthorIDs: command.AuthorIDs})
if err := g.validateSEO(command); err != nil {
return article.Article{}, err
}
update := command.toUpdate()
slug, err := g.resolveSlug(before, command)
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
}
update.Slug = slug
updated, err := g.articleRepository.Update(id, update)
if isSlugTakenError(err) {
err = g.raceConflict(before, command, slug)
}
if err != nil {
g.recordFailure(ctx, audit.ActionArticleEdit, before, err)
return article.Article{}, err
@@ -55,6 +82,93 @@ func (g *UpdateArticleUseCase) Update(ctx context.Context, id uint32, command Up
return renderArticle(g.bodyRenderer, updated, command.Format)
}
func (c UpdateArticleCommand) toUpdate() article.Update {
update := article.Update{
Body: c.Body,
SiteID: c.SiteID,
AuthorIDs: c.AuthorIDs,
Excerpt: c.Excerpt,
SEOTitle: c.SEOTitle,
SEODescription: c.SEODescription,
CoverImageID: c.CoverImageID,
CoverImageAlt: c.CoverImageAlt,
SocialTitle: c.SocialTitle,
SocialDescription: c.SocialDescription,
SocialImageID: c.SocialImageID,
CanonicalURL: c.CanonicalURL,
Noindex: c.Noindex,
}
if c.SEODismissed != nil {
normalized := article.NormalizeDismissed(*c.SEODismissed)
update.SEODismissed = &normalized
}
return update
}
func (g *UpdateArticleUseCase) validateSEO(command UpdateArticleCommand) error {
if command.Slug != nil && *command.Slug != "" && !article.IsValidSlug(*command.Slug) {
return article.ErrInvalidSlug
}
if command.CanonicalURL != nil {
if err := article.ValidateCanonicalURL(*command.CanonicalURL); err != nil {
return err
}
}
if command.CoverImageID.Set {
if err := ensureImageExists(g.images, command.CoverImageID.Value); err != nil {
return err
}
}
if command.SocialImageID.Set {
return ensureImageExists(g.images, command.SocialImageID.Value)
}
return nil
}
func targetSiteID(before article.Article, command UpdateArticleCommand) uint32 {
if command.SiteID != nil {
return *command.SiteID
}
return before.SiteID
}
func requestedSlug(before article.Article, command UpdateArticleCommand) string {
if command.Slug == nil {
return before.Slug
}
if *command.Slug == "" {
return article.GenerateSlug(before.Title)
}
return *command.Slug
}
func (g *UpdateArticleUseCase) resolveSlug(before article.Article, command UpdateArticleCommand) (*string, error) {
target := targetSiteID(before, command)
slug := requestedSlug(before, command)
if slug == "" || (slug == before.Slug && target == before.SiteID) {
return nil, nil
}
taken, err := g.articleRepository.SlugTaken(target, slug, before.ID)
if err != nil {
return nil, err
}
if taken {
return nil, slugConflict(g.articleRepository, target, slug, before.ID)
}
if slug == before.Slug {
return nil, nil
}
return &slug, nil
}
func (g *UpdateArticleUseCase) raceConflict(before article.Article, command UpdateArticleCommand, chosen *string) error {
slug := requestedSlug(before, command)
if chosen != nil {
slug = *chosen
}
return slugConflict(g.articleRepository, targetSiteID(before, command), slug, before.ID)
}
func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
if command.SiteID == nil {
return nil, nil
@@ -79,22 +193,14 @@ func (g *UpdateArticleUseCase) ensureAuthorsExist(ids *[]uint32) error {
}
func authorsChange(before article.Article, after article.Article) (audit.Change, bool) {
beforeIDs := bylineIDs(before.Authors)
afterIDs := bylineIDs(after.Authors)
beforeIDs := author.BylineIDs(before.Authors)
afterIDs := author.BylineIDs(after.Authors)
if slices.Equal(beforeIDs, afterIDs) {
return audit.Change{}, false
}
return audit.TextChange(audit.FieldAuthors, bylineNames(before.Authors), bylineNames(after.Authors)), true
}
func bylineIDs(bylines []author.Byline) []uint32 {
ids := make([]uint32, 0, len(bylines))
for _, byline := range bylines {
ids = append(ids, byline.ID)
}
return ids
}
func bylineNames(bylines []author.Byline) string {
names := make([]string, 0, len(bylines))
for _, byline := range bylines {
@@ -105,6 +211,8 @@ func bylineNames(bylines []author.Byline) string {
func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Article, after article.Article, destination *site.Site) {
changes := audit.ArticleChanges(before, after)
changes = append(changes, audit.SEOChanges(before, after)...)
changes = append(changes, g.imageChanges(before, after)...)
if destination != nil && before.SiteID != after.SiteID {
changes = append(changes, audit.TextChange(audit.FieldSite, g.siteName(before.SiteID), destination.Name))
}
@@ -120,6 +228,17 @@ func (g *UpdateArticleUseCase) recordEdit(ctx context.Context, before article.Ar
g.recorder.Record(ctx, event)
}
func (g *UpdateArticleUseCase) imageChanges(before article.Article, after article.Article) []audit.Change {
changes := []audit.Change{}
if !sameImage(before.CoverImageID, after.CoverImageID) {
changes = append(changes, audit.TextChange(audit.FieldCoverImage, imageLabel(g.images, before.CoverImageID), imageLabel(g.images, after.CoverImageID)))
}
if !sameImage(before.SocialImageID, after.SocialImageID) {
changes = append(changes, audit.TextChange(audit.FieldArticleSocialImage, imageLabel(g.images, before.SocialImageID), imageLabel(g.images, after.SocialImageID)))
}
return changes
}
func (g *UpdateArticleUseCase) siteName(id uint32) string {
found, err := g.sites.Get(id)
if err != nil {
+19 -11
View File
@@ -80,28 +80,36 @@ func (u *UpdateSiteUseCase) apply(before site.Site, patch site.Patch) (site.Site
}
after.DefaultSocialImageID = patch.DefaultSocialImageID.Value
}
if patch.SiteName != nil {
after.SiteName = *patch.SiteName
}
if patch.TitleTemplate != nil {
if err := site.ValidateTitleTemplate(*patch.TitleTemplate); err != nil {
return site.Site{}, err
}
after.TitleTemplate = *patch.TitleTemplate
}
if patch.DefaultDescription != nil {
after.DefaultDescription = *patch.DefaultDescription
}
return after, nil
}
func (u *UpdateSiteUseCase) changes(before site.Site, after site.Site) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldBaseURL, before.BaseURL, after.BaseURL)
changes = appendTextChange(changes, audit.FieldDefaultRobots, before.DefaultRobots, after.DefaultRobots)
changes = appendTextChange(changes, audit.FieldLocale, localeLabel(before.Locale), localeLabel(after.Locale))
changes = audit.AppendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = audit.AppendTextChange(changes, audit.FieldBaseURL, before.BaseURL, after.BaseURL)
changes = audit.AppendTextChange(changes, audit.FieldDefaultRobots, before.DefaultRobots, after.DefaultRobots)
changes = audit.AppendTextChange(changes, audit.FieldLocale, localeLabel(before.Locale), localeLabel(after.Locale))
if !sameImage(before.DefaultSocialImageID, after.DefaultSocialImageID) {
changes = append(changes, audit.TextChange(audit.FieldSocialImage, imageLabel(u.imageRepository, before.DefaultSocialImageID), imageLabel(u.imageRepository, after.DefaultSocialImageID)))
}
changes = audit.AppendTextChange(changes, audit.FieldSiteName, before.SiteName, after.SiteName)
changes = audit.AppendTextChange(changes, audit.FieldTitleTemplate, before.TitleTemplate, after.TitleTemplate)
changes = audit.AppendTextChange(changes, audit.FieldDefaultDescription, before.DefaultDescription, after.DefaultDescription)
return changes
}
func appendTextChange(changes []audit.Change, field string, before string, after string) []audit.Change {
if before == after {
return changes
}
return append(changes, audit.TextChange(field, before, after))
}
func sameImage(a *uint32, b *uint32) bool {
if a == nil || b == nil {
return a == b
@@ -0,0 +1,39 @@
package useCases
import (
"RenewCMS/internal/domain/article"
"errors"
)
const (
firstSlugAttempt = 1
firstSuggestedAttempt = 2
maxCreateSlugAttempts = 5
maxFreeSlugAttempts = 100
)
func freeSlug(articles article.Repository, siteID uint32, base string, exceptID uint32, firstAttempt int) (string, error) {
for attempt := firstAttempt; attempt <= maxFreeSlugAttempts; attempt++ {
candidate := article.SlugWithSuffix(base, attempt)
taken, err := articles.SlugTaken(siteID, candidate, exceptID)
if err != nil {
return "", err
}
if !taken {
return candidate, nil
}
}
return "", article.ErrSlugTaken
}
func slugConflict(articles article.Repository, siteID uint32, slug string, exceptID uint32) error {
suggestion, err := freeSlug(articles, siteID, slug, exceptID, firstSuggestedAttempt)
if err != nil {
return err
}
return &article.SlugTakenError{Suggestion: suggestion}
}
func isSlugTakenError(err error) bool {
return errors.Is(err, article.ErrSlugTaken)
}
@@ -24,6 +24,7 @@ type failureReason struct {
var failureReasons = []failureReason{
{article.ErrInvalidTitle, "The title must be between 3 and 200 characters."},
{article.ErrSlugTaken, "That slug is already used by another article on this site."},
{image.ErrImageTypeNotAllowed, "The file must be a PNG, JPEG, WEBP or SVG image."},
{image.ErrImageTooLarge, "The file is too large to upload."},
{image.ErrImageUnreadable, "The image could not be read."},
@@ -63,6 +64,7 @@ var failureReasons = []failureReason{
{site.ErrSiteHasArticles, "The site still has articles."},
{site.ErrInvalidSiteURL, "The site address is not valid."},
{site.ErrInvalidLocale, "The locale is not valid."},
{site.ErrInvalidTitleTemplate, "The title template must contain %title%."},
{site.ErrUseSetDefault, "Use the default site action to change the default."},
{site.ErrInvalidReassign, "The destination site is not valid."},
{author.ErrSlugTaken, "That slug is already used by another author."},
@@ -100,11 +100,11 @@ func sameID(a *uint32, b *uint32) bool {
func authorChanges(users user.Repository, images image.Repository, before author.Author, after author.Author) []audit.Change {
changes := []audit.Change{}
changes = appendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = appendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
changes = appendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
changes = appendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
changes = appendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
changes = audit.AppendTextChange(changes, audit.FieldName, before.Name, after.Name)
changes = audit.AppendTextChange(changes, audit.FieldSlug, before.Slug, after.Slug)
changes = audit.AppendTextChange(changes, audit.FieldBio, before.Bio, after.Bio)
changes = audit.AppendTextChange(changes, audit.FieldWebsite, websiteLabel(before.Website), websiteLabel(after.Website))
changes = audit.AppendTextChange(changes, audit.FieldSocialLinks, linksLabel(before.SocialLinks), linksLabel(after.SocialLinks))
if !sameID(before.AvatarID, after.AvatarID) {
changes = append(changes, audit.TextChange(audit.FieldAvatar, imageLabel(images, before.AvatarID), imageLabel(images, after.AvatarID)))
}
@@ -86,10 +86,9 @@ func groupByHash(images []image.Image) [][]image.Image {
}
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) {
if articleMentionsImage(a, img) {
used++
}
}
@@ -135,16 +134,16 @@ func isBetterToKeep(candidate, current image.Image, usage map[uint32]int) bool {
}
func articlesUsingCopies(group purgeGroup, members []image.Image, articles []article.Article) []uint32 {
mergedNames := []string{}
merged := []image.Image{}
for _, member := range members {
if member.ID != group.kept.ID {
mergedNames = append(mergedNames, image.StoredName(member.Path))
merged = append(merged, member)
}
}
ids := []uint32{}
for _, a := range articles {
for _, name := range mergedNames {
if strings.Contains(a.Body, name) {
for _, copied := range merged {
if articleMentionsImage(a, copied) {
ids = append(ids, a.ID)
break
}