feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -2,7 +2,11 @@ package article
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/slug"
|
||||
"errors"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -11,13 +15,95 @@ var (
|
||||
ErrInvalidTitle = errors.New("article's title length should be between 3 and 200 caracters")
|
||||
ErrArticleNotFound = errors.New("article not found")
|
||||
ErrArticleOffline = errors.New("article is offline")
|
||||
|
||||
ErrInvalidSlug = errors.New("invalid slug")
|
||||
ErrSlugTaken = errors.New("another article on this site already uses that slug")
|
||||
ErrInvalidCanonicalURL = errors.New("invalid canonical url")
|
||||
)
|
||||
|
||||
type SlugTakenError struct {
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *SlugTakenError) Error() string {
|
||||
return ErrSlugTaken.Error()
|
||||
}
|
||||
|
||||
func (e *SlugTakenError) Is(target error) bool {
|
||||
return target == ErrSlugTaken
|
||||
}
|
||||
|
||||
const (
|
||||
MinTitleRunes = 3
|
||||
MaxTitleRunes = 200
|
||||
|
||||
MaxCanonicalURLRunes = 2048
|
||||
|
||||
FallbackSlug = "article"
|
||||
DeletedSlug = "#deleted-"
|
||||
|
||||
DismissTitleLong = "title_long"
|
||||
DismissTitleEmpty = "title_empty"
|
||||
DismissDescShort = "desc_short"
|
||||
DismissDescLong = "desc_long"
|
||||
DismissCoverAlt = "cover_alt"
|
||||
DismissCoverNone = "cover_none"
|
||||
DismissSlugLong = "slug_long"
|
||||
DismissNoAuthor = "no_au"
|
||||
DismissNoHeading = "no_h"
|
||||
DismissBodyAlt = "body_alt"
|
||||
DismissShort = "short"
|
||||
)
|
||||
|
||||
var dismissCodes = []string{
|
||||
DismissTitleLong, DismissTitleEmpty, DismissDescShort, DismissDescLong, DismissCoverAlt, DismissCoverNone,
|
||||
DismissSlugLong, DismissNoAuthor, DismissNoHeading, DismissBodyAlt, DismissShort,
|
||||
}
|
||||
|
||||
func DismissCodes() []string {
|
||||
return slices.Clone(dismissCodes)
|
||||
}
|
||||
|
||||
func NormalizeDismissed(codes []string) []string {
|
||||
normalized := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
if !slices.Contains(normalized, code) {
|
||||
normalized = append(normalized, code)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func IsValidSlug(candidate string) bool {
|
||||
return slug.IsValid(candidate)
|
||||
}
|
||||
|
||||
func Tombstone(id uint32) string {
|
||||
return DeletedSlug + strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
func GenerateSlug(title string) string {
|
||||
return slug.Generate(title, FallbackSlug)
|
||||
}
|
||||
|
||||
func SlugWithSuffix(base string, attempt int) string {
|
||||
return slug.WithSuffix(base, attempt)
|
||||
}
|
||||
|
||||
func ValidateCanonicalURL(raw string) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if utf8.RuneCountInString(raw) > MaxCanonicalURLRunes {
|
||||
return ErrInvalidCanonicalURL
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
|
||||
return ErrInvalidCanonicalURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTitle(title string) error {
|
||||
runeCount := utf8.RuneCountInString(title)
|
||||
if runeCount < MinTitleRunes || runeCount > MaxTitleRunes {
|
||||
@@ -27,12 +113,28 @@ func ValidateTitle(title string) error {
|
||||
}
|
||||
|
||||
type Article struct {
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
Authors []author.Byline `json:"authors"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
SiteID uint32 `json:"site_id"`
|
||||
Authors []author.Byline `json:"authors"`
|
||||
Slug string `json:"slug"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
SEOTitle string `json:"seo_title"`
|
||||
SEODescription string `json:"seo_description"`
|
||||
CoverImageID *uint32 `json:"cover_image_id"`
|
||||
CoverImageAlt string `json:"cover_image_alt"`
|
||||
SocialTitle string `json:"social_title"`
|
||||
SocialDescription string `json:"social_description"`
|
||||
SocialImageID *uint32 `json:"social_image_id"`
|
||||
CanonicalURL string `json:"canonical_url"`
|
||||
Noindex bool `json:"noindex"`
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (a Article) UsesImage(imageID uint32) bool {
|
||||
return a.CoverImageID != nil && *a.CoverImageID == imageID || a.SocialImageID != nil && *a.SocialImageID == imageID
|
||||
}
|
||||
@@ -38,10 +38,36 @@ type Filters struct {
|
||||
Sort Sort
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
Body *string
|
||||
SiteID *uint32
|
||||
AuthorIDs *[]uint32
|
||||
Slug *string
|
||||
Excerpt *string
|
||||
SEOTitle *string
|
||||
SEODescription *string
|
||||
CoverImageID Nullable[uint32]
|
||||
CoverImageAlt *string
|
||||
SocialTitle *string
|
||||
SocialDescription *string
|
||||
SocialImageID Nullable[uint32]
|
||||
CanonicalURL *string
|
||||
Noindex *bool
|
||||
SEODismissed *[]string
|
||||
}
|
||||
|
||||
type ImageReferences struct {
|
||||
Cover []uint32
|
||||
Social []uint32
|
||||
}
|
||||
|
||||
func (r ImageReferences) Empty() bool {
|
||||
return len(r.Cover) == 0 && len(r.Social) == 0
|
||||
}
|
||||
|
||||
type Counts struct {
|
||||
@@ -68,4 +94,9 @@ type Repository interface {
|
||||
UpdateIsOnline(id uint32, isOnline bool) (Article, error)
|
||||
Delete(id uint32) error
|
||||
FindByBodyContaining(filename string) ([]Article, error)
|
||||
FindByImage(imageID uint32) ([]Article, error)
|
||||
SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error)
|
||||
ClearImage(imageID uint32) (ImageReferences, error)
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
}
|
||||
@@ -12,8 +12,33 @@ func TextChange(field string, before string, after string) Change {
|
||||
}
|
||||
|
||||
func BodyChange(before string, after string) Change {
|
||||
return SizeChange(FieldBody, before, after)
|
||||
}
|
||||
|
||||
func SizeChange(field string, before string, after string) Change {
|
||||
delta := utf8.RuneCountInString(after) - utf8.RuneCountInString(before)
|
||||
return Change{Field: FieldBody, Kind: ChangeBody, Delta: &delta}
|
||||
return Change{Field: field, Kind: ChangeBody, Delta: &delta}
|
||||
}
|
||||
|
||||
func appendSizeChange(changes []Change, field string, before string, after string) []Change {
|
||||
if before == after {
|
||||
return changes
|
||||
}
|
||||
return append(changes, SizeChange(field, before, after))
|
||||
}
|
||||
|
||||
func SEOChanges(before article.Article, after article.Article) []Change {
|
||||
changes := []Change{}
|
||||
changes = AppendTextChange(changes, FieldSlug, before.Slug, after.Slug)
|
||||
changes = AppendTextChange(changes, FieldCanonicalURL, before.CanonicalURL, after.CanonicalURL)
|
||||
changes = AppendTextChange(changes, FieldNoindex, strconv.FormatBool(before.Noindex), strconv.FormatBool(after.Noindex))
|
||||
changes = appendSizeChange(changes, FieldExcerpt, before.Excerpt, after.Excerpt)
|
||||
changes = appendSizeChange(changes, FieldSEOTitle, before.SEOTitle, after.SEOTitle)
|
||||
changes = appendSizeChange(changes, FieldSEODescription, before.SEODescription, after.SEODescription)
|
||||
changes = appendSizeChange(changes, FieldCoverImageAlt, before.CoverImageAlt, after.CoverImageAlt)
|
||||
changes = appendSizeChange(changes, FieldSocialTitle, before.SocialTitle, after.SocialTitle)
|
||||
changes = appendSizeChange(changes, FieldSocialDescription, before.SocialDescription, after.SocialDescription)
|
||||
return changes
|
||||
}
|
||||
|
||||
func ArticleChanges(before article.Article, after article.Article) []Change {
|
||||
@@ -32,15 +57,15 @@ func ArticleChanges(before article.Article, after article.Article) []Change {
|
||||
|
||||
func VariantChanges(before image.VariantSettings, after image.VariantSettings) []Change {
|
||||
changes := []Change{}
|
||||
changes = appendChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
|
||||
changes = appendChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
|
||||
changes = appendChange(changes, FieldFit, string(before.Fit), string(after.Fit))
|
||||
changes = appendChange(changes, FieldFormat, string(before.Format), string(after.Format))
|
||||
changes = appendChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
|
||||
changes = AppendTextChange(changes, FieldWidth, image.DimensionText(before.Width), image.DimensionText(after.Width))
|
||||
changes = AppendTextChange(changes, FieldHeight, image.DimensionText(before.Height), image.DimensionText(after.Height))
|
||||
changes = AppendTextChange(changes, FieldFit, string(before.Fit), string(after.Fit))
|
||||
changes = AppendTextChange(changes, FieldFormat, string(before.Format), string(after.Format))
|
||||
changes = AppendTextChange(changes, FieldQuality, image.QualityText(before.Quality), image.QualityText(after.Quality))
|
||||
return changes
|
||||
}
|
||||
|
||||
func appendChange(changes []Change, field string, before string, after string) []Change {
|
||||
func AppendTextChange(changes []Change, field string, before string, after string) []Change {
|
||||
if before == after {
|
||||
return changes
|
||||
}
|
||||
|
||||
@@ -121,58 +121,71 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
SystemActorName = "System"
|
||||
ReasonWrongLogin = "Wrong username or password."
|
||||
MaxUserAgentLength = 255
|
||||
ParamRemoved = "removed"
|
||||
ParamFrom = "from"
|
||||
ParamTo = "to"
|
||||
ParamMaxGBFrom = "max_gb_from"
|
||||
ParamMaxGBTo = "max_gb_to"
|
||||
ParamMerged = "merged"
|
||||
ParamArticlesUpdated = "articles_updated"
|
||||
ParamFailedGroups = "failed_groups"
|
||||
ParamUpdateAvailable = "update_available"
|
||||
ParamKind = "kind"
|
||||
ParamPurgedFiles = "purged_files"
|
||||
ParamPurgedBytes = "purged_bytes"
|
||||
ParamFailedFiles = "failed_files"
|
||||
ParamOlderThanDays = "older_than_days"
|
||||
ParamGenerated = "generated"
|
||||
ParamFailed = "failed"
|
||||
ParamCauses = "causes"
|
||||
ParamWidth = "width"
|
||||
ParamHeight = "height"
|
||||
ParamFit = "fit"
|
||||
ParamFormat = "format"
|
||||
ParamQuality = "quality"
|
||||
ParamTag = "tag"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
FieldFilename = "filename"
|
||||
FieldDisplayName = "display_name"
|
||||
FieldWidth = "width"
|
||||
FieldHeight = "height"
|
||||
FieldFit = "fit"
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
FieldName = "name"
|
||||
FieldBaseURL = "base_url"
|
||||
FieldDefaultRobots = "default_robots"
|
||||
FieldLocale = "locale"
|
||||
FieldSocialImage = "default_social_image"
|
||||
FieldSite = "site"
|
||||
FieldSlug = "slug"
|
||||
FieldBio = "bio"
|
||||
FieldWebsite = "website"
|
||||
FieldSocialLinks = "social_links"
|
||||
FieldAvatar = "avatar"
|
||||
FieldUser = "user"
|
||||
FieldAuthors = "authors"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
SystemActorName = "System"
|
||||
ReasonWrongLogin = "Wrong username or password."
|
||||
MaxUserAgentLength = 255
|
||||
ParamRemoved = "removed"
|
||||
ParamFrom = "from"
|
||||
ParamTo = "to"
|
||||
ParamMaxGBFrom = "max_gb_from"
|
||||
ParamMaxGBTo = "max_gb_to"
|
||||
ParamMerged = "merged"
|
||||
ParamArticlesUpdated = "articles_updated"
|
||||
ParamFailedGroups = "failed_groups"
|
||||
ParamUpdateAvailable = "update_available"
|
||||
ParamKind = "kind"
|
||||
ParamPurgedFiles = "purged_files"
|
||||
ParamPurgedBytes = "purged_bytes"
|
||||
ParamFailedFiles = "failed_files"
|
||||
ParamOlderThanDays = "older_than_days"
|
||||
ParamGenerated = "generated"
|
||||
ParamFailed = "failed"
|
||||
ParamCauses = "causes"
|
||||
ParamWidth = "width"
|
||||
ParamHeight = "height"
|
||||
ParamFit = "fit"
|
||||
ParamFormat = "format"
|
||||
ParamQuality = "quality"
|
||||
ParamTag = "tag"
|
||||
FieldTitle = "title"
|
||||
FieldOnline = "online"
|
||||
FieldBody = "body"
|
||||
FieldFilename = "filename"
|
||||
FieldDisplayName = "display_name"
|
||||
FieldWidth = "width"
|
||||
FieldHeight = "height"
|
||||
FieldFit = "fit"
|
||||
FieldFormat = "format"
|
||||
FieldQuality = "quality"
|
||||
FieldName = "name"
|
||||
FieldBaseURL = "base_url"
|
||||
FieldDefaultRobots = "default_robots"
|
||||
FieldLocale = "locale"
|
||||
FieldSocialImage = "default_social_image"
|
||||
FieldSite = "site"
|
||||
FieldSlug = "slug"
|
||||
FieldBio = "bio"
|
||||
FieldWebsite = "website"
|
||||
FieldSocialLinks = "social_links"
|
||||
FieldAvatar = "avatar"
|
||||
FieldUser = "user"
|
||||
FieldAuthors = "authors"
|
||||
FieldCanonicalURL = "canonical_url"
|
||||
FieldNoindex = "noindex"
|
||||
FieldCoverImage = "cover_image"
|
||||
FieldArticleSocialImage = "social_image"
|
||||
FieldExcerpt = "excerpt"
|
||||
FieldSEOTitle = "seo_title"
|
||||
FieldSEODescription = "seo_description"
|
||||
FieldSocialTitle = "social_title"
|
||||
FieldSocialDescription = "social_description"
|
||||
FieldCoverImageAlt = "cover_image_alt"
|
||||
FieldSiteName = "site_name"
|
||||
FieldTitleTemplate = "title_template"
|
||||
FieldDefaultDescription = "default_description"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
)
|
||||
|
||||
func ActorTypes() []ActorType {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package author
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/slug"
|
||||
"errors"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -41,31 +40,6 @@ const (
|
||||
SocialCustom = "custom"
|
||||
)
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var nonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
var diacriticFolder = strings.NewReplacer(
|
||||
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
|
||||
"æ", "ae",
|
||||
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
|
||||
"ď", "d", "đ", "d",
|
||||
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
|
||||
"ğ", "g",
|
||||
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
|
||||
"ł", "l", "ľ", "l",
|
||||
"ñ", "n", "ń", "n", "ň", "n",
|
||||
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
|
||||
"œ", "oe",
|
||||
"ř", "r",
|
||||
"ś", "s", "š", "s", "ş", "s", "ș", "s",
|
||||
"ß", "ss",
|
||||
"ť", "t", "ț", "t", "ţ", "t",
|
||||
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
|
||||
"ý", "y", "ÿ", "y",
|
||||
"ź", "z", "ż", "z", "ž", "z",
|
||||
)
|
||||
|
||||
type SocialLink struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
@@ -103,6 +77,14 @@ func (a Author) Byline() Byline {
|
||||
return Byline{ID: a.ID, Name: a.Name, Slug: a.Slug, Bio: a.Bio, AvatarID: a.AvatarID, Website: a.Website, SocialLinks: links}
|
||||
}
|
||||
|
||||
func BylineIDs(bylines []Byline) []uint32 {
|
||||
ids := make([]uint32, 0, len(bylines))
|
||||
for _, byline := range bylines {
|
||||
ids = append(ids, byline.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
@@ -129,9 +111,8 @@ type Page struct {
|
||||
Total int
|
||||
}
|
||||
|
||||
func IsValidSlug(slug string) bool {
|
||||
length := utf8.RuneCountInString(slug)
|
||||
return length >= 1 && length <= MaxSlugRunes && slugPattern.MatchString(slug)
|
||||
func IsValidSlug(candidate string) bool {
|
||||
return slug.IsValid(candidate)
|
||||
}
|
||||
|
||||
func IsValidName(name string) bool {
|
||||
@@ -191,26 +172,9 @@ func ValidateLinks(links []SocialLink) error {
|
||||
}
|
||||
|
||||
func GenerateSlug(name string) string {
|
||||
folded := diacriticFolder.Replace(strings.ToLower(name))
|
||||
slug := strings.Trim(nonSlugRun.ReplaceAllString(folded, "-"), "-")
|
||||
slug = truncateSlug(slug, MaxSlugRunes)
|
||||
if slug == "" {
|
||||
return FallbackSlug
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func truncateSlug(slug string, limit int) string {
|
||||
if len(slug) <= limit {
|
||||
return slug
|
||||
}
|
||||
return strings.TrimRight(slug[:limit], "-")
|
||||
return slug.Generate(name, FallbackSlug)
|
||||
}
|
||||
|
||||
func SlugWithSuffix(base string, attempt int) string {
|
||||
if attempt <= 1 {
|
||||
return base
|
||||
}
|
||||
suffix := "-" + strconv.Itoa(attempt)
|
||||
return truncateSlug(base, MaxSlugRunes-len(suffix)) + suffix
|
||||
return slug.WithSuffix(base, attempt)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type Repository interface {
|
||||
DeleteMovingCredits(id uint32, reassignTo *uint32) error
|
||||
DeleteUncrediting(id uint32) error
|
||||
AvatarUsage() (map[uint32]int, error)
|
||||
FindByAvatar(imageID uint32) ([]Author, error)
|
||||
ClearAvatar(imageID uint32) ([]uint32, error)
|
||||
AssignAvatar(authorIDs []uint32, imageID uint32) error
|
||||
RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error)
|
||||
|
||||
@@ -18,6 +18,8 @@ var (
|
||||
ErrInvalidLocale = errors.New("invalid locale")
|
||||
ErrUseSetDefault = errors.New("the default flag is changed through the default route")
|
||||
ErrInvalidReassign = errors.New("invalid reassign destination")
|
||||
|
||||
ErrInvalidTitleTemplate = errors.New("the title template must contain %title%")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -30,8 +32,14 @@ const (
|
||||
MinNameRunes = 1
|
||||
MaxNameRunes = 100
|
||||
|
||||
MaxLocaleRunes = 35
|
||||
MaxBaseURLRunes = 255
|
||||
|
||||
MinKeyRunes = 2
|
||||
MaxKeyRunes = 32
|
||||
|
||||
TitlePlaceholder = "%title%"
|
||||
DefaultTitleTemplate = "%title% | %site%"
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
@@ -49,6 +57,9 @@ type Site struct {
|
||||
Locale *string `json:"locale"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
ArticleCount int `json:"article_count"`
|
||||
SiteName string `json:"site_name"`
|
||||
TitleTemplate string `json:"title_template"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
@@ -64,6 +75,9 @@ type Patch struct {
|
||||
Locale Nullable[string]
|
||||
DefaultSocialImageID Nullable[uint32]
|
||||
IsDefault *bool
|
||||
SiteName *string
|
||||
TitleTemplate *string
|
||||
DefaultDescription *string
|
||||
}
|
||||
|
||||
func IsValidKey(key string) bool {
|
||||
@@ -76,11 +90,18 @@ func IsValidName(name string) bool {
|
||||
return length >= MinNameRunes && length <= MaxNameRunes
|
||||
}
|
||||
|
||||
func ValidateTitleTemplate(template string) error {
|
||||
if !strings.Contains(template, TitlePlaceholder) {
|
||||
return ErrInvalidTitleTemplate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeLocale(locale *string) (*string, error) {
|
||||
if locale == nil || *locale == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !localePattern.MatchString(*locale) {
|
||||
if utf8.RuneCountInString(*locale) > MaxLocaleRunes || !localePattern.MatchString(*locale) {
|
||||
return nil, ErrInvalidLocale
|
||||
}
|
||||
return locale, nil
|
||||
@@ -126,7 +147,11 @@ func NormalizeBaseURL(raw string) (string, error) {
|
||||
if found.scheme == "http" && !isLoopback(found.hostname) {
|
||||
return "", ErrInvalidSiteURL
|
||||
}
|
||||
return found.scheme + "://" + found.host, nil
|
||||
normalized := found.scheme + "://" + found.host
|
||||
if utf8.RuneCountInString(normalized) > MaxBaseURLRunes {
|
||||
return "", ErrInvalidSiteURL
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func SeedBaseURL(publicURL string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package slug
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxRunes = 100
|
||||
|
||||
var pattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
var nonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
var diacriticFolder = strings.NewReplacer(
|
||||
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
|
||||
"æ", "ae",
|
||||
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
|
||||
"ď", "d", "đ", "d",
|
||||
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
|
||||
"ğ", "g",
|
||||
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
|
||||
"ł", "l", "ľ", "l",
|
||||
"ñ", "n", "ń", "n", "ň", "n",
|
||||
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
|
||||
"œ", "oe",
|
||||
"ř", "r",
|
||||
"ś", "s", "š", "s", "ş", "s", "ș", "s",
|
||||
"ß", "ss",
|
||||
"ť", "t", "ț", "t", "ţ", "t",
|
||||
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
|
||||
"ý", "y", "ÿ", "y",
|
||||
"ź", "z", "ż", "z", "ž", "z",
|
||||
)
|
||||
|
||||
func IsValid(candidate string) bool {
|
||||
length := utf8.RuneCountInString(candidate)
|
||||
return length >= 1 && length <= MaxRunes && pattern.MatchString(candidate)
|
||||
}
|
||||
|
||||
func Generate(text string, fallback string) string {
|
||||
folded := diacriticFolder.Replace(strings.ToLower(text))
|
||||
generated := strings.Trim(nonSlugRun.ReplaceAllString(folded, "-"), "-")
|
||||
generated = truncate(generated, MaxRunes)
|
||||
if generated == "" {
|
||||
return fallback
|
||||
}
|
||||
return generated
|
||||
}
|
||||
|
||||
func WithSuffix(base string, attempt int) string {
|
||||
if attempt <= 1 {
|
||||
return base
|
||||
}
|
||||
suffix := "-" + strconv.Itoa(attempt)
|
||||
return truncate(base, MaxRunes-len(suffix)) + suffix
|
||||
}
|
||||
|
||||
func truncate(candidate string, limit int) string {
|
||||
if len(candidate) <= limit {
|
||||
return candidate
|
||||
}
|
||||
return strings.TrimRight(candidate[:limit], "-")
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
articleSlugIndexName = "idx_articles_site_slug"
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX " + articleSlugIndexName + " ON articles (site_id, slug)"
|
||||
)
|
||||
|
||||
type slugOwner struct {
|
||||
siteID uint32
|
||||
slug string
|
||||
}
|
||||
|
||||
type slugAssignment struct {
|
||||
id uint32
|
||||
slug string
|
||||
}
|
||||
|
||||
func BackfillArticleSlugs(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var rows []entity.Article
|
||||
if err := tx.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
assignments := planSlugAssignments(rows)
|
||||
for _, assignment := range assignments {
|
||||
if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", assignment.id).UpdateColumn("slug", assignment.slug).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func planSlugAssignments(rows []entity.Article) []slugAssignment {
|
||||
assignments := []slugAssignment{}
|
||||
claimed := map[slugOwner]bool{}
|
||||
pending := []entity.Article{}
|
||||
for _, row := range rows {
|
||||
if row.DeletedAt.Valid {
|
||||
if tombstone := article.Tombstone(row.ID); row.Slug != tombstone {
|
||||
assignments = append(assignments, slugAssignment{row.ID, tombstone})
|
||||
}
|
||||
continue
|
||||
}
|
||||
owner := slugOwner{row.SiteID, row.Slug}
|
||||
if row.Slug == "" || claimed[owner] {
|
||||
pending = append(pending, row)
|
||||
continue
|
||||
}
|
||||
claimed[owner] = true
|
||||
}
|
||||
for _, row := range pending {
|
||||
base := row.Slug
|
||||
attempt := 2
|
||||
if base == "" {
|
||||
base = snapshotSlug(row.Title)
|
||||
attempt = 1
|
||||
}
|
||||
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt)
|
||||
claimed[slugOwner{row.SiteID, slug}] = true
|
||||
assignments = append(assignments, slugAssignment{row.ID, slug})
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string {
|
||||
for ; ; attempt++ {
|
||||
candidate := snapshotSlugWithSuffix(base, attempt)
|
||||
if !claimed[slugOwner{siteID, candidate}] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureArticleSlugIndex(db *gorm.DB) error {
|
||||
if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(articleSlugIndexStatement).Error
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
snapshotMaxRunes = 100
|
||||
snapshotFallbackSlug = "article"
|
||||
)
|
||||
|
||||
var snapshotNonSlugRun = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
var snapshotFolder = strings.NewReplacer(
|
||||
"à", "a", "á", "a", "â", "a", "ã", "a", "ä", "a", "å", "a", "ā", "a", "ă", "a", "ą", "a",
|
||||
"æ", "ae",
|
||||
"ç", "c", "ć", "c", "č", "c", "ĉ", "c",
|
||||
"ď", "d", "đ", "d",
|
||||
"è", "e", "é", "e", "ê", "e", "ë", "e", "ē", "e", "ė", "e", "ę", "e", "ě", "e",
|
||||
"ğ", "g",
|
||||
"ì", "i", "í", "i", "î", "i", "ï", "i", "ī", "i", "į", "i", "ı", "i",
|
||||
"ł", "l", "ľ", "l",
|
||||
"ñ", "n", "ń", "n", "ň", "n",
|
||||
"ò", "o", "ó", "o", "ô", "o", "õ", "o", "ö", "o", "ø", "o", "ō", "o", "ő", "o",
|
||||
"œ", "oe",
|
||||
"ř", "r",
|
||||
"ś", "s", "š", "s", "ş", "s", "ș", "s",
|
||||
"ß", "ss",
|
||||
"ť", "t", "ț", "t", "ţ", "t",
|
||||
"ù", "u", "ú", "u", "û", "u", "ü", "u", "ū", "u", "ů", "u", "ű", "u", "ų", "u",
|
||||
"ý", "y", "ÿ", "y",
|
||||
"ź", "z", "ż", "z", "ž", "z",
|
||||
)
|
||||
|
||||
func snapshotSlug(text string) string {
|
||||
folded := snapshotFolder.Replace(strings.ToLower(text))
|
||||
generated := strings.Trim(snapshotNonSlugRun.ReplaceAllString(folded, "-"), "-")
|
||||
generated = snapshotTruncate(generated, snapshotMaxRunes)
|
||||
if generated == "" {
|
||||
return snapshotFallbackSlug
|
||||
}
|
||||
return generated
|
||||
}
|
||||
|
||||
func snapshotSlugWithSuffix(base string, attempt int) string {
|
||||
if attempt <= 1 {
|
||||
return base
|
||||
}
|
||||
suffix := "-" + strconv.Itoa(attempt)
|
||||
return snapshotTruncate(base, snapshotMaxRunes-len(suffix)) + suffix
|
||||
}
|
||||
|
||||
func snapshotTruncate(candidate string, limit int) string {
|
||||
if len(candidate) <= limit {
|
||||
return candidate
|
||||
}
|
||||
return strings.TrimRight(candidate[:limit], "-")
|
||||
}
|
||||
@@ -17,7 +17,16 @@ func Migrate(db *gorm.DB, publicURL string) error {
|
||||
if err := BackfillUserRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return SeedDefaultSite(db, publicURL)
|
||||
if err := SeedDefaultSite(db, publicURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillSiteTitleTemplates(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillArticleSlugs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureArticleSlugIndex(db)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -36,6 +36,7 @@ func ensureDefaultSite(tx *gorm.DB, publicURL string) (entity.Site, error) {
|
||||
BaseURL: baseURL,
|
||||
DefaultRobots: domainSite.RobotsIndex,
|
||||
IsDefault: true,
|
||||
TitleTemplate: domainSite.DefaultTitleTemplate,
|
||||
}
|
||||
if err := tx.Create(&seeded).Error; err != nil {
|
||||
return entity.Site{}, err
|
||||
@@ -50,3 +51,7 @@ func firstDefaultSite(tx *gorm.DB) (entity.Site, error) {
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func BackfillSiteTitleTemplates(db *gorm.DB) error {
|
||||
return db.Model(&entity.Site{}).Where("title_template IS NULL OR title_template = ''").UpdateColumn("title_template", domainSite.DefaultTitleTemplate).Error
|
||||
}
|
||||
@@ -3,27 +3,76 @@ package mappers
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
const emptyDismissed = "[]"
|
||||
|
||||
func decodeDismissed(raw string) []string {
|
||||
codes := []string{}
|
||||
if raw == "" {
|
||||
return codes
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &codes); err != nil || codes == nil {
|
||||
return []string{}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func encodeDismissed(codes []string) string {
|
||||
if len(codes) == 0 {
|
||||
return emptyDismissed
|
||||
}
|
||||
encoded, err := json.Marshal(codes)
|
||||
if err != nil {
|
||||
return emptyDismissed
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func ArticleToDomain(m models.Article) article.Article {
|
||||
return article.Article{
|
||||
ID: m.ID,
|
||||
Title: m.Title,
|
||||
Body: m.Body,
|
||||
IsOnline: m.IsOnline,
|
||||
SiteID: m.SiteID,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
ID: m.ID,
|
||||
Title: m.Title,
|
||||
Body: m.Body,
|
||||
IsOnline: m.IsOnline,
|
||||
SiteID: m.SiteID,
|
||||
Slug: m.Slug,
|
||||
Excerpt: m.Excerpt,
|
||||
SEOTitle: m.SEOTitle,
|
||||
SEODescription: m.SEODescription,
|
||||
CoverImageID: m.CoverImageID,
|
||||
CoverImageAlt: m.CoverImageAlt,
|
||||
SocialTitle: m.SocialTitle,
|
||||
SocialDescription: m.SocialDescription,
|
||||
SocialImageID: m.SocialImageID,
|
||||
CanonicalURL: m.CanonicalURL,
|
||||
Noindex: m.Noindex,
|
||||
SEODismissed: decodeDismissed(m.SEODismissed),
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ArticleToModel(a article.Article) models.Article {
|
||||
return models.Article{
|
||||
Title: a.Title,
|
||||
Body: a.Body,
|
||||
IsOnline: a.IsOnline,
|
||||
SiteID: a.SiteID,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
Title: a.Title,
|
||||
Body: a.Body,
|
||||
IsOnline: a.IsOnline,
|
||||
SiteID: a.SiteID,
|
||||
Slug: a.Slug,
|
||||
Excerpt: a.Excerpt,
|
||||
SEOTitle: a.SEOTitle,
|
||||
SEODescription: a.SEODescription,
|
||||
CoverImageID: a.CoverImageID,
|
||||
CoverImageAlt: a.CoverImageAlt,
|
||||
SocialTitle: a.SocialTitle,
|
||||
SocialDescription: a.SocialDescription,
|
||||
SocialImageID: a.SocialImageID,
|
||||
CanonicalURL: a.CanonicalURL,
|
||||
Noindex: a.Noindex,
|
||||
SEODismissed: encodeDismissed(a.SEODismissed),
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
)
|
||||
|
||||
func SiteToDomain(m models.Site, articleCount int) site.Site {
|
||||
titleTemplate := m.TitleTemplate
|
||||
if titleTemplate == "" {
|
||||
titleTemplate = site.DefaultTitleTemplate
|
||||
}
|
||||
return site.Site{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
@@ -16,6 +20,9 @@ func SiteToDomain(m models.Site, articleCount int) site.Site {
|
||||
Locale: m.Locale,
|
||||
IsDefault: m.IsDefault,
|
||||
ArticleCount: articleCount,
|
||||
SiteName: m.SiteName,
|
||||
TitleTemplate: titleTemplate,
|
||||
DefaultDescription: m.DefaultDescription,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +36,8 @@ func SiteToModel(s site.Site) models.Site {
|
||||
DefaultRobots: s.DefaultRobots,
|
||||
Locale: s.Locale,
|
||||
IsDefault: s.IsDefault,
|
||||
SiteName: s.SiteName,
|
||||
TitleTemplate: s.TitleTemplate,
|
||||
DefaultDescription: s.DefaultDescription,
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,23 @@ import (
|
||||
|
||||
type Article struct {
|
||||
gorm.Model
|
||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
||||
Title string
|
||||
Body string
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
SiteID uint32 `gorm:"index"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
||||
Title string
|
||||
Body string
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
SiteID uint32 `gorm:"index"`
|
||||
Slug string `gorm:"size:100;not null;default:''"`
|
||||
Excerpt string `gorm:"size:300;not null;default:''"`
|
||||
SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"`
|
||||
SEODescription string `gorm:"column:seo_description;size:320;not null;default:''"`
|
||||
CoverImageID *uint32 `gorm:"index"`
|
||||
CoverImageAlt string `gorm:"size:200;not null;default:''"`
|
||||
SocialTitle string `gorm:"size:120;not null;default:''"`
|
||||
SocialDescription string `gorm:"size:320;not null;default:''"`
|
||||
SocialImageID *uint32 `gorm:"index"`
|
||||
CanonicalURL string `gorm:"column:canonical_url;size:2048;not null;default:''"`
|
||||
Noindex bool `gorm:"not null;default:false"`
|
||||
SEODismissed string `gorm:"column:seo_dismissed;type:text"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -11,6 +11,9 @@ type Site struct {
|
||||
DefaultRobots string `gorm:"size:16;not null"`
|
||||
Locale *string `gorm:"size:35"`
|
||||
IsDefault bool `gorm:"not null"`
|
||||
SiteName string `gorm:"size:100;not null;default:''"`
|
||||
TitleTemplate string `gorm:"size:200"`
|
||||
DefaultDescription string `gorm:"size:320;not null;default:''"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
authordomain "RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -260,35 +261,59 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
created := entity.Article{
|
||||
Title: article.Title,
|
||||
Body: article.Body,
|
||||
IsOnline: article.IsOnline,
|
||||
SiteID: article.SiteID,
|
||||
}
|
||||
created := mappers.ArticleToModel(article)
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if article.SiteID != 0 {
|
||||
if _, err := lockSite(tx, article.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&created).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceCredits(tx, created.ID, bylineIDs(article.Authors))
|
||||
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
|
||||
}
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
|
||||
}
|
||||
|
||||
func bylineIDs(bylines []authordomain.Byline) []uint32 {
|
||||
ids := make([]uint32, 0, len(bylines))
|
||||
for _, byline := range bylines {
|
||||
ids = append(ids, byline.ID)
|
||||
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
var total int64
|
||||
err := db.Model(&entity.Article{}).Where("site_id = ? AND slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) error {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if taken {
|
||||
return domain.ErrSlugTaken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
|
||||
if errors.Is(cause, domain.ErrSlugTaken) {
|
||||
return cause
|
||||
}
|
||||
if err := ensureSlugFree(a.db, siteID, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
|
||||
return err
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
return articleSlugInUse(a.db, siteID, slug, exceptID)
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) GetAll() []domain.Article {
|
||||
@@ -333,11 +358,16 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
if err := tx.Model(&entity.Article{}).First(&localArticle, id).Error; err != nil {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
if update.Body != nil {
|
||||
localArticle.Body = *update.Body
|
||||
if update.SiteID == nil && update.Slug != nil {
|
||||
if _, err := lockSite(tx, localArticle.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if update.SiteID != nil {
|
||||
localArticle.SiteID = *update.SiteID
|
||||
applyUpdate(&localArticle, update)
|
||||
if update.Slug != nil || update.SiteID != nil {
|
||||
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Save(&localArticle).Error; err != nil {
|
||||
return err
|
||||
@@ -348,11 +378,44 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
|
||||
}
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
}
|
||||
|
||||
func applyUpdate(target *entity.Article, update domain.Update) {
|
||||
setString(&target.Body, update.Body)
|
||||
setString(&target.Slug, update.Slug)
|
||||
setString(&target.Excerpt, update.Excerpt)
|
||||
setString(&target.SEOTitle, update.SEOTitle)
|
||||
setString(&target.SEODescription, update.SEODescription)
|
||||
setString(&target.CoverImageAlt, update.CoverImageAlt)
|
||||
setString(&target.SocialTitle, update.SocialTitle)
|
||||
setString(&target.SocialDescription, update.SocialDescription)
|
||||
setString(&target.CanonicalURL, update.CanonicalURL)
|
||||
if update.SiteID != nil {
|
||||
target.SiteID = *update.SiteID
|
||||
}
|
||||
if update.Noindex != nil {
|
||||
target.Noindex = *update.Noindex
|
||||
}
|
||||
if update.CoverImageID.Set {
|
||||
target.CoverImageID = update.CoverImageID.Value
|
||||
}
|
||||
if update.SocialImageID.Set {
|
||||
target.SocialImageID = update.SocialImageID.Value
|
||||
}
|
||||
if update.SEODismissed != nil {
|
||||
target.SEODismissed = mappers.ArticleToModel(domain.Article{SEODismissed: *update.SEODismissed}).SEODismissed
|
||||
}
|
||||
}
|
||||
|
||||
func setString(target *string, value *string) {
|
||||
if value != nil {
|
||||
*target = *value
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
|
||||
var localArticle entity.Article
|
||||
err := a.db.Model(&entity.Article{}).First(&localArticle, id).Error
|
||||
@@ -371,6 +434,14 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
|
||||
|
||||
func (a *ArticleRepository) Delete(id uint32) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var found entity.Article
|
||||
if err := tx.First(&found, id).Error; err != nil {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
tombstone := domain.Tombstone(id)
|
||||
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", tombstone).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -388,4 +459,73 @@ func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Arti
|
||||
return toDomainArticles(entityArticles), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) FindByImage(imageID uint32) ([]domain.Article, error) {
|
||||
var entityArticles []entity.Article
|
||||
err := a.db.Model(&entity.Article{}).Where("cover_image_id = ? OR social_image_id = ?", imageID, imageID).Order("id asc").Find(&entityArticles).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toDomainArticles(entityArticles), nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) ClearImage(imageID uint32) (domain.ImageReferences, error) {
|
||||
references := domain.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := clearColumn(tx, "cover_image_id", imageID, &references.Cover); err != nil {
|
||||
return err
|
||||
}
|
||||
return clearColumn(tx, "social_image_id", imageID, &references.Social)
|
||||
})
|
||||
if err != nil {
|
||||
return domain.ImageReferences{}, err
|
||||
}
|
||||
return references, nil
|
||||
}
|
||||
|
||||
func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32) error {
|
||||
if err := tx.Model(&entity.Article{}).Where(column+" = ?", imageID).Order("id ASC").Pluck("id", cleared).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(*cleared) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := assignColumn(tx, "cover_image_id", references.Cover, imageID); err != nil {
|
||||
return err
|
||||
}
|
||||
return assignColumn(tx, "social_image_id", references.Social, imageID)
|
||||
})
|
||||
}
|
||||
|
||||
func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint32) error {
|
||||
if len(articleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
|
||||
changed := []uint32{}
|
||||
if len(fromImageIDs) == 0 {
|
||||
return changed, nil
|
||||
}
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
var _ domain.Repository = &ArticleRepository{}
|
||||
@@ -319,6 +319,14 @@ func (a *AuthorRepository) AvatarUsage() (map[uint32]int, error) {
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) FindByAvatar(imageID uint32) ([]domain.Author, error) {
|
||||
var found []entity.Author
|
||||
if err := a.db.Where("avatar_id = ?", imageID).Order("id ASC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.toDomain(a.db, found)
|
||||
}
|
||||
|
||||
func (a *AuthorRepository) ClearAvatar(imageID uint32) ([]uint32, error) {
|
||||
ids := []uint32{}
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
@@ -106,11 +106,18 @@ func (s *SiteRepository) Create(site domain.Site) (domain.Site, error) {
|
||||
return tx.Create(&model).Error
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Site{}, err
|
||||
return domain.Site{}, s.explainKeyFailure(model.Key, err)
|
||||
}
|
||||
return mappers.SiteToDomain(model, 0), nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) explainKeyFailure(key string, cause error) error {
|
||||
if _, err := s.GetByKey(key); err == nil {
|
||||
return domain.ErrSiteKeyTaken
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
func lockAllSites(tx *gorm.DB) error {
|
||||
var locked []entity.Site
|
||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Order("id ASC").Find(&locked).Error
|
||||
@@ -135,6 +142,9 @@ func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
|
||||
"default_social_image_id": site.DefaultSocialImageID,
|
||||
"default_robots": site.DefaultRobots,
|
||||
"locale": site.Locale,
|
||||
"site_name": site.SiteName,
|
||||
"title_template": site.TitleTemplate,
|
||||
"default_description": site.DefaultDescription,
|
||||
}
|
||||
if _, err := s.Get(site.ID); err != nil {
|
||||
return domain.Site{}, err
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user