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
@@ -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