refac: article reads, story delete, no leaks, stored SEO status, rate limit, cleanup (%site%, article repo split, old lang repos, one default per site rule to domain)
This commit is contained in:
1 parent
9d284e65a9
commit
7c15206170
85 files changed
+1788
-593
No files matched your search
@@ -140,6 +140,8 @@ type Article struct {
|
||||
EditRevision uint32 `json:"-"`
|
||||
SourceArticleID *uint32 `json:"-"`
|
||||
SourceRevision uint32 `json:"-"`
|
||||
SEO SEOSummary `json:"-"`
|
||||
SEOVersion uint32 `json:"-"`
|
||||
}
|
||||
|
||||
func (a Article) UsesImage(imageID uint32) bool {
|
||||
|
||||
@@ -78,12 +78,14 @@ func (e *SlugConflictError) Is(target error) bool {
|
||||
}
|
||||
|
||||
type Translation struct {
|
||||
ID uint32 `json:"id"`
|
||||
Lang string `json:"lang"`
|
||||
Title string `json:"title"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
LanguageEnabled bool `json:"language_enabled"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
ID uint32 `json:"id"`
|
||||
Lang string `json:"lang"`
|
||||
Title string `json:"title"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
LanguageEnabled bool `json:"language_enabled"`
|
||||
ExcludedFromPublic bool `json:"excluded_from_public"`
|
||||
SourceArticleID *uint32 `json:"source_article_id"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package article
|
||||
|
||||
import "math"
|
||||
|
||||
const (
|
||||
StatusOnline = "online"
|
||||
StatusDraft = "draft"
|
||||
@@ -37,13 +39,23 @@ type Filters struct {
|
||||
Size int
|
||||
Q string
|
||||
Status string
|
||||
Lang *string
|
||||
SEOStatus string
|
||||
Lang string
|
||||
Langs *[]string
|
||||
AuthorID *uint32
|
||||
Sort Sort
|
||||
Unpaged bool
|
||||
}
|
||||
|
||||
func (f Filters) Window() (int, int) {
|
||||
size := max(f.Size, 0)
|
||||
skipped := max(f.Page, 1) - 1
|
||||
if size > 0 && skipped > math.MaxInt/size {
|
||||
return math.MaxInt, size
|
||||
}
|
||||
return skipped * size, size
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
Set bool
|
||||
Value *T
|
||||
@@ -97,10 +109,7 @@ type Repository interface {
|
||||
CountByVisibility(onlineOnly bool) (Counts, error)
|
||||
CountVisibleTo(userID uint32) (Counts, error)
|
||||
CountVersions(filters Filters) (Counts, error)
|
||||
FindStoryPage(filters StoryFilters) (StoryPage, error)
|
||||
CountStories(filters StoryFilters) (StoryCounts, error)
|
||||
ListByStories(storyIDs []uint32, readable StoryFilters) ([]Article, error)
|
||||
FindTitleMatches(q string, readable StoryFilters) ([]uint32, error)
|
||||
CountSuggestions(filters Filters) (int, error)
|
||||
Get(id uint32) (Article, error)
|
||||
GetByName(name string) (Article, error)
|
||||
GetByLangSlug(siteID uint32, lang string, slug string) (Article, error)
|
||||
@@ -120,4 +129,26 @@ type Repository interface {
|
||||
AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
|
||||
ListByStory(storyID uint32) ([]Article, error)
|
||||
MoveToSite(ids []uint32, siteID uint32) error
|
||||
DeleteStory(storyID uint32) error
|
||||
}
|
||||
|
||||
type StoryReader interface {
|
||||
FindStoryPage(filters StoryFilters) (StoryPage, error)
|
||||
CountStories(filters StoryFilters) (StoryCounts, error)
|
||||
CountSuggestedStories(readable StoryFilters) (int, error)
|
||||
ListByStories(storyIDs []uint32, readable StoryFilters) ([]Article, error)
|
||||
ListVersions(ids []uint32) ([]Article, error)
|
||||
FindTitleMatches(q string, readable StoryFilters) ([]uint32, error)
|
||||
}
|
||||
|
||||
type SEOIndex interface {
|
||||
StaleSEO(afterID uint32, limit int) ([]Article, error)
|
||||
StoreSEO(records []SEORecord) error
|
||||
MarkSiteSEOStale(siteID uint32) error
|
||||
}
|
||||
|
||||
type ListingRepository interface {
|
||||
Repository
|
||||
StoryReader
|
||||
SEOIndex
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type StoryFilters struct {
|
||||
Lang string
|
||||
Missing bool
|
||||
Status string
|
||||
SEOStatus string
|
||||
AuthorID *uint32
|
||||
VersionIDs *[]uint32
|
||||
Sort Sort
|
||||
|
||||
@@ -37,6 +37,16 @@ type SEOSummary struct {
|
||||
Suggestions int
|
||||
}
|
||||
|
||||
func (s SEOSummary) Stale() bool {
|
||||
return s.Status == ""
|
||||
}
|
||||
|
||||
type SEORecord struct {
|
||||
ArticleID uint32
|
||||
Version uint32
|
||||
Summary SEOSummary
|
||||
}
|
||||
|
||||
func (a SEOAssessment) Summary() SEOSummary {
|
||||
return SEOSummary{Status: a.Status, Suggestions: len(a.Suggestions)}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
var (
|
||||
ErrInvalidLanguage = errors.New("invalid language tag")
|
||||
ErrUnsupportedLanguage = errors.New("unsupported language")
|
||||
ErrNoDefaultLanguage = errors.New("the site has no default language")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -64,27 +63,3 @@ func Suggest(locale *string) Suggestion {
|
||||
}
|
||||
return Suggestion{}
|
||||
}
|
||||
|
||||
type SiteLanguage struct {
|
||||
SiteID uint32
|
||||
Tag string
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
func DefaultOf(languages []SiteLanguage) (SiteLanguage, bool) {
|
||||
index := slices.IndexFunc(languages, func(candidate SiteLanguage) bool { return candidate.IsDefault })
|
||||
if index < 0 {
|
||||
return SiteLanguage{}, false
|
||||
}
|
||||
return languages[index], true
|
||||
}
|
||||
|
||||
func StartingTag(languages []SiteLanguage) string {
|
||||
defaultLanguage, found := DefaultOf(languages)
|
||||
if !found || !defaultLanguage.Confirmed {
|
||||
return Unassigned
|
||||
}
|
||||
return defaultLanguage.Tag
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package language
|
||||
|
||||
type Repository interface {
|
||||
ListBySite(siteID uint32) ([]SiteLanguage, error)
|
||||
LockSiteLanguages(siteID uint32) ([]SiteLanguage, error)
|
||||
MarkConfirmed(siteID uint32, tag string) error
|
||||
}
|
||||
@@ -13,6 +13,7 @@ var (
|
||||
ErrSetupInProgress = errors.New("another language setup is running")
|
||||
ErrSetupIncomplete = errors.New("every pending site needs a language")
|
||||
ErrDuplicateSite = errors.New("the same site is listed twice")
|
||||
ErrSingleDefault = errors.New("a site has exactly one default language")
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -128,6 +128,34 @@ func MissingTags(rows []Row, tags []string) []string {
|
||||
return missing
|
||||
}
|
||||
|
||||
func ConfirmedDefaultTag(rows []Row) string {
|
||||
if NeedsSetup(rows) {
|
||||
return language.Unassigned
|
||||
}
|
||||
return DefaultTag(rows)
|
||||
}
|
||||
|
||||
func SiteTextAllowed(defaultTag string, tag string) bool {
|
||||
return tag == language.Unassigned || tag == defaultTag
|
||||
}
|
||||
|
||||
func PublicTag(rows []Row, tag string) bool {
|
||||
return tag != language.Unassigned && EnabledTag(rows, tag)
|
||||
}
|
||||
|
||||
func RequireSingleDefault(rows []Row) error {
|
||||
defaults := 0
|
||||
for _, row := range rows {
|
||||
if row.IsDefault {
|
||||
defaults++
|
||||
}
|
||||
}
|
||||
if defaults != 1 {
|
||||
return ErrSingleDefault
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DefaultTag(rows []Row) string {
|
||||
for _, row := range rows {
|
||||
if row.IsDefault {
|
||||
|
||||
@@ -46,7 +46,8 @@ const (
|
||||
MaxKeyRunes = 32
|
||||
|
||||
TitlePlaceholder = "%title%"
|
||||
DefaultTitleTemplate = "%title% | %site%"
|
||||
SitePlaceholder = "%site%"
|
||||
DefaultTitleTemplate = TitlePlaceholder + " | " + SitePlaceholder
|
||||
)
|
||||
|
||||
var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
@@ -20,8 +19,8 @@ type Repositories struct {
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Languages language.Repository
|
||||
Localization localization.Repository
|
||||
ArticleSEO article.SEOIndex
|
||||
}
|
||||
|
||||
type Transactor interface {
|
||||
|
||||
@@ -58,6 +58,8 @@ func ArticleToDomain(m models.Article) article.Article {
|
||||
EditRevision: m.EditRevision,
|
||||
SourceArticleID: m.SourceArticleID,
|
||||
SourceRevision: m.SourceRevision,
|
||||
SEO: article.SEOSummary{Status: m.SEOStatus, Suggestions: m.SEOSuggestions},
|
||||
SEOVersion: m.SEOVersion,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func SiteLanguageToDomain(m models.SiteLanguage) language.SiteLanguage {
|
||||
return language.SiteLanguage{
|
||||
SiteID: m.SiteID,
|
||||
Tag: m.Lang,
|
||||
IsDefault: m.IsDefault,
|
||||
Enabled: m.Enabled,
|
||||
Confirmed: m.Confirmed,
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,7 @@ type Article struct {
|
||||
SourceArticleID *uint32
|
||||
SourceRevision uint32 `gorm:"not null;default:0"`
|
||||
RetiredID uint32 `gorm:"column:retired_id;not null;default:0"`
|
||||
SEOStatus string `gorm:"column:seo_status;size:16;not null;default:'';index"`
|
||||
SEOSuggestions int `gorm:"column:seo_suggestions;not null;default:0"`
|
||||
SEOVersion uint32 `gorm:"column:seo_version;not null;default:0"`
|
||||
}
|
||||
@@ -61,8 +61,11 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
|
||||
case domain.StatusDraft:
|
||||
query = query.Where("is_online = ?", false)
|
||||
}
|
||||
if filters.Lang != nil {
|
||||
query = query.Where("lang = ?", *filters.Lang)
|
||||
if filters.Lang != "" {
|
||||
query = query.Where("lang = ?", filters.Lang)
|
||||
}
|
||||
if filters.SEOStatus != "" {
|
||||
query = query.Where("seo_status = ?", filters.SEOStatus)
|
||||
}
|
||||
if filters.Langs != nil {
|
||||
query = restrictToLangs(query, *filters.Langs)
|
||||
@@ -119,7 +122,8 @@ func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult,
|
||||
var entityArticles []entity.Article
|
||||
ordered := a.scoped(filters).Order(orderClause(filters.Sort))
|
||||
if !filters.Unpaged {
|
||||
ordered = ordered.Limit(filters.Size).Offset((filters.Page - 1) * filters.Size)
|
||||
offset, limit := filters.Window()
|
||||
ordered = ordered.Limit(limit).Offset(offset)
|
||||
}
|
||||
err := ordered.Find(&entityArticles).Error
|
||||
if err != nil {
|
||||
@@ -146,9 +150,9 @@ func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.Pa
|
||||
}
|
||||
pageIds := matchingIds
|
||||
if !filters.Unpaged {
|
||||
start := min((filters.Page-1)*filters.Size, len(matchingIds))
|
||||
end := min(start+filters.Size, len(matchingIds))
|
||||
pageIds = matchingIds[start:end]
|
||||
offset, limit := filters.Window()
|
||||
start := min(offset, len(matchingIds))
|
||||
pageIds = matchingIds[start : start+min(limit, len(matchingIds)-start)]
|
||||
}
|
||||
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
|
||||
if len(pageIds) == 0 {
|
||||
@@ -220,6 +224,13 @@ func (a *ArticleRepository) CountVersions(filters domain.Filters) (domain.Counts
|
||||
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) CountSuggestions(filters domain.Filters) (int, error) {
|
||||
visible := domain.Filters{IsOnline: filters.IsOnline, VisibleTo: filters.VisibleTo, SEOStatus: domain.SEOStatusSuggestions}
|
||||
var total int64
|
||||
err := a.scoped(visible).Count(&total).Error
|
||||
return int(total), err
|
||||
}
|
||||
|
||||
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
|
||||
domainArticles := make([]domain.Article, 0, len(entityArticles))
|
||||
for _, entityArticle := range entityArticles {
|
||||
@@ -463,7 +474,7 @@ func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
|
||||
return a.updateColumns(id, map[string]any{"body": body})
|
||||
return a.updateColumns(id, withStaleSEO(map[string]any{"body": body}))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
|
||||
@@ -603,6 +614,8 @@ func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision u
|
||||
"edit_revision": written.EditRevision,
|
||||
"content_revision": written.ContentRevision,
|
||||
"updated_at": written.UpdatedAt,
|
||||
seoStatusColumn: "",
|
||||
seoVersionColumn: gorm.Expr(seoVersionColumn + " + 1"),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -668,20 +681,42 @@ func (a *ArticleRepository) Delete(id uint32) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id}
|
||||
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
|
||||
if err := retireVersion(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return closeEmptyStory(tx, found.StoryID)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) DeleteStory(storyID uint32) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var ids []uint32
|
||||
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return domain.ErrArticleNotFound
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := retireVersion(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return deleteOrNotFound(tx.Delete(&entity.Story{}, storyID), domain.ErrArticleNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func retireVersion(tx *gorm.DB, id uint32) error {
|
||||
retired := map[string]any{"slug": domain.Tombstone(id), "retired_id": id}
|
||||
if err := tx.Model(&entity.Article{}).Where("id = ?", id).UpdateColumns(retired).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteOrNotFound(tx.Delete(&entity.Article{}, id), domain.ErrArticleNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("article_id = ?", id).Delete(&entity.ArticleAuthor{}).Error
|
||||
}
|
||||
|
||||
func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
|
||||
if storyID == 0 {
|
||||
return nil
|
||||
@@ -709,7 +744,7 @@ func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
|
||||
if err := tx.First(&moving, id).Error; err != nil {
|
||||
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
||||
}
|
||||
moved := map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")}
|
||||
moved := withStaleSEO(map[string]any{"site_id": siteID, "edit_revision": gorm.Expr("edit_revision + 1")})
|
||||
if err := tx.Model(&entity.Article{}).Where("id = ?", id).Updates(moved).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -763,7 +798,7 @@ func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32)
|
||||
if len(*cleared) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
|
||||
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumns(withStaleSEO(map[string]any{column: nil})).Error
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
|
||||
@@ -779,7 +814,7 @@ func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint3
|
||||
if len(articleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
|
||||
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumns(withStaleSEO(map[string]any{column: imageID})).Error
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
|
||||
@@ -791,10 +826,10 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
|
||||
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 {
|
||||
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumns(withStaleSEO(map[string]any{"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
|
||||
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumns(withStaleSEO(map[string]any{"social_image_id": toImageID})).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -805,7 +840,7 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
|
||||
var _ domain.Repository = &ArticleRepository{}
|
||||
|
||||
func (a *ArticleRepository) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
|
||||
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumn("lang", lang)
|
||||
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumns(withStaleSEO(map[string]any{"lang": lang}))
|
||||
return int(result.RowsAffected), result.Error
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
seoStatusColumn = "seo_status"
|
||||
seoSuggestionsColumn = "seo_suggestions"
|
||||
seoVersionColumn = "seo_version"
|
||||
)
|
||||
|
||||
func withStaleSEO(columns map[string]any) map[string]any {
|
||||
columns[seoStatusColumn] = ""
|
||||
columns[seoVersionColumn] = gorm.Expr(seoVersionColumn + " + 1")
|
||||
return columns
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) StaleSEO(afterID uint32, limit int) ([]domain.Article, error) {
|
||||
var found []entity.Article
|
||||
err := a.db.Model(&entity.Article{}).Where(seoStatusColumn+" = ? AND id > ?", "", afterID).Order("id ASC").Limit(limit).Find(&found).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.withAuthors(a.db, toDomainArticles(found))
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) StoreSEO(records []domain.SEORecord) error {
|
||||
return a.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, record := range records {
|
||||
stored := map[string]any{seoStatusColumn: record.Summary.Status, seoSuggestionsColumn: record.Summary.Suggestions}
|
||||
if err := tx.Model(&entity.Article{}).Where("id = ? AND "+seoVersionColumn+" = ?", record.ArticleID, record.Version).UpdateColumns(stored).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) MarkSiteSEOStale(siteID uint32) error {
|
||||
return a.db.Model(&entity.Article{}).Where("site_id = ?", siteID).UpdateColumns(withStaleSEO(map[string]any{})).Error
|
||||
}
|
||||
|
||||
var _ domain.SEOIndex = &ArticleRepository{}
|
||||
@@ -2,6 +2,7 @@ package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/language"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"strings"
|
||||
)
|
||||
@@ -127,16 +128,26 @@ func qualifyingVersions(filters domain.StoryFilters) storySQL {
|
||||
case domain.StatusDraft:
|
||||
condition.add("q.is_online = ?", false)
|
||||
}
|
||||
if filters.SEOStatus != "" {
|
||||
condition.add("q.seo_status = ?", filters.SEOStatus)
|
||||
}
|
||||
if filters.AuthorID != nil {
|
||||
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
|
||||
}
|
||||
if filters.Missing {
|
||||
condition.merge(lacksReadableVersionIn("?", filters, ""))
|
||||
condition.merge(holdsReadableLanguage(filters))
|
||||
condition.merge(missingCondition(filters))
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func holdsReadableLanguage(filters domain.StoryFilters) storySQL {
|
||||
readable := readableCondition("l", filters)
|
||||
condition := storySQL{}
|
||||
condition.add("EXISTS (SELECT 1 FROM articles l WHERE l.story_id = q.story_id AND "+readable.where()+" AND l.lang <> ?)", append(readable.args, language.Unassigned)...)
|
||||
return condition
|
||||
}
|
||||
|
||||
func missingCondition(filters domain.StoryFilters) storySQL {
|
||||
condition := storySQL{}
|
||||
if filters.Lang != "" {
|
||||
@@ -310,6 +321,31 @@ func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.St
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) CountSuggestedStories(readable domain.StoryFilters) (int, error) {
|
||||
condition := readableCondition("q", readable)
|
||||
condition.add("q.seo_status = ?", domain.SEOStatusSuggestions)
|
||||
var stories, standalone int64
|
||||
if err := a.db.Raw("SELECT COUNT(DISTINCT q.story_id) FROM articles q WHERE "+condition.where()+" AND q.story_id <> ?", append(condition.args, 0)...).Scan(&stories).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err := a.db.Raw("SELECT COUNT(*) FROM articles q WHERE "+condition.where()+" AND q.story_id = ?", append(condition.args, 0)...).Scan(&standalone).Error
|
||||
return int(stories + standalone), err
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) ListVersions(ids []uint32) ([]domain.Article, error) {
|
||||
var found []entity.Article
|
||||
for _, chunk := range chunkIDs(ids) {
|
||||
var loaded []entity.Article
|
||||
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, loaded...)
|
||||
}
|
||||
return a.withAuthors(a.db, toDomainArticles(found))
|
||||
}
|
||||
|
||||
var _ domain.StoryReader = &ArticleRepository{}
|
||||
|
||||
func (a *ArticleRepository) ListByStories(storyIDs []uint32, filters domain.StoryFilters) ([]domain.Article, error) {
|
||||
var found []entity.Article
|
||||
for _, chunk := range chunkIDs(storyIDs) {
|
||||
|
||||
@@ -282,6 +282,10 @@ func hasCredits(tx *gorm.DB, id uint32) (bool, error) {
|
||||
}
|
||||
|
||||
func removeAuthor(tx *gorm.DB, id uint32) error {
|
||||
credited := tx.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", id)
|
||||
if err := tx.Model(&entity.Article{}).Where("id IN (?)", credited).UpdateColumns(withStaleSEO(map[string]any{})).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("author_id = ?", id).Delete(&entity.ArticleAuthor{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -105,9 +105,6 @@ func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := l.clearOtherDefaults(siteID, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := l.save(siteID, row, known); err != nil {
|
||||
return err
|
||||
@@ -116,15 +113,6 @@ func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalizationRepository) clearOtherDefaults(siteID uint32, rows []domain.Row) error {
|
||||
for _, row := range rows {
|
||||
if row.IsDefault {
|
||||
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND is_default = ? AND lang <> ?", siteID, true, row.Tag).UpdateColumn("is_default", false).Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalizationRepository) save(siteID uint32, row domain.Row, known map[string]domain.Row) error {
|
||||
before, exists := known[row.Tag]
|
||||
if !exists {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SiteLanguageRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteLanguageRepository(db *gorm.DB) *SiteLanguageRepository {
|
||||
return &SiteLanguageRepository{db}
|
||||
}
|
||||
|
||||
func (s *SiteLanguageRepository) ListBySite(siteID uint32) ([]domain.SiteLanguage, error) {
|
||||
var found []entity.SiteLanguage
|
||||
if err := s.db.Where("site_id = ?", siteID).Order("is_default DESC, lang ASC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
languages := make([]domain.SiteLanguage, 0, len(found))
|
||||
for _, row := range found {
|
||||
languages = append(languages, mappers.SiteLanguageToDomain(row))
|
||||
}
|
||||
return languages, nil
|
||||
}
|
||||
|
||||
func (s *SiteLanguageRepository) LockSiteLanguages(siteID uint32) ([]domain.SiteLanguage, error) {
|
||||
if _, err := lockSite(s.db, siteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListBySite(siteID)
|
||||
}
|
||||
|
||||
func (s *SiteLanguageRepository) MarkConfirmed(siteID uint32, tag string) error {
|
||||
return s.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", siteID, tag).UpdateColumn("confirmed", true).Error
|
||||
}
|
||||
|
||||
var _ domain.Repository = &SiteLanguageRepository{}
|
||||
@@ -258,7 +258,7 @@ func moveArticles(tx *gorm.DB, from uint32, to uint32) error {
|
||||
if _, err := lockSite(tx, to); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumn("site_id", to).Error
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ?", from).UpdateColumns(withStaleSEO(map[string]any{"site_id": to})).Error
|
||||
}
|
||||
|
||||
func (s *SiteRepository) ClearImage(imageID uint32) ([]uint32, error) {
|
||||
|
||||
@@ -21,14 +21,15 @@ func NewTransactor(db *gorm.DB) *Transactor {
|
||||
|
||||
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
|
||||
err := t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
articles := NewArticleRepository(tx)
|
||||
return work(domain.Repositories{
|
||||
Articles: NewArticleRepository(tx),
|
||||
Articles: articles,
|
||||
Sites: NewSiteRepository(tx),
|
||||
Authors: NewAuthorRepository(tx),
|
||||
Entries: NewEntryRepository(tx),
|
||||
Images: NewImageRepository(tx),
|
||||
Languages: NewSiteLanguageRepository(tx),
|
||||
Localization: NewLocalizationRepository(tx),
|
||||
ArticleSEO: articles,
|
||||
})
|
||||
})
|
||||
if database.IsLockContention(t.db, err) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
@@ -12,6 +13,7 @@ type CheckArticleSEOUseCase struct {
|
||||
articleRepository article.Repository
|
||||
sites site.Repository
|
||||
images image.Repository
|
||||
localization localization.Repository
|
||||
}
|
||||
|
||||
type CheckArticleSEOCommand struct {
|
||||
@@ -30,8 +32,8 @@ type CheckArticleSEOCommand struct {
|
||||
SEODismissed *[]string
|
||||
}
|
||||
|
||||
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository) *CheckArticleSEOUseCase {
|
||||
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images}
|
||||
func NewCheckArticleSEOUseCase(articleRepository article.Repository, sites site.Repository, images image.Repository, localizationRepository localization.Repository) *CheckArticleSEOUseCase {
|
||||
return &CheckArticleSEOUseCase{articleRepository: articleRepository, sites: sites, images: images, localization: localizationRepository}
|
||||
}
|
||||
|
||||
func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command CheckArticleSEOCommand) (article.SEOCheck, error) {
|
||||
@@ -57,7 +59,11 @@ func (u *CheckArticleSEOUseCase) Check(id uint32, caller ContentCaller, command
|
||||
if err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
assessment := assessSEO(candidate, owner)
|
||||
rows, err := u.localization.Rows(saved.SiteID)
|
||||
if err != nil {
|
||||
return article.SEOCheck{}, err
|
||||
}
|
||||
assessment := assessSEO(candidate, owner, localization.SiteTextAllowed(localization.ConfirmedDefaultTag(rows), candidate.Lang))
|
||||
return article.SEOCheck{Status: assessment.Status, Suggestions: assessment.Suggestions, Slug: slugCheck}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
)
|
||||
|
||||
type ConfirmDefaultLanguageUseCase struct {
|
||||
transactor transaction.Transactor
|
||||
}
|
||||
|
||||
func NewConfirmDefaultLanguageUseCase(transactor transaction.Transactor) *ConfirmDefaultLanguageUseCase {
|
||||
return &ConfirmDefaultLanguageUseCase{transactor}
|
||||
}
|
||||
|
||||
func (u *ConfirmDefaultLanguageUseCase) Confirm(ctx context.Context, siteID uint32) error {
|
||||
return u.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
languages, err := tx.Languages.LockSiteLanguages(siteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultLanguage, found := language.DefaultOf(languages)
|
||||
if !found {
|
||||
return language.ErrNoDefaultLanguage
|
||||
}
|
||||
if defaultLanguage.Confirmed {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Languages.MarkConfirmed(siteID, defaultLanguage.Tag); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Articles.AssignUnassignedLanguage(siteID, defaultLanguage.Tag)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -124,21 +124,20 @@ func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, requested
|
||||
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requested string, draft article.Article) (article.Article, error) {
|
||||
var created article.Article
|
||||
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
languages, err := tx.Languages.LockSiteLanguages(draft.SiteID)
|
||||
err := tx.Sites.LockSite(draft.SiteID)
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
return article.ErrTargetSiteDeleted
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lang := language.StartingTag(languages)
|
||||
if requested != language.Unassigned {
|
||||
lang, err = chooseLanguage(tx.Localization, draft.SiteID, requested)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Localization.Rows(draft.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if draft.Lang, err = chooseLanguage(rows, requested); err != nil {
|
||||
return err
|
||||
}
|
||||
draft.Lang = lang
|
||||
draft.Slug, err = freeSlug(tx.Articles, draft.SiteID, draft.Lang, article.GenerateSlug(draft.Title), 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -149,10 +148,9 @@ func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, requeste
|
||||
return created, err
|
||||
}
|
||||
|
||||
func chooseLanguage(repository localization.Repository, siteID uint32, requested string) (string, error) {
|
||||
rows, err := repository.Rows(siteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func chooseLanguage(rows []localization.Row, requested string) (string, error) {
|
||||
if requested == language.Unassigned {
|
||||
return localization.ConfirmedDefaultTag(rows), nil
|
||||
}
|
||||
if localization.NeedsSetup(rows) {
|
||||
return "", localization.ErrSetupRequired
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type DeleteStoryUseCase struct {
|
||||
transactor transaction.Transactor
|
||||
articles article.Repository
|
||||
recorder audit.Recorder
|
||||
}
|
||||
|
||||
type DeleteStoryCommand struct {
|
||||
StoryID uint32
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
func NewDeleteStoryUseCase(transactor transaction.Transactor, articles article.Repository, recorder audit.Recorder) *DeleteStoryUseCase {
|
||||
return &DeleteStoryUseCase{transactor: transactor, articles: articles, recorder: recorder}
|
||||
}
|
||||
|
||||
func (u *DeleteStoryUseCase) DeleteStory(ctx context.Context, command DeleteStoryCommand) error {
|
||||
if err := requireDeleteOnEveryVersion(command.Caller); err != nil {
|
||||
return err
|
||||
}
|
||||
var deleted []article.Article
|
||||
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
versions, err := lockStoryVersions(tx, command.StoryID, command.Caller)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleted = sortByCreation(versions)
|
||||
return tx.Articles.DeleteStory(command.StoryID)
|
||||
})
|
||||
if err != nil {
|
||||
u.recordRefusal(ctx, command, err)
|
||||
return err
|
||||
}
|
||||
for _, version := range deleted {
|
||||
event := articleEvent(audit.ActionArticleDelete, version)
|
||||
event.Params[audit.ParamStoryID] = command.StoryID
|
||||
u.recorder.Record(ctx, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireDeleteOnEveryVersion(caller ContentCaller) error {
|
||||
if caller.Grants.ScopeFor(role.KeyContentDelete, builtintype.KeyArticle) != role.ScopeAll {
|
||||
return role.MissingPermissionError{Key: role.KeyContentDelete}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lockStoryVersions(tx transaction.Repositories, storyID uint32, caller ContentCaller) ([]article.Article, error) {
|
||||
locked := []uint32{}
|
||||
for attempt := 0; attempt < maxSiteLockRounds; attempt++ {
|
||||
versions, err := tx.Articles.ListByStory(storyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(caller.readableVersions(versions)) == 0 {
|
||||
return nil, article.ErrArticleNotFound
|
||||
}
|
||||
needed := siteIDsOf(versions)
|
||||
if allLocked(locked, needed) {
|
||||
return versions, nil
|
||||
}
|
||||
if err := lockSites(tx, needed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
locked = needed
|
||||
}
|
||||
return nil, transaction.ErrLockContention
|
||||
}
|
||||
|
||||
func (u *DeleteStoryUseCase) recordRefusal(ctx context.Context, command DeleteStoryCommand, cause error) {
|
||||
if isNotFound(cause) || errors.Is(cause, role.ErrMissingPermission) {
|
||||
return
|
||||
}
|
||||
versions, err := u.articles.ListByStory(command.StoryID)
|
||||
readable := sortByCreation(command.Caller.readableVersions(versions))
|
||||
if err != nil || len(readable) == 0 {
|
||||
return
|
||||
}
|
||||
recordFailure(ctx, u.recorder, articleEvent(audit.ActionArticleDelete, readable[0]), cause)
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"errors"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type DescribeArticleUseCase struct {
|
||||
@@ -13,9 +16,13 @@ type DescribeArticleUseCase struct {
|
||||
}
|
||||
|
||||
type ArticleContext struct {
|
||||
Capabilities []string
|
||||
Translations []article.Translation
|
||||
Source *article.Source
|
||||
Capabilities []string
|
||||
Translations []article.Translation
|
||||
Source *article.Source
|
||||
SourceUnavailable bool
|
||||
MissingLangs []string
|
||||
ExcludedFromPublic bool
|
||||
PartialView bool
|
||||
}
|
||||
|
||||
func NewDescribeArticleUseCase(articles article.Repository, localizationRepository localization.Repository) *DescribeArticleUseCase {
|
||||
@@ -23,73 +30,75 @@ func NewDescribeArticleUseCase(articles article.Repository, localizationReposito
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) Describe(art article.Article, caller ContentCaller) (ArticleContext, error) {
|
||||
described := ArticleContext{Capabilities: caller.capabilitiesFor(art), Translations: []article.Translation{}}
|
||||
rows, err := d.localization.Rows(art.SiteID)
|
||||
if err != nil {
|
||||
return ArticleContext{}, err
|
||||
}
|
||||
described := ArticleContext{
|
||||
Capabilities: caller.capabilitiesFor(art),
|
||||
Translations: []article.Translation{},
|
||||
MissingLangs: []string{},
|
||||
ExcludedFromPublic: !localization.PublicTag(rows, art.Lang),
|
||||
PartialView: caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) != role.ScopeAll,
|
||||
}
|
||||
if caller.isAnonymous() {
|
||||
return described, nil
|
||||
}
|
||||
translations, err := d.translations(art, caller)
|
||||
readable, err := d.readableVersions(art, caller)
|
||||
if err != nil {
|
||||
return ArticleContext{}, err
|
||||
}
|
||||
described.Translations = translations
|
||||
described.Source, err = d.source(art, caller)
|
||||
if err != nil {
|
||||
return ArticleContext{}, err
|
||||
}
|
||||
return described, nil
|
||||
described.Translations = translationsOf(caller, sortByLanguage(readable, rows), rows)
|
||||
described.MissingLangs = missingLanguages(readable, rows, caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle))
|
||||
described.Source, described.SourceUnavailable, err = d.source(art, caller)
|
||||
return described, err
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) translations(art article.Article, caller ContentCaller) ([]article.Translation, error) {
|
||||
versions := []article.Article{art}
|
||||
if art.StoryID != 0 {
|
||||
found, err := d.articles.ListByStory(art.StoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
versions = found
|
||||
}
|
||||
rows, err := d.rows(art.SiteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
translations := []article.Translation{}
|
||||
for _, version := range sortByLanguage(caller.readableVersions(versions), rows) {
|
||||
translations = append(translations, article.Translation{
|
||||
ID: version.ID,
|
||||
Lang: version.Lang,
|
||||
Title: version.Title,
|
||||
IsOnline: version.IsOnline,
|
||||
LanguageEnabled: d.languageEnabled(rows, version.Lang),
|
||||
Capabilities: caller.capabilitiesFor(version),
|
||||
})
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) rows(siteID uint32) ([]localization.Row, error) {
|
||||
return d.localization.Rows(siteID)
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) languageEnabled(rows []localization.Row, tag string) bool {
|
||||
if tag == language.Unassigned {
|
||||
return true
|
||||
}
|
||||
return localization.EnabledTag(rows, tag)
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, error) {
|
||||
func (d *DescribeArticleUseCase) source(art article.Article, caller ContentCaller) (*article.Source, bool, error) {
|
||||
if art.SourceArticleID == nil {
|
||||
return nil, nil
|
||||
return nil, false, nil
|
||||
}
|
||||
origin, err := d.articles.Get(*art.SourceArticleID)
|
||||
if errors.Is(err, article.ErrArticleNotFound) {
|
||||
return nil, nil
|
||||
if errors.Is(err, article.ErrArticleNotFound) || err == nil && !caller.seesArticle(origin) {
|
||||
return nil, true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, false, nil
|
||||
}
|
||||
|
||||
func (d *DescribeArticleUseCase) readableVersions(art article.Article, caller ContentCaller) ([]article.Article, error) {
|
||||
if art.StoryID == 0 {
|
||||
return []article.Article{art}, nil
|
||||
}
|
||||
versions, err := d.articles.ListByStory(art.StoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !caller.seesArticle(origin) {
|
||||
return nil, nil
|
||||
return caller.readableVersions(versions), nil
|
||||
}
|
||||
|
||||
func translationsOf(caller ContentCaller, ordered []article.Article, rows []localization.Row) []article.Translation {
|
||||
translations := make([]article.Translation, 0, len(ordered))
|
||||
for _, version := range ordered {
|
||||
translations = append(translations, article.Translation{
|
||||
ID: version.ID,
|
||||
Lang: version.Lang,
|
||||
Title: version.Title,
|
||||
IsOnline: version.IsOnline,
|
||||
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(rows, version.Lang),
|
||||
ExcludedFromPublic: !localization.PublicTag(rows, version.Lang),
|
||||
SourceArticleID: trackedSourceID(version, ordered),
|
||||
Capabilities: caller.capabilitiesFor(version),
|
||||
})
|
||||
}
|
||||
return &article.Source{ArticleID: origin.ID, Revision: art.SourceRevision}, nil
|
||||
return translations
|
||||
}
|
||||
|
||||
func trackedSourceID(version article.Article, readable []article.Article) *uint32 {
|
||||
if version.SourceArticleID == nil || !slices.ContainsFunc(readable, func(candidate article.Article) bool { return candidate.ID == *version.SourceArticleID }) {
|
||||
return nil
|
||||
}
|
||||
return version.SourceArticleID
|
||||
}
|
||||
@@ -28,13 +28,14 @@ type ListStoriesCommand struct {
|
||||
}
|
||||
|
||||
type StoryRow struct {
|
||||
Article article.Article
|
||||
SEO article.SEOSummary
|
||||
UpdatedAt time.Time
|
||||
Authors []author.Byline
|
||||
Coverage []article.Translation
|
||||
MissingLangs []string
|
||||
Capabilities []string
|
||||
Article article.Article
|
||||
SEO article.SEOSummary
|
||||
UpdatedAt time.Time
|
||||
Authors []author.Byline
|
||||
Coverage []article.Translation
|
||||
MissingLangs []string
|
||||
ExcludedFromPublic bool
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
type StoryRowsPage struct {
|
||||
@@ -46,11 +47,13 @@ type StoryRowsPage struct {
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) {
|
||||
if err := u.seo.RefreshStale(); err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
visibility := visibilityFor(command.Caller)
|
||||
creatable := command.Caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle)
|
||||
empty := StoryRowsPage{Rows: []StoryRow{}, Page: command.Page, Size: command.Size}
|
||||
filters := storyFilters(command, visibility, command.Lang == "" && !creatable)
|
||||
owners := &siteOwners{repository: u.sites}
|
||||
if command.SiteKey != nil {
|
||||
siteID, known, err := u.siteIDOf(*command.SiteKey)
|
||||
if err != nil {
|
||||
@@ -61,18 +64,17 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
|
||||
}
|
||||
filters.SiteID = &siteID
|
||||
}
|
||||
summaries, visibleVersions, err := u.summarizeVisibleVersions(visibility, owners)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
if filters.VersionIDs, err = u.versionRestriction(command, filters, summaries); err != nil {
|
||||
var err error
|
||||
if filters.VersionIDs, err = u.titleRestriction(command, filters); err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
counts, err := u.articleRepository.CountStories(filters)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
counts.Suggestions = countSuggestedStories(visibleVersions, summaries)
|
||||
if counts.Suggestions, err = u.articleRepository.CountSuggestedStories(readableOnly(filters)); err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
empty.Counts = counts
|
||||
if command.Tab == article.TabMissing && command.Lang == "" && !creatable {
|
||||
return empty, nil
|
||||
@@ -85,7 +87,7 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
|
||||
if len(found.Refs) == 0 {
|
||||
return empty, nil
|
||||
}
|
||||
rows, err := u.storyRows(command, filters, found.Refs, creatable, storyVersionsByID(visibleVersions), summaries)
|
||||
rows, err := u.storyRows(command, filters, found.Refs, creatable)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
@@ -93,6 +95,10 @@ func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (Story
|
||||
return empty, nil
|
||||
}
|
||||
|
||||
func readableOnly(filters article.StoryFilters) article.StoryFilters {
|
||||
return article.StoryFilters{OnlyOnline: filters.OnlyOnline, VisibleTo: filters.VisibleTo}
|
||||
}
|
||||
|
||||
func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters {
|
||||
return article.StoryFilters{
|
||||
SkipMissing: skipMissing,
|
||||
@@ -101,6 +107,7 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
|
||||
Lang: command.Lang,
|
||||
Missing: command.Tab == article.TabMissing,
|
||||
Status: command.Status,
|
||||
SEOStatus: command.SEO,
|
||||
AuthorID: command.Author,
|
||||
Sort: command.Sort,
|
||||
Page: command.Page,
|
||||
@@ -108,75 +115,34 @@ func storyFilters(command ListStoriesCommand, visibility articleVisibility, skip
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarizeVisibleVersions(visibility articleVisibility, owners *siteOwners) (map[uint32]article.SEOSummary, []article.Article, error) {
|
||||
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
|
||||
func (u *ListArticlesUseCase) titleRestriction(command ListStoriesCommand, filters article.StoryFilters) (*[]uint32, error) {
|
||||
query := strings.TrimSpace(command.Q)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
matching, err := u.articleRepository.FindTitleMatches(query, filters)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
summaries, err := u.summarize(found.Items, owners)
|
||||
return summaries, found.Items, err
|
||||
return &matching, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) versionRestriction(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) (*[]uint32, error) {
|
||||
var allowed *[]uint32
|
||||
if query := strings.TrimSpace(command.Q); query != "" {
|
||||
matching, err := u.articleRepository.FindTitleMatches(query, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed = &matching
|
||||
}
|
||||
if command.SEO == "" {
|
||||
return allowed, nil
|
||||
}
|
||||
matchingTitle := map[uint32]bool{}
|
||||
if allowed != nil {
|
||||
for _, id := range *allowed {
|
||||
matchingTitle[id] = true
|
||||
}
|
||||
}
|
||||
withStatus := []uint32{}
|
||||
for id, summary := range summaries {
|
||||
if summary.Status == command.SEO && (allowed == nil || matchingTitle[id]) {
|
||||
withStatus = append(withStatus, id)
|
||||
}
|
||||
}
|
||||
return &withStatus, nil
|
||||
}
|
||||
|
||||
func storyVersionsByID(versions []article.Article) map[uint32]article.Article {
|
||||
byID := make(map[uint32]article.Article, len(versions))
|
||||
for _, version := range versions {
|
||||
byID[version.ID] = version
|
||||
}
|
||||
return byID
|
||||
}
|
||||
|
||||
func countSuggestedStories(versions []article.Article, summaries map[uint32]article.SEOSummary) int {
|
||||
stories := map[uint32]bool{}
|
||||
standalone := 0
|
||||
for _, version := range versions {
|
||||
if summaries[version.ID].Status != article.SEOStatusSuggestions {
|
||||
continue
|
||||
}
|
||||
if version.StoryID == 0 {
|
||||
standalone++
|
||||
continue
|
||||
}
|
||||
stories[version.StoryID] = true
|
||||
}
|
||||
return len(stories) + standalone
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool, visible map[uint32]article.Article, summaries map[uint32]article.SEOSummary) ([]StoryRow, error) {
|
||||
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters article.StoryFilters, refs []article.StoryRef, creatable bool) ([]StoryRow, error) {
|
||||
storyIDs := make([]uint32, 0, len(refs))
|
||||
representativeIDs := make([]uint32, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
storyIDs = append(storyIDs, ref.StoryID)
|
||||
representativeIDs = append(representativeIDs, ref.ArticleID)
|
||||
}
|
||||
loaded, err := u.articleRepository.ListByStories(storyIDs, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withBodies, err := u.articleRepository.ListVersions(representativeIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := storyVersionsByID(withBodies)
|
||||
byStory := map[uint32][]article.Article{}
|
||||
for _, version := range command.Caller.readableVersions(loaded) {
|
||||
byStory[version.StoryID] = append(byStory[version.StoryID], version)
|
||||
@@ -188,7 +154,7 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters arti
|
||||
versions := byStory[ref.StoryID]
|
||||
_, listed := versionByID(versions, ref.ArticleID)
|
||||
representative, found := visible[ref.ArticleID]
|
||||
if !listed || !found {
|
||||
if !listed || !found || !command.Caller.seesArticle(representative) {
|
||||
continue
|
||||
}
|
||||
languages, cached := siteRows[representative.SiteID]
|
||||
@@ -207,11 +173,19 @@ func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, filters arti
|
||||
}
|
||||
for index := range rows {
|
||||
rows[index].Article = rendered[index]
|
||||
rows[index].SEO = summaries[rendered[index].ID]
|
||||
rows[index].SEO = representatives[index].SEO
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func storyVersionsByID(versions []article.Article) map[uint32]article.Article {
|
||||
byID := make(map[uint32]article.Article, len(versions))
|
||||
for _, version := range versions {
|
||||
byID[version.ID] = version
|
||||
}
|
||||
return byID
|
||||
}
|
||||
|
||||
func versionByID(versions []article.Article, id uint32) (article.Article, bool) {
|
||||
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.ID == id })
|
||||
if index < 0 {
|
||||
@@ -222,19 +196,10 @@ func versionByID(versions []article.Article, id uint32) (article.Article, bool)
|
||||
|
||||
func buildStoryRow(caller ContentCaller, representative article.Article, versions []article.Article, languages []localization.Row, creatable bool) StoryRow {
|
||||
ordered := sortByLanguage(versions, languages)
|
||||
coverage := make([]article.Translation, 0, len(ordered))
|
||||
updatedAt := time.Time{}
|
||||
authors := []author.Byline{}
|
||||
seen := map[uint32]bool{}
|
||||
for _, version := range ordered {
|
||||
coverage = append(coverage, article.Translation{
|
||||
ID: version.ID,
|
||||
Lang: version.Lang,
|
||||
Title: version.Title,
|
||||
IsOnline: version.IsOnline,
|
||||
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(languages, version.Lang),
|
||||
Capabilities: caller.capabilitiesFor(version),
|
||||
})
|
||||
if version.UpdatedAt.After(updatedAt) {
|
||||
updatedAt = version.UpdatedAt
|
||||
}
|
||||
@@ -246,17 +211,18 @@ func buildStoryRow(caller ContentCaller, representative article.Article, version
|
||||
}
|
||||
}
|
||||
return StoryRow{
|
||||
UpdatedAt: updatedAt,
|
||||
Authors: authors,
|
||||
Coverage: coverage,
|
||||
MissingLangs: missingLanguages(ordered, languages, creatable),
|
||||
Capabilities: caller.capabilitiesFor(representative),
|
||||
UpdatedAt: updatedAt,
|
||||
Authors: authors,
|
||||
Coverage: translationsOf(caller, ordered, languages),
|
||||
MissingLangs: missingLanguages(ordered, languages, creatable),
|
||||
ExcludedFromPublic: !localization.PublicTag(languages, representative.Lang),
|
||||
Capabilities: caller.capabilitiesFor(representative),
|
||||
}
|
||||
}
|
||||
|
||||
func missingLanguages(versions []article.Article, languages []localization.Row, creatable bool) []string {
|
||||
missing := []string{}
|
||||
if !creatable || slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang == language.Unassigned }) {
|
||||
if !creatable || !slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang != language.Unassigned }) {
|
||||
return missing
|
||||
}
|
||||
present := languagesOf(versions)
|
||||
|
||||
@@ -10,14 +10,21 @@ import (
|
||||
)
|
||||
|
||||
type ListArticlesUseCase struct {
|
||||
articleRepository article.Repository
|
||||
articleRepository article.ListingRepository
|
||||
bodyRenderer article.BodyRenderer
|
||||
sites site.Repository
|
||||
localization localization.Repository
|
||||
seo *RefreshArticleSEOUseCase
|
||||
}
|
||||
|
||||
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites, localization: localizationRepository}
|
||||
func NewListArticlesUseCase(articleRepository article.ListingRepository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{
|
||||
articleRepository: articleRepository,
|
||||
bodyRenderer: bodyRenderer,
|
||||
sites: sites,
|
||||
localization: localizationRepository,
|
||||
seo: NewRefreshArticleSEOUseCase(articleRepository, sites, localizationRepository),
|
||||
}
|
||||
}
|
||||
|
||||
type ListArticlesCommand struct {
|
||||
@@ -30,7 +37,7 @@ type ListArticlesCommand struct {
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
Lang *string
|
||||
Lang string
|
||||
Author *uint32
|
||||
}
|
||||
|
||||
@@ -73,7 +80,7 @@ func (v articleVisibility) apply(filters article.Filters) article.Filters {
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
|
||||
if command.Lang != nil {
|
||||
if command.Lang != "" {
|
||||
return u.countVisibleVersions(command, visibility)
|
||||
}
|
||||
return u.countVisible(visibility)
|
||||
@@ -87,18 +94,18 @@ func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (articl
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
|
||||
onlineOnly := visibility.onlineOnly
|
||||
if err := u.seo.RefreshStale(); err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
counts, err := u.countFlat(command, visibility)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
summaries, err := u.summarizeVisible(visibility)
|
||||
if err != nil {
|
||||
if counts.Suggestions, err = u.articleRepository.CountSuggestions(visibility.apply(article.Filters{})); err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
counts.Suggestions = countSuggestions(summaries)
|
||||
page := emptyArticlePage(command, counts)
|
||||
if onlineOnly && command.Status == article.StatusDraft {
|
||||
if visibility.onlineOnly && command.Status == article.StatusDraft {
|
||||
return page, nil
|
||||
}
|
||||
filters := visibility.apply(pageFilters(command))
|
||||
@@ -112,39 +119,32 @@ func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesComman
|
||||
}
|
||||
filters.SiteID = &siteID
|
||||
}
|
||||
filters.Unpaged = command.SEO != ""
|
||||
found, err := u.articleRepository.FindPage(filters)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
items, total := found.Items, found.Total
|
||||
if command.SEO != "" {
|
||||
items, total = pageBySEOStatus(found.Items, summaries, command)
|
||||
}
|
||||
rendered, err := renderArticles(u.bodyRenderer, items, command.Format)
|
||||
rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
page.Items = rendered
|
||||
page.Total = total
|
||||
page.SEO = summaries
|
||||
page.Total = found.Total
|
||||
page.SEO = storedSummaries(found.Items)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func storedSummaries(items []article.Article) map[uint32]article.SEOSummary {
|
||||
summaries := make(map[uint32]article.SEOSummary, len(items))
|
||||
for _, item := range items {
|
||||
summaries[item.ID] = item.SEO
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
func emptyArticlePage(command ListArticlesCommand, counts article.Counts) ArticlePage {
|
||||
return ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
|
||||
}
|
||||
|
||||
func withSEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, status string) []article.Article {
|
||||
matching := make([]article.Article, 0, len(items))
|
||||
for _, item := range items {
|
||||
if summaries[item.ID].Status == status {
|
||||
matching = append(matching, item)
|
||||
}
|
||||
}
|
||||
return matching
|
||||
}
|
||||
|
||||
func pageSlice[T any](items []T, page int, size int) []T {
|
||||
size = min(max(size, 0), len(items))
|
||||
if size == 0 {
|
||||
@@ -159,69 +159,11 @@ func pageSlice[T any](items []T, page int, size int) []T {
|
||||
return items[start:min(start+size, len(items))]
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (map[uint32]article.SEOSummary, error) {
|
||||
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.summarize(found.Items, &siteOwners{repository: u.sites})
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countVisibleVersions(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
|
||||
filters := visibility.apply(article.Filters{Lang: command.Lang})
|
||||
return u.articleRepository.CountVersions(filters)
|
||||
}
|
||||
|
||||
func countSuggestions(summaries map[uint32]article.SEOSummary) int {
|
||||
suggestions := 0
|
||||
for _, summary := range summaries {
|
||||
if summary.Status == article.SEOStatusSuggestions {
|
||||
suggestions++
|
||||
}
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
|
||||
matching := withSEOStatus(items, summaries, command.SEO)
|
||||
return pageSlice(matching, command.Page, command.Size), len(matching)
|
||||
}
|
||||
|
||||
type siteOwners struct {
|
||||
repository site.Repository
|
||||
byID map[uint32]site.Site
|
||||
}
|
||||
|
||||
func (o *siteOwners) get() (map[uint32]site.Site, error) {
|
||||
if o.byID != nil {
|
||||
return o.byID, nil
|
||||
}
|
||||
known, err := o.repository.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.byID = make(map[uint32]site.Site, len(known))
|
||||
for _, owner := range known {
|
||||
o.byID[owner.ID] = owner
|
||||
}
|
||||
return o.byID, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarize(items []article.Article, lookup *siteOwners) (map[uint32]article.SEOSummary, error) {
|
||||
summaries := make(map[uint32]article.SEOSummary, len(items))
|
||||
if len(items) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
owners, err := lookup.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
|
||||
found, err := u.sites.GetByKey(key)
|
||||
if errors.Is(err, site.ErrSiteNotFound) {
|
||||
@@ -234,5 +176,5 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
|
||||
}
|
||||
|
||||
func pageFilters(command ListArticlesCommand) article.Filters {
|
||||
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
|
||||
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, SEOStatus: command.SEO, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
)
|
||||
|
||||
const seoRefreshBatchSize = 500
|
||||
|
||||
type RefreshArticleSEOUseCase struct {
|
||||
index article.SEOIndex
|
||||
sites site.Repository
|
||||
localization localization.Repository
|
||||
}
|
||||
|
||||
func NewRefreshArticleSEOUseCase(index article.SEOIndex, sites site.Repository, localizationRepository localization.Repository) *RefreshArticleSEOUseCase {
|
||||
return &RefreshArticleSEOUseCase{index: index, sites: sites, localization: localizationRepository}
|
||||
}
|
||||
|
||||
func (u *RefreshArticleSEOUseCase) RefreshStale() error {
|
||||
afterID := uint32(0)
|
||||
for {
|
||||
stale, err := u.index.StaleSEO(afterID, seoRefreshBatchSize)
|
||||
if err != nil || len(stale) == 0 {
|
||||
return err
|
||||
}
|
||||
context := u.siteContextReadAfter()
|
||||
records := make([]article.SEORecord, 0, len(stale))
|
||||
for _, version := range stale {
|
||||
summary, err := context.assess(version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records = append(records, article.SEORecord{ArticleID: version.ID, Version: version.SEOVersion, Summary: summary})
|
||||
}
|
||||
if err := u.index.StoreSEO(records); err != nil {
|
||||
return err
|
||||
}
|
||||
afterID = stale[len(stale)-1].ID
|
||||
}
|
||||
}
|
||||
|
||||
func (u *RefreshArticleSEOUseCase) siteContextReadAfter() *seoSiteContext {
|
||||
return &seoSiteContext{sites: u.sites, localization: u.localization, owners: map[uint32]site.Site{}, defaults: map[uint32]string{}}
|
||||
}
|
||||
|
||||
type seoSiteContext struct {
|
||||
sites site.Repository
|
||||
localization localization.Repository
|
||||
owners map[uint32]site.Site
|
||||
defaults map[uint32]string
|
||||
}
|
||||
|
||||
func (c *seoSiteContext) assess(version article.Article) (article.SEOSummary, error) {
|
||||
owner, err := c.owner(version.SiteID)
|
||||
if err != nil {
|
||||
return article.SEOSummary{}, err
|
||||
}
|
||||
defaultTag, err := c.defaultTag(version.SiteID)
|
||||
if err != nil {
|
||||
return article.SEOSummary{}, err
|
||||
}
|
||||
return assessSEO(version, owner, localization.SiteTextAllowed(defaultTag, version.Lang)).Summary(), nil
|
||||
}
|
||||
|
||||
func (c *seoSiteContext) owner(siteID uint32) (site.Site, error) {
|
||||
if owner, cached := c.owners[siteID]; cached {
|
||||
return owner, nil
|
||||
}
|
||||
owner, err := c.sites.Get(siteID)
|
||||
if err != nil && !errors.Is(err, site.ErrSiteNotFound) {
|
||||
return site.Site{}, err
|
||||
}
|
||||
c.owners[siteID] = owner
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
func (c *seoSiteContext) defaultTag(siteID uint32) (string, error) {
|
||||
if tag, cached := c.defaults[siteID]; cached {
|
||||
return tag, nil
|
||||
}
|
||||
rows, err := c.localization.Rows(siteID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.defaults[siteID] = localization.ConfirmedDefaultTag(rows)
|
||||
return c.defaults[siteID], nil
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const (
|
||||
jsonLDArticle = "Article"
|
||||
jsonLDPerson = "Person"
|
||||
jsonLDWebPage = "WebPage"
|
||||
sitePlaceholder = "%site%"
|
||||
)
|
||||
|
||||
type ResolveArticleSEOUseCase struct {
|
||||
@@ -69,7 +68,7 @@ func (u *ResolveArticleSEOUseCase) languageOf(art article.Article, owner site.Si
|
||||
localeAlternates = []string{}
|
||||
}
|
||||
return seoLanguage{
|
||||
defaultTag: confirmedDefault(rows),
|
||||
defaultTag: localization.ConfirmedDefaultTag(rows),
|
||||
locale: locale,
|
||||
alternates: alternatesOf(owner, siblings),
|
||||
localeAlternates: localeAlternates,
|
||||
@@ -129,7 +128,7 @@ func applyTitleTemplate(template string, title string, siteName string) string {
|
||||
if template == "" {
|
||||
template = site.DefaultTitleTemplate
|
||||
}
|
||||
replacer := strings.NewReplacer(site.TitlePlaceholder, title, sitePlaceholder, siteName)
|
||||
replacer := strings.NewReplacer(site.TitlePlaceholder, title, site.SitePlaceholder, siteName)
|
||||
return strings.TrimSpace(replacer.Replace(template))
|
||||
}
|
||||
|
||||
@@ -195,7 +194,7 @@ func buildJSONLD(headline string, description string, picked article.SEOImage, a
|
||||
func buildResolvedSEO(art article.Article, owner site.Site, picked article.SEOImage, spoken seoLanguage) article.ResolvedSEO {
|
||||
meta, titleSource := metaTitle(art)
|
||||
title := applyTitleTemplate(owner.TitleTemplate, meta, owner.SiteName)
|
||||
siteTextAllowed := art.Lang == "" || art.Lang == spoken.defaultTag
|
||||
siteTextAllowed := localization.SiteTextAllowed(spoken.defaultTag, art.Lang)
|
||||
description, descriptionSource := resolveDescription(art, owner, analyzeBody(art.Body), siteTextAllowed)
|
||||
siteTextSkipped := descriptionSource == article.SEOSourceNone && owner.DefaultDescription != ""
|
||||
canonical, canonicalSource := resolveCanonical(art, owner)
|
||||
|
||||
@@ -96,7 +96,7 @@ func (u *ResolvePublicArticleUseCase) ByStory(query PublicStoryQuery) (PublicArt
|
||||
if unsupported {
|
||||
return PublicArticle{}, article.ErrLanguageNotAvailable
|
||||
}
|
||||
defaultTag := confirmedDefault(rows)
|
||||
defaultTag := localization.ConfirmedDefaultTag(rows)
|
||||
if query.Lang == nil {
|
||||
requested = defaultTag
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/site"
|
||||
@@ -11,10 +12,11 @@ type UpdateSiteUseCase struct {
|
||||
siteRepository site.Repository
|
||||
imageRepository image.Repository
|
||||
recorder audit.Recorder
|
||||
articleSEO article.SEOIndex
|
||||
}
|
||||
|
||||
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder) *UpdateSiteUseCase {
|
||||
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder}
|
||||
func NewUpdateSiteUseCase(siteRepository site.Repository, imageRepository image.Repository, recorder audit.Recorder, articleSEO article.SEOIndex) *UpdateSiteUseCase {
|
||||
return &UpdateSiteUseCase{siteRepository, imageRepository, recorder, articleSEO}
|
||||
}
|
||||
|
||||
func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Patch) (site.Site, error) {
|
||||
@@ -43,6 +45,9 @@ func (u *UpdateSiteUseCase) Update(ctx context.Context, id uint32, patch site.Pa
|
||||
event.Target = siteTarget(saved)
|
||||
event.Changes = changes
|
||||
u.recorder.Record(ctx, event)
|
||||
if before.DefaultDescription != saved.DefaultDescription {
|
||||
return saved, u.articleSEO.MarkSiteSEOStale(saved.ID)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -65,10 +65,8 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !localization.SameRows(before, after) {
|
||||
if err := tx.Localization.Replace(found.ID, after); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replaceLanguages(tx, found.ID, before, after); err != nil {
|
||||
return err
|
||||
}
|
||||
view, err = readSiteLanguages(tx.Localization, found)
|
||||
return err
|
||||
@@ -87,19 +85,26 @@ func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateS
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func languageChanges(before []localization.Row, after []localization.Row) []audit.Change {
|
||||
changes := []audit.Change{}
|
||||
changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, defaultTag(before), defaultTag(after))
|
||||
return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after))
|
||||
func replaceLanguages(tx transaction.Repositories, siteID uint32, before []localization.Row, after []localization.Row) error {
|
||||
if localization.SameRows(before, after) {
|
||||
return nil
|
||||
}
|
||||
if err := localization.RequireSingleDefault(after); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Localization.Replace(siteID, after); err != nil {
|
||||
return err
|
||||
}
|
||||
if localization.DefaultTag(before) == localization.DefaultTag(after) {
|
||||
return nil
|
||||
}
|
||||
return tx.ArticleSEO.MarkSiteSEOStale(siteID)
|
||||
}
|
||||
|
||||
func defaultTag(rows []localization.Row) string {
|
||||
for _, row := range rows {
|
||||
if row.IsDefault {
|
||||
return row.Tag
|
||||
}
|
||||
}
|
||||
return ""
|
||||
func languageChanges(before []localization.Row, after []localization.Row) []audit.Change {
|
||||
changes := []audit.Change{}
|
||||
changes = audit.AppendTextChange(changes, audit.FieldDefaultLanguage, localization.DefaultTag(before), localization.DefaultTag(after))
|
||||
return audit.AppendTextChange(changes, audit.FieldLanguages, languagesLabel(before), languagesLabel(after))
|
||||
}
|
||||
|
||||
func languagesLabel(rows []localization.Row) string {
|
||||
|
||||
@@ -7,21 +7,14 @@ import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
func confirmedDefault(rows []localization.Row) string {
|
||||
if localization.NeedsSetup(rows) {
|
||||
return ""
|
||||
}
|
||||
return localization.DefaultTag(rows)
|
||||
}
|
||||
|
||||
func eligibleSiblings(rows []localization.Row, versions []article.Article, served article.Article) []article.Article {
|
||||
defaultTag := confirmedDefault(rows)
|
||||
defaultTag := localization.ConfirmedDefaultTag(rows)
|
||||
siblings := []article.Article{}
|
||||
for _, version := range versions {
|
||||
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline || version.Lang == "" {
|
||||
if version.ID == served.ID || version.SiteID != served.SiteID || !version.IsOnline {
|
||||
continue
|
||||
}
|
||||
if localization.EnabledTag(rows, version.Lang) {
|
||||
if localization.PublicTag(rows, version.Lang) {
|
||||
siblings = append(siblings, version)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ func metaTitle(art article.Article) (string, string) {
|
||||
return art.Title, article.SEOSourceTitle
|
||||
}
|
||||
|
||||
const withSiteText = true
|
||||
|
||||
func resolveDescription(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) (string, string) {
|
||||
if art.SEODescription != "" {
|
||||
return art.SEODescription, article.SEOSourceSEODescription
|
||||
@@ -37,7 +35,7 @@ func suggestion(code string, field string, params map[string]int) article.SEOSug
|
||||
return article.SEOSuggestion{Code: code, Field: field, Params: params}
|
||||
}
|
||||
|
||||
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []article.SEOSuggestion {
|
||||
func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts, siteTextAllowed bool) []article.SEOSuggestion {
|
||||
suggestions := []article.SEOSuggestion{}
|
||||
title, _ := metaTitle(art)
|
||||
titleRunes := utf8.RuneCountInString(title)
|
||||
@@ -47,7 +45,7 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
|
||||
if strings.TrimSpace(title) == "" {
|
||||
suggestions = append(suggestions, suggestion(article.DismissTitleEmpty, article.SEOFieldTitle, map[string]int{}))
|
||||
}
|
||||
description, _ := resolveDescription(art, owner, facts, withSiteText)
|
||||
description, _ := resolveDescription(art, owner, facts, siteTextAllowed)
|
||||
descriptionRunes := utf8.RuneCountInString(description)
|
||||
if descriptionRunes < article.SEODescriptionMinRunes {
|
||||
suggestions = append(suggestions, suggestion(article.DismissDescShort, article.SEOFieldDescription, map[string]int{"length": descriptionRunes, "min": article.SEODescriptionMinRunes}))
|
||||
@@ -80,10 +78,10 @@ func rawSuggestions(art article.Article, owner site.Site, facts bodyFacts) []art
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func assessSEO(art article.Article, owner site.Site) article.SEOAssessment {
|
||||
func assessSEO(art article.Article, owner site.Site, siteTextAllowed bool) article.SEOAssessment {
|
||||
facts := analyzeBody(art.Body)
|
||||
kept := []article.SEOSuggestion{}
|
||||
for _, found := range rawSuggestions(art, owner, facts) {
|
||||
for _, found := range rawSuggestions(art, owner, facts, siteTextAllowed) {
|
||||
if !slices.Contains(art.SEODismissed, found.Code) {
|
||||
kept = append(kept, found)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user