feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
@@ -61,6 +61,12 @@ 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.AuthorID != nil {
|
||||
query = query.Where("id IN (SELECT article_id FROM article_authors WHERE author_id = ?)", *filters.AuthorID)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -188,6 +194,22 @@ func (a *ArticleRepository) CountVisibleTo(userID uint32) (domain.Counts, error)
|
||||
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) CountVersions(filters domain.Filters) (domain.Counts, error) {
|
||||
narrowed := domain.Filters{Lang: filters.Lang}
|
||||
var online, draft int64
|
||||
if err := a.scoped(narrowed).Where("is_online = ?", true).Count(&online).Error; err != nil {
|
||||
return domain.Counts{}, err
|
||||
}
|
||||
onlineOnly := filters.IsOnline != nil && *filters.IsOnline
|
||||
if !onlineOnly {
|
||||
narrowed.VisibleTo = filters.VisibleTo
|
||||
if err := a.scoped(narrowed).Where("is_online = ?", false).Count(&draft).Error; err != nil {
|
||||
return domain.Counts{}, err
|
||||
}
|
||||
}
|
||||
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
|
||||
}
|
||||
|
||||
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
|
||||
domainArticles := make([]domain.Article, 0, len(entityArticles))
|
||||
for _, entityArticle := range entityArticles {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type storySQL struct {
|
||||
conditions []string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (s *storySQL) add(condition string, args ...any) {
|
||||
s.conditions = append(s.conditions, condition)
|
||||
s.args = append(s.args, args...)
|
||||
}
|
||||
|
||||
func (s storySQL) where() string {
|
||||
return strings.Join(s.conditions, " AND ")
|
||||
}
|
||||
|
||||
func readableCondition(alias string, filters domain.StoryFilters) storySQL {
|
||||
condition := storySQL{}
|
||||
condition.add(alias + ".deleted_at IS NULL")
|
||||
if filters.OnlyOnline {
|
||||
condition.add(alias+".is_online = ?", true)
|
||||
}
|
||||
if filters.VisibleTo != nil {
|
||||
condition.add("("+alias+".is_online = ? OR "+alias+".created_by = ?)", true, *filters.VisibleTo)
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func (s *storySQL) merge(other storySQL) {
|
||||
s.conditions = append(s.conditions, other.conditions...)
|
||||
s.args = append(s.args, other.args...)
|
||||
}
|
||||
|
||||
func lacksReadableVersionIn(languageExpression string, filters domain.StoryFilters, languageArgs ...any) storySQL {
|
||||
readable := readableCondition("w", filters)
|
||||
condition := storySQL{}
|
||||
condition.add("NOT EXISTS (SELECT 1 FROM articles w WHERE w.story_id = q.story_id AND "+readable.where()+" AND w.lang = "+languageExpression+")", append(readable.args, languageArgs...)...)
|
||||
return condition
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) versionRestriction(filters domain.StoryFilters) ([]uint32, bool, error) {
|
||||
restricted := filters.VersionIDs != nil
|
||||
var allowed []uint32
|
||||
if restricted {
|
||||
allowed = *filters.VersionIDs
|
||||
}
|
||||
query := strings.TrimSpace(filters.Q)
|
||||
if query == "" {
|
||||
return allowed, restricted, nil
|
||||
}
|
||||
readable := readableCondition("q", filters)
|
||||
if filters.SiteID != nil {
|
||||
readable.add("q.site_id = ?", *filters.SiteID)
|
||||
}
|
||||
var candidates []entity.Article
|
||||
if err := a.db.Raw("SELECT q.id AS id, q.title AS title FROM articles q WHERE "+readable.where(), readable.args...).Scan(&candidates).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
needle := foldForSearch(query)
|
||||
permitted := map[uint32]bool{}
|
||||
for _, id := range allowed {
|
||||
permitted[id] = true
|
||||
}
|
||||
matching := make([]uint32, 0)
|
||||
for _, candidate := range candidates {
|
||||
if restricted && !permitted[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(foldForSearch(candidate.Title), needle) {
|
||||
matching = append(matching, candidate.ID)
|
||||
}
|
||||
}
|
||||
return matching, true, nil
|
||||
}
|
||||
|
||||
func qualifyingVersions(filters domain.StoryFilters, allowed []uint32, restricted bool) storySQL {
|
||||
condition := readableCondition("q", filters)
|
||||
if filters.SiteID != nil {
|
||||
condition.add("q.site_id = ?", *filters.SiteID)
|
||||
}
|
||||
if filters.PerLanguage() {
|
||||
condition.add("q.lang = ?", filters.Lang)
|
||||
}
|
||||
switch filters.Status {
|
||||
case domain.StatusOnline:
|
||||
condition.add("q.is_online = ?", true)
|
||||
case domain.StatusDraft:
|
||||
condition.add("q.is_online = ?", false)
|
||||
}
|
||||
if filters.AuthorID != nil {
|
||||
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
|
||||
}
|
||||
if restricted {
|
||||
if len(allowed) == 0 {
|
||||
condition.add("1 = 0")
|
||||
} else {
|
||||
condition.add("q.id IN ?", allowed)
|
||||
}
|
||||
}
|
||||
if filters.Missing {
|
||||
condition.merge(lacksReadableVersionIn("?", filters, ""))
|
||||
condition.merge(missingCondition(filters))
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func missingCondition(filters domain.StoryFilters) storySQL {
|
||||
condition := storySQL{}
|
||||
if filters.Lang != "" {
|
||||
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.lang = ? AND sl.enabled = ?)", filters.Lang, true)
|
||||
condition.merge(lacksReadableVersionIn("?", filters, filters.Lang))
|
||||
return condition
|
||||
}
|
||||
absent := lacksReadableVersionIn("sl.lang", filters)
|
||||
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.enabled = ? AND "+strings.Join(absent.conditions, " AND ")+")", append([]any{true}, absent.args...)...)
|
||||
return condition
|
||||
}
|
||||
|
||||
func unitExpression(filters domain.StoryFilters) string {
|
||||
if filters.PerLanguage() {
|
||||
return "COUNT(*)"
|
||||
}
|
||||
return "COUNT(DISTINCT q.story_id)"
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) countUnits(filters domain.StoryFilters, allowed []uint32, restricted bool) (int, error) {
|
||||
qualifying := qualifyingVersions(filters, allowed, restricted)
|
||||
var total int64
|
||||
err := a.db.Raw("SELECT "+unitExpression(filters)+" FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&total).Error
|
||||
return int(total), err
|
||||
}
|
||||
|
||||
var storyRowOrder = map[domain.SortField]string{
|
||||
domain.SortByTitle: "s.sort_title",
|
||||
domain.SortByStatus: "s.sort_online",
|
||||
domain.SortByCreatedAt: "s.sort_created",
|
||||
domain.SortByUpdatedAt: "s.sort_updated",
|
||||
}
|
||||
|
||||
var versionRowOrder = map[domain.SortField]string{
|
||||
domain.SortByTitle: "LOWER(q.title)",
|
||||
domain.SortByStatus: "q.is_online",
|
||||
domain.SortByCreatedAt: "q.created_at",
|
||||
domain.SortByUpdatedAt: "q.updated_at",
|
||||
}
|
||||
|
||||
func storyOrderClause(columns map[domain.SortField]string, sort domain.Sort, storyColumn string) string {
|
||||
column, ok := columns[sort.Field]
|
||||
direction, directionOk := sortDirections[sort.Direction]
|
||||
if !ok || !directionOk {
|
||||
column, direction = columns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
|
||||
}
|
||||
return column + " " + direction + ", " + storyColumn + " DESC"
|
||||
}
|
||||
|
||||
type storyRefRow struct {
|
||||
StoryID uint32
|
||||
ArticleID uint32
|
||||
}
|
||||
|
||||
func toStoryRefs(rows []storyRefRow) []domain.StoryRef {
|
||||
refs := make([]domain.StoryRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
refs = append(refs, domain.StoryRef{StoryID: row.StoryID, ArticleID: row.ArticleID})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.StoryPage, error) {
|
||||
filters.Page = max(filters.Page, 1)
|
||||
filters.Size = max(filters.Size, 0)
|
||||
allowed, restricted, err := a.versionRestriction(filters)
|
||||
if err != nil {
|
||||
return domain.StoryPage{}, err
|
||||
}
|
||||
total, err := a.countUnits(filters, allowed, restricted)
|
||||
if err != nil {
|
||||
return domain.StoryPage{}, err
|
||||
}
|
||||
page := domain.StoryPage{Refs: []domain.StoryRef{}, Total: total}
|
||||
if total == 0 {
|
||||
return page, nil
|
||||
}
|
||||
qualifying := qualifyingVersions(filters, allowed, restricted)
|
||||
offset := (filters.Page - 1) * filters.Size
|
||||
var rows []storyRefRow
|
||||
if filters.PerLanguage() {
|
||||
query := "SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE " + qualifying.where() +
|
||||
" ORDER BY " + storyOrderClause(versionRowOrder, filters.Sort, "q.story_id") + " LIMIT ? OFFSET ?"
|
||||
err = a.db.Raw(query, append(qualifying.args, filters.Size, offset)...).Scan(&rows).Error
|
||||
} else {
|
||||
query, args := storyRowsQuery(filters, qualifying)
|
||||
err = a.db.Raw(query, append(args, filters.Size, offset)...).Scan(&rows).Error
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StoryPage{}, err
|
||||
}
|
||||
page.Refs = toStoryRefs(rows)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func storyRowsQuery(filters domain.StoryFilters, qualifying storySQL) (string, []any) {
|
||||
readable := readableCondition("a", filters)
|
||||
args := []any{true}
|
||||
args = append(args, readable.args...)
|
||||
args = append(args, qualifying.args...)
|
||||
query := "SELECT s.story_id AS story_id, s.article_id AS article_id FROM (" +
|
||||
"SELECT r.story_id AS story_id, r.id AS article_id, r.sort_title AS sort_title, r.sort_online AS sort_online, r.sort_updated AS sort_updated, r.sort_created AS sort_created FROM (" +
|
||||
"SELECT a.id AS id, a.story_id AS story_id, LOWER(a.title) AS sort_title, a.is_online AS sort_online, " +
|
||||
"MAX(a.updated_at) OVER (PARTITION BY a.story_id) AS sort_updated, " +
|
||||
"MIN(a.created_at) OVER (PARTITION BY a.story_id) AS sort_created, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY a.story_id ORDER BY CASE WHEN d.lang IS NULL THEN 1 ELSE 0 END, a.lang, a.id) AS row_rank " +
|
||||
"FROM articles a LEFT JOIN site_languages d ON d.site_id = a.site_id AND d.lang = a.lang AND d.is_default = ? " +
|
||||
"WHERE " + readable.where() + " AND a.story_id IN (SELECT q.story_id FROM articles q WHERE " + qualifying.where() + ")" +
|
||||
") r WHERE r.row_rank = 1) s ORDER BY " + storyOrderClause(storyRowOrder, filters.Sort, "s.story_id") + " LIMIT ? OFFSET ?"
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.StoryCounts, error) {
|
||||
filters.Status = ""
|
||||
allowed, restricted, err := a.versionRestriction(filters)
|
||||
if err != nil {
|
||||
return domain.StoryCounts{}, err
|
||||
}
|
||||
counts := domain.StoryCounts{}
|
||||
for _, target := range []struct {
|
||||
status string
|
||||
into *int
|
||||
}{{"", &counts.All}, {domain.StatusOnline, &counts.Online}, {domain.StatusDraft, &counts.Draft}} {
|
||||
scoped := filters
|
||||
scoped.Missing = false
|
||||
scoped.Status = target.status
|
||||
if *target.into, err = a.countUnits(scoped, allowed, restricted); err != nil {
|
||||
return domain.StoryCounts{}, err
|
||||
}
|
||||
}
|
||||
if filters.SkipMissing {
|
||||
return counts, nil
|
||||
}
|
||||
missing := filters
|
||||
missing.Missing = true
|
||||
counts.Missing, err = a.countUnits(missing, allowed, restricted)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) ListByStories(storyIDs []uint32) ([]domain.Article, error) {
|
||||
var found []entity.Article
|
||||
for _, chunk := range chunkIDs(storyIDs) {
|
||||
var loaded []entity.Article
|
||||
if err := a.db.Model(&entity.Article{}).Where("story_id IN ?", chunk).Order("id ASC").Find(&loaded).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, loaded...)
|
||||
}
|
||||
return a.withAuthors(a.db, toDomainArticles(found))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListStoriesCommand struct {
|
||||
Format article.BodyFormat
|
||||
Caller ContentCaller
|
||||
Page int
|
||||
Size int
|
||||
Q string
|
||||
Status string
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
Lang string
|
||||
Tab string
|
||||
Author *uint32
|
||||
}
|
||||
|
||||
type StoryRow struct {
|
||||
Article article.Article
|
||||
SEO article.SEOSummary
|
||||
UpdatedAt time.Time
|
||||
Authors []author.Byline
|
||||
Coverage []article.Translation
|
||||
MissingLangs []string
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
type StoryRowsPage struct {
|
||||
Rows []StoryRow
|
||||
Total int
|
||||
Page int
|
||||
Size int
|
||||
Counts article.StoryCounts
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) {
|
||||
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 {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
if !known {
|
||||
return empty, nil
|
||||
}
|
||||
filters.SiteID = &siteID
|
||||
}
|
||||
summaries, visibleVersions, err := u.summarizeVisibleVersions(visibility, owners)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
if command.SEO != "" {
|
||||
allowed, err := u.versionsWithSEOStatus(command, filters, summaries)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
filters.VersionIDs = &allowed
|
||||
}
|
||||
counts, err := u.articleRepository.CountStories(filters)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
counts.Suggestions = countSuggestedStories(visibleVersions, summaries)
|
||||
empty.Counts = counts
|
||||
if command.Tab == article.TabMissing && command.Lang == "" && !creatable {
|
||||
return empty, nil
|
||||
}
|
||||
found, err := u.articleRepository.FindStoryPage(filters)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
empty.Total = found.Total
|
||||
if len(found.Refs) == 0 {
|
||||
return empty, nil
|
||||
}
|
||||
rows, err := u.storyRows(command, found.Refs, creatable, owners)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
empty.Rows = rows
|
||||
return empty, nil
|
||||
}
|
||||
|
||||
func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters {
|
||||
return article.StoryFilters{
|
||||
SkipMissing: skipMissing,
|
||||
OnlyOnline: visibility.onlineOnly,
|
||||
VisibleTo: visibility.userID,
|
||||
Lang: command.Lang,
|
||||
Missing: command.Tab == article.TabMissing,
|
||||
Status: command.Status,
|
||||
AuthorID: command.Author,
|
||||
Q: command.Q,
|
||||
Sort: command.Sort,
|
||||
Page: command.Page,
|
||||
Size: command.Size,
|
||||
}
|
||||
}
|
||||
|
||||
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}))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
summaries, err := u.summarize(found.Items, owners)
|
||||
return summaries, found.Items, err
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) versionsWithSEOStatus(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) ([]uint32, error) {
|
||||
narrowed := article.Filters{VisibleTo: filters.VisibleTo, SiteID: filters.SiteID, AuthorID: filters.AuthorID, Q: filters.Q, Unpaged: true}
|
||||
if filters.OnlyOnline {
|
||||
online := true
|
||||
narrowed.IsOnline = &online
|
||||
}
|
||||
found, err := u.articleRepository.FindPage(narrowed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := []uint32{}
|
||||
for _, version := range found.Items {
|
||||
if summaries[version.ID].Status == command.SEO {
|
||||
allowed = append(allowed, version.ID)
|
||||
}
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
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, refs []article.StoryRef, creatable bool, owners *siteOwners) ([]StoryRow, error) {
|
||||
storyIDs := make([]uint32, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
storyIDs = append(storyIDs, ref.StoryID)
|
||||
}
|
||||
loaded, err := u.articleRepository.ListByStories(storyIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byStory := map[uint32][]article.Article{}
|
||||
for _, version := range command.Caller.readableVersions(loaded) {
|
||||
byStory[version.StoryID] = append(byStory[version.StoryID], version)
|
||||
}
|
||||
siteRows := map[uint32][]localization.Row{}
|
||||
rows := make([]StoryRow, 0, len(refs))
|
||||
representatives := make([]article.Article, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
versions := byStory[ref.StoryID]
|
||||
representative, found := versionByID(versions, ref.ArticleID)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
languages, cached := siteRows[representative.SiteID]
|
||||
if !cached {
|
||||
if languages, err = u.localization.Rows(representative.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
siteRows[representative.SiteID] = languages
|
||||
}
|
||||
rows = append(rows, buildStoryRow(command.Caller, representative, versions, languages, creatable))
|
||||
representatives = append(representatives, representative)
|
||||
}
|
||||
summaries, err := u.summarize(representatives, owners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rendered, err := renderArticles(u.bodyRenderer, representatives, command.Format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
rows[index].Article = rendered[index]
|
||||
rows[index].SEO = summaries[rendered[index].ID]
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return article.Article{}, false
|
||||
}
|
||||
return versions[index], true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
for _, byline := range version.Authors {
|
||||
if !seen[byline.ID] {
|
||||
seen[byline.ID] = true
|
||||
authors = append(authors, byline)
|
||||
}
|
||||
}
|
||||
}
|
||||
return StoryRow{
|
||||
UpdatedAt: updatedAt,
|
||||
Authors: authors,
|
||||
Coverage: coverage,
|
||||
MissingLangs: missingLanguages(ordered, languages, creatable),
|
||||
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 }) {
|
||||
return missing
|
||||
}
|
||||
present := languagesOf(versions)
|
||||
for _, row := range languages {
|
||||
if row.Enabled && !slices.Contains(present, row.Tag) {
|
||||
missing = append(missing, row.Tag)
|
||||
}
|
||||
}
|
||||
defaultTag := localization.DefaultTag(languages)
|
||||
slices.SortFunc(missing, func(left string, right string) int { return localization.CompareTags(defaultTag, left, right) })
|
||||
return missing
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
@@ -12,10 +13,11 @@ type ListArticlesUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
sites site.Repository
|
||||
localization localization.Repository
|
||||
}
|
||||
|
||||
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites}
|
||||
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}
|
||||
}
|
||||
|
||||
type ListArticlesCommand struct {
|
||||
@@ -28,6 +30,8 @@ type ListArticlesCommand struct {
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
Lang *string
|
||||
Author *uint32
|
||||
}
|
||||
|
||||
type ArticlePage struct {
|
||||
@@ -40,13 +44,17 @@ type ArticlePage struct {
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
|
||||
switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
|
||||
return u.listVisibleByRepository(command, visibilityFor(command.Caller))
|
||||
}
|
||||
|
||||
func visibilityFor(caller ContentCaller) articleVisibility {
|
||||
switch caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
|
||||
case role.ScopeAll:
|
||||
return u.listVisibleByRepository(command, articleVisibility{})
|
||||
return articleVisibility{}
|
||||
case role.ScopeOwn:
|
||||
return u.listVisibleByRepository(command, articleVisibility{userID: &command.Caller.UserID})
|
||||
return articleVisibility{userID: &caller.UserID}
|
||||
default:
|
||||
return u.listVisibleByRepository(command, articleVisibility{onlineOnly: true})
|
||||
return articleVisibility{onlineOnly: true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +72,13 @@ func (v articleVisibility) apply(filters article.Filters) article.Filters {
|
||||
return filters
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
|
||||
if command.Lang != nil {
|
||||
return u.countVisibleVersions(command, visibility)
|
||||
}
|
||||
return u.countVisible(visibility)
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (article.Counts, error) {
|
||||
if visibility.userID != nil {
|
||||
return u.articleRepository.CountVisibleTo(*visibility.userID)
|
||||
@@ -73,7 +88,7 @@ func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (articl
|
||||
|
||||
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
|
||||
onlineOnly := visibility.onlineOnly
|
||||
counts, err := u.countVisible(visibility)
|
||||
counts, err := u.countFlat(command, visibility)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
@@ -149,7 +164,12 @@ func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (ma
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.summarize(found.Items)
|
||||
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 {
|
||||
@@ -167,19 +187,35 @@ func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSu
|
||||
return pageSlice(matching, command.Page, command.Size), len(matching)
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
|
||||
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
|
||||
}
|
||||
known, err := u.sites.List()
|
||||
owners, err := lookup.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners := make(map[uint32]site.Site, len(known))
|
||||
for _, owner := range known {
|
||||
owners[owner.ID] = owner
|
||||
}
|
||||
for _, item := range items {
|
||||
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
|
||||
}
|
||||
@@ -198,5 +234,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}
|
||||
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
|
||||
}
|
||||
Reference in new issue
Block a user