824 lines
27 KiB
Go
824 lines
27 KiB
Go
package persistence
|
|
|
|
import (
|
|
domain "RenewCMS/internal/domain/article"
|
|
authordomain "RenewCMS/internal/domain/author"
|
|
"RenewCMS/internal/domain/language"
|
|
"RenewCMS/internal/domain/transaction"
|
|
"RenewCMS/internal/infrastructure/persistence/database"
|
|
"RenewCMS/internal/infrastructure/persistence/mappers"
|
|
entity "RenewCMS/internal/infrastructure/persistence/models"
|
|
"errors"
|
|
"slices"
|
|
"sort"
|
|
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"golang.org/x/text/runes"
|
|
"golang.org/x/text/transform"
|
|
"golang.org/x/text/unicode/norm"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type ArticleRepository struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewArticleRepository(db *gorm.DB) *ArticleRepository {
|
|
return &ArticleRepository{db}
|
|
}
|
|
|
|
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
|
|
query := a.db.Model(&entity.Article{})
|
|
|
|
if filters.IsOnline != nil {
|
|
query = query.Where("is_online = ?", *filters.IsOnline)
|
|
}
|
|
|
|
var entityArticles []entity.Article
|
|
err := query.Find(&entityArticles).Error
|
|
|
|
return toDomainArticles(entityArticles), err
|
|
}
|
|
|
|
func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
|
|
query := a.db.Model(&entity.Article{})
|
|
if filters.IsOnline != nil {
|
|
query = query.Where("is_online = ?", *filters.IsOnline)
|
|
}
|
|
if filters.SiteID != nil {
|
|
query = query.Where("site_id = ?", *filters.SiteID)
|
|
}
|
|
if filters.VisibleTo != nil {
|
|
query = query.Where("(is_online = ? OR created_by = ?)", true, *filters.VisibleTo)
|
|
}
|
|
switch filters.Status {
|
|
case domain.StatusOnline:
|
|
query = query.Where("is_online = ?", true)
|
|
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
|
|
}
|
|
|
|
var sortColumns = map[domain.SortField]string{
|
|
domain.SortByTitle: "LOWER(title)",
|
|
domain.SortByStatus: "is_online",
|
|
domain.SortByCreatedAt: "created_at",
|
|
domain.SortByUpdatedAt: "updated_at",
|
|
domain.SortByID: "id",
|
|
}
|
|
|
|
var sortDirections = map[domain.SortDirection]string{
|
|
domain.SortAsc: "ASC",
|
|
domain.SortDesc: "DESC",
|
|
}
|
|
|
|
func orderClause(sort domain.Sort) string {
|
|
column, ok := sortColumns[sort.Field]
|
|
direction, directionOk := sortDirections[sort.Direction]
|
|
if !ok || !directionOk {
|
|
column, direction = sortColumns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
|
|
}
|
|
if sort.Field == domain.SortByID {
|
|
return column + " " + direction
|
|
}
|
|
return column + " " + direction + ", id DESC"
|
|
}
|
|
|
|
func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult, error) {
|
|
filters.Page = max(filters.Page, 1)
|
|
filters.Size = max(filters.Size, 0)
|
|
filters.Q = strings.TrimSpace(filters.Q)
|
|
if filters.Q != "" {
|
|
return a.findMatchingTitle(filters)
|
|
}
|
|
var total int64
|
|
if err := a.scoped(filters).Count(&total).Error; err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
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)
|
|
}
|
|
err := ordered.Find(&entityArticles).Error
|
|
if err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
items, err := a.withAuthors(a.db, toDomainArticles(entityArticles))
|
|
if err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
return domain.PageResult{Items: items, Total: int(total)}, nil
|
|
}
|
|
|
|
func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.PageResult, error) {
|
|
var candidates []entity.Article
|
|
if err := a.scoped(filters).Select("id", "title").Order(orderClause(filters.Sort)).Find(&candidates).Error; err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
needle := foldForSearch(filters.Q)
|
|
matchingIds := make([]uint32, 0)
|
|
for _, candidate := range candidates {
|
|
if strings.Contains(foldForSearch(candidate.Title), needle) {
|
|
matchingIds = append(matchingIds, candidate.ID)
|
|
}
|
|
}
|
|
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]
|
|
}
|
|
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
|
|
if len(pageIds) == 0 {
|
|
return page, nil
|
|
}
|
|
var entityArticles []entity.Article
|
|
for _, chunk := range chunkIDs(pageIds) {
|
|
var loaded []entity.Article
|
|
if err := a.db.Model(&entity.Article{}).Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
entityArticles = append(entityArticles, loaded...)
|
|
}
|
|
byId := make(map[uint32]entity.Article, len(entityArticles))
|
|
for _, entityArticle := range entityArticles {
|
|
byId[entityArticle.ID] = entityArticle
|
|
}
|
|
ordered := make([]entity.Article, 0, len(pageIds))
|
|
for _, id := range pageIds {
|
|
if entityArticle, found := byId[id]; found {
|
|
ordered = append(ordered, entityArticle)
|
|
}
|
|
}
|
|
items, err := a.withAuthors(a.db, toDomainArticles(ordered))
|
|
if err != nil {
|
|
return domain.PageResult{}, err
|
|
}
|
|
page.Items = items
|
|
return page, nil
|
|
}
|
|
|
|
func (a *ArticleRepository) CountByVisibility(onlineOnly bool) (domain.Counts, error) {
|
|
var online, draft int64
|
|
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", true).Count(&online).Error; err != nil {
|
|
return domain.Counts{}, err
|
|
}
|
|
if !onlineOnly {
|
|
if err := a.db.Model(&entity.Article{}).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 (a *ArticleRepository) CountVisibleTo(userID uint32) (domain.Counts, error) {
|
|
var online, draft int64
|
|
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", true).Count(&online).Error; err != nil {
|
|
return domain.Counts{}, err
|
|
}
|
|
if err := a.db.Model(&entity.Article{}).Where("is_online = ? AND created_by = ?", false, userID).Count(&draft).Error; err != nil {
|
|
return domain.Counts{}, err
|
|
}
|
|
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 {
|
|
domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
|
|
}
|
|
return domainArticles
|
|
}
|
|
|
|
func (a *ArticleRepository) withAuthors(db *gorm.DB, articles []domain.Article) ([]domain.Article, error) {
|
|
ids := make([]uint32, 0, len(articles))
|
|
for _, article := range articles {
|
|
ids = append(ids, article.ID)
|
|
}
|
|
bylines, err := loadBylines(db, ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for index := range articles {
|
|
articles[index].Authors = bylines[articles[index].ID]
|
|
if articles[index].Authors == nil {
|
|
articles[index].Authors = []authordomain.Byline{}
|
|
}
|
|
}
|
|
return articles, nil
|
|
}
|
|
|
|
func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article) (domain.Article, error) {
|
|
loaded, err := a.withAuthors(db, []domain.Article{article})
|
|
if err != nil {
|
|
return domain.Article{}, err
|
|
}
|
|
return loaded[0], nil
|
|
}
|
|
|
|
var errArticleSiteChanged = errors.New("the article moved to another site while it was being locked")
|
|
|
|
const (
|
|
idChunkSize = 500
|
|
maxUpdateAttempts = 5
|
|
maxSiteLockRounds = 5
|
|
)
|
|
|
|
func chunkIDs(ids []uint32) [][]uint32 {
|
|
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
|
|
for start := 0; start < len(ids); start += idChunkSize {
|
|
chunks = append(chunks, ids[start:min(start+idChunkSize, len(ids))])
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
func loadBylines(db *gorm.DB, articleIDs []uint32) (map[uint32][]authordomain.Byline, error) {
|
|
bylines := make(map[uint32][]authordomain.Byline, len(articleIDs))
|
|
if len(articleIDs) == 0 {
|
|
return bylines, nil
|
|
}
|
|
var credits []entity.ArticleAuthor
|
|
for _, chunk := range chunkIDs(articleIDs) {
|
|
var loaded []entity.ArticleAuthor
|
|
if err := db.Where("article_id IN ?", chunk).Order("position ASC, id ASC").Find(&loaded).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
credits = append(credits, loaded...)
|
|
}
|
|
sort.SliceStable(credits, func(i, j int) bool {
|
|
if credits[i].Position != credits[j].Position {
|
|
return credits[i].Position < credits[j].Position
|
|
}
|
|
return credits[i].ID < credits[j].ID
|
|
})
|
|
if len(credits) == 0 {
|
|
return bylines, nil
|
|
}
|
|
authorIDs := make([]uint32, 0, len(credits))
|
|
for _, credit := range credits {
|
|
authorIDs = append(authorIDs, credit.AuthorID)
|
|
}
|
|
var found []entity.Author
|
|
for _, chunk := range chunkIDs(authorIDs) {
|
|
var loaded []entity.Author
|
|
if err := db.Where("id IN ?", chunk).Find(&loaded).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
found = append(found, loaded...)
|
|
}
|
|
byID := make(map[uint32]entity.Author, len(found))
|
|
for _, model := range found {
|
|
byID[model.ID] = model
|
|
}
|
|
for _, credit := range credits {
|
|
if model, ok := byID[credit.AuthorID]; ok {
|
|
bylines[credit.ArticleID] = append(bylines[credit.ArticleID], mappers.AuthorToDomain(model, 0).Byline())
|
|
}
|
|
}
|
|
return bylines, nil
|
|
}
|
|
|
|
func replaceCredits(tx *gorm.DB, articleID uint32, authorIDs []uint32) error {
|
|
if err := tx.Where("article_id = ?", articleID).Delete(&entity.ArticleAuthor{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for position, authorID := range authorIDs {
|
|
credit := entity.ArticleAuthor{ArticleID: articleID, AuthorID: authorID, Position: position}
|
|
if err := tx.Create(&credit).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func foldForSearch(value string) string {
|
|
folder := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
|
folded, _, err := transform.String(folder, value)
|
|
if err != nil {
|
|
return strings.ToLower(value)
|
|
}
|
|
return strings.ToLower(folded)
|
|
}
|
|
|
|
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
|
|
var article entity.Article
|
|
err := a.db.Model(&entity.Article{}).First(&article, id).Error
|
|
if err != nil {
|
|
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
|
}
|
|
|
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
|
|
}
|
|
|
|
func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
|
var article entity.Article
|
|
err := a.db.Model(&entity.Article{}).Where("title = ?", name).First(&article).Error
|
|
if err != nil {
|
|
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
|
}
|
|
|
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(article))
|
|
}
|
|
|
|
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
|
created := mappers.ArticleToModel(article.Opened())
|
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
|
if article.SiteID != 0 {
|
|
if _, err := lockSite(tx, article.SiteID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := ensureStoryLanguageFree(tx, created.StoryID, created.Lang); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureSlugFree(tx, created.SiteID, created.Lang, created.Slug, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := openStory(tx, &created); err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Create(&created).Error; err != nil {
|
|
return err
|
|
}
|
|
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
|
|
})
|
|
if err != nil {
|
|
return domain.Article{}, a.explainSlugFailure(created.SiteID, created.Lang, created.Slug, 0, err)
|
|
}
|
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
|
|
}
|
|
|
|
func ensureStoryLanguageFree(tx *gorm.DB, storyID uint32, lang string) error {
|
|
if storyID == 0 {
|
|
return nil
|
|
}
|
|
var stories int64
|
|
if err := tx.Model(&entity.Story{}).Where("id = ?", storyID).Count(&stories).Error; err != nil {
|
|
return err
|
|
}
|
|
if stories == 0 {
|
|
return domain.ErrArticleNotFound
|
|
}
|
|
var versions int64
|
|
if err := tx.Model(&entity.Article{}).Where("story_id = ? AND lang = ?", storyID, lang).Count(&versions).Error; err != nil {
|
|
return err
|
|
}
|
|
if versions > 0 {
|
|
return domain.ErrLanguageTaken
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func openStory(tx *gorm.DB, created *entity.Article) error {
|
|
if created.StoryID != 0 {
|
|
return nil
|
|
}
|
|
story := entity.Story{}
|
|
if err := tx.Create(&story).Error; err != nil {
|
|
return err
|
|
}
|
|
created.StoryID = story.ID
|
|
return nil
|
|
}
|
|
|
|
func articleSlugInLanguage(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
|
|
var total int64
|
|
err := db.Model(&entity.Article{}).Where("site_id = ? AND lang = ? AND slug = ? AND id <> ?", siteID, lang, slug, exceptID).Count(&total).Error
|
|
return total > 0, err
|
|
}
|
|
|
|
func ensureSlugFree(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) error {
|
|
if slug == "" {
|
|
return nil
|
|
}
|
|
taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taken {
|
|
return domain.ErrSlugTaken
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ArticleRepository) explainSlugFailure(siteID uint32, lang string, slug string, exceptID uint32, cause error) error {
|
|
if errors.Is(cause, domain.ErrSlugTaken) {
|
|
return cause
|
|
}
|
|
if err := ensureSlugFree(a.db, siteID, lang, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
|
|
return err
|
|
}
|
|
return cause
|
|
}
|
|
|
|
func (a *ArticleRepository) SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error) {
|
|
return articleSlugInLanguage(a.db, siteID, lang, slug, exceptID)
|
|
}
|
|
|
|
func (a *ArticleRepository) GetAll() ([]domain.Article, error) {
|
|
var articles []entity.Article
|
|
err := a.db.Model(&entity.Article{}).Find(&articles).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var domainArticles = make([]domain.Article, 0)
|
|
for _, article := range articles {
|
|
domainArticles = append(domainArticles, mappers.ArticleToDomain(article))
|
|
}
|
|
|
|
return domainArticles, nil
|
|
}
|
|
|
|
func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article, error) {
|
|
return a.updateColumns(id, map[string]any{"body": body})
|
|
}
|
|
|
|
func (a *ArticleRepository) updateColumns(id uint32, columns map[string]any) (domain.Article, error) {
|
|
columns["updated_at"] = time.Now()
|
|
if err := a.db.Model(&entity.Article{}).Where("id = ?", id).Updates(columns).Error; err != nil {
|
|
return domain.Article{}, err
|
|
}
|
|
return a.Get(id)
|
|
}
|
|
|
|
func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Article, error) {
|
|
var updated domain.Article
|
|
var err error
|
|
for attempt := 0; attempt < maxUpdateAttempts; attempt++ {
|
|
updated, err = a.updateOnce(id, update)
|
|
if err == nil || !database.IsLockContention(a.db, err) {
|
|
break
|
|
}
|
|
}
|
|
return updated, err
|
|
}
|
|
|
|
func (a *ArticleRepository) updateOnce(id uint32, update domain.Update) (domain.Article, error) {
|
|
for round := 0; round < maxSiteLockRounds; round++ {
|
|
var probe entity.Article
|
|
if err := a.db.Model(&entity.Article{}).Select("id", "site_id").First(&probe, id).Error; err != nil {
|
|
return domain.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
|
}
|
|
updated, err := a.updateAt(id, update, probe.SiteID)
|
|
if !errors.Is(err, errArticleSiteChanged) {
|
|
return updated, err
|
|
}
|
|
}
|
|
return domain.Article{}, transaction.ErrLockContention
|
|
}
|
|
|
|
func (a *ArticleRepository) updateAt(id uint32, update domain.Update, probedSiteID uint32) (domain.Article, error) {
|
|
var localArticle entity.Article
|
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
|
locked, err := lockArticleBehindSites(tx, id, update, probedSiteID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
localArticle = locked
|
|
if update.IfRevision != nil && localArticle.EditRevision != *update.IfRevision {
|
|
return &domain.RevisionConflictError{Current: localArticle.EditRevision}
|
|
}
|
|
before := localArticle
|
|
applyUpdate(&localArticle, update)
|
|
if update.Slug != nil || update.SiteID != nil {
|
|
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
authorsChanged, err := authorsDiffer(tx, id, update.AuthorIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
impact := domain.ImpactOf(mappers.ArticleToDomain(before), mappers.ArticleToDomain(localArticle), authorsChanged)
|
|
revised := mappers.ArticleToDomain(localArticle).Revised(impact)
|
|
localArticle.EditRevision = revised.EditRevision
|
|
localArticle.ContentRevision = revised.ContentRevision
|
|
localArticle.UpdatedAt = time.Now()
|
|
if err := writeArticleColumns(tx, localArticle, before.EditRevision); err != nil {
|
|
return err
|
|
}
|
|
if update.AuthorIDs != nil {
|
|
return replaceCredits(tx, id, *update.AuthorIDs)
|
|
}
|
|
return nil
|
|
})
|
|
if errors.Is(err, domain.ErrRevisionConflict) || errors.Is(err, errArticleSiteChanged) {
|
|
return domain.Article{}, err
|
|
}
|
|
if err != nil {
|
|
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
|
|
}
|
|
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
|
}
|
|
|
|
func lockArticleBehindSites(tx *gorm.DB, id uint32, update domain.Update, probedSiteID uint32) (entity.Article, error) {
|
|
if update.SiteID != nil || update.Slug != nil {
|
|
if err := lockSitesInOrder(tx, probedSiteID, update.SiteID); err != nil {
|
|
return entity.Article{}, err
|
|
}
|
|
}
|
|
var locked entity.Article
|
|
if err := tx.Model(&entity.Article{}).Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
|
|
return entity.Article{}, mapNotFoundErr(err, domain.ErrArticleNotFound)
|
|
}
|
|
if locked.SiteID != probedSiteID {
|
|
return entity.Article{}, errArticleSiteChanged
|
|
}
|
|
return locked, nil
|
|
}
|
|
|
|
func lockSitesInOrder(tx *gorm.DB, currentSiteID uint32, targetSiteID *uint32) error {
|
|
siteIDs := []uint32{currentSiteID}
|
|
if targetSiteID != nil {
|
|
siteIDs = append(siteIDs, *targetSiteID)
|
|
}
|
|
slices.Sort(siteIDs)
|
|
for _, siteID := range slices.Compact(siteIDs) {
|
|
if siteID == 0 && targetSiteID != nil {
|
|
continue
|
|
}
|
|
if _, err := lockSite(tx, siteID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func authorsDiffer(tx *gorm.DB, articleID uint32, authorIDs *[]uint32) (bool, error) {
|
|
if authorIDs == nil {
|
|
return false, nil
|
|
}
|
|
var current []uint32
|
|
if err := tx.Model(&entity.ArticleAuthor{}).Where("article_id = ?", articleID).Order("position ASC, id ASC").Pluck("author_id", ¤t).Error; err != nil {
|
|
return false, err
|
|
}
|
|
return !slices.Equal(current, *authorIDs), nil
|
|
}
|
|
|
|
func writeArticleColumns(tx *gorm.DB, written entity.Article, expectedRevision uint32) error {
|
|
result := tx.Model(&entity.Article{}).Where("id = ? AND edit_revision = ?", written.ID, expectedRevision).Updates(map[string]any{
|
|
"title": written.Title,
|
|
"body": written.Body,
|
|
"site_id": written.SiteID,
|
|
"slug": written.Slug,
|
|
"excerpt": written.Excerpt,
|
|
"seo_title": written.SEOTitle,
|
|
"seo_description": written.SEODescription,
|
|
"cover_image_id": written.CoverImageID,
|
|
"cover_image_alt": written.CoverImageAlt,
|
|
"social_title": written.SocialTitle,
|
|
"social_description": written.SocialDescription,
|
|
"social_image_id": written.SocialImageID,
|
|
"canonical_url": written.CanonicalURL,
|
|
"noindex": written.Noindex,
|
|
"seo_dismissed": written.SEODismissed,
|
|
"edit_revision": written.EditRevision,
|
|
"content_revision": written.ContentRevision,
|
|
"updated_at": written.UpdatedAt,
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return &domain.RevisionConflictError{Current: currentRevision(tx, written.ID)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentRevision(tx *gorm.DB, id uint32) uint32 {
|
|
var revision uint32
|
|
_ = tx.Model(&entity.Article{}).Where("id = ?", id).Pluck("edit_revision", &revision).Error
|
|
return revision
|
|
}
|
|
|
|
func applyUpdate(target *entity.Article, update domain.Update) {
|
|
setString(&target.Title, update.Title)
|
|
setString(&target.Body, update.Body)
|
|
setString(&target.Slug, update.Slug)
|
|
setString(&target.Excerpt, update.Excerpt)
|
|
setString(&target.SEOTitle, update.SEOTitle)
|
|
setString(&target.SEODescription, update.SEODescription)
|
|
setString(&target.CoverImageAlt, update.CoverImageAlt)
|
|
setString(&target.SocialTitle, update.SocialTitle)
|
|
setString(&target.SocialDescription, update.SocialDescription)
|
|
setString(&target.CanonicalURL, update.CanonicalURL)
|
|
if update.SiteID != nil {
|
|
target.SiteID = *update.SiteID
|
|
}
|
|
if update.Noindex != nil {
|
|
target.Noindex = *update.Noindex
|
|
}
|
|
if update.CoverImageID.Set {
|
|
target.CoverImageID = update.CoverImageID.Value
|
|
}
|
|
if update.SocialImageID.Set {
|
|
target.SocialImageID = update.SocialImageID.Value
|
|
}
|
|
if update.SEODismissed != nil {
|
|
target.SEODismissed = mappers.ArticleToModel(domain.Article{SEODismissed: *update.SEODismissed}).SEODismissed
|
|
}
|
|
}
|
|
|
|
func setString(target *string, value *string) {
|
|
if value != nil {
|
|
*target = *value
|
|
}
|
|
}
|
|
|
|
func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Article, error) {
|
|
return a.updateColumns(id, map[string]any{"is_online": isOnline})
|
|
}
|
|
|
|
func (a *ArticleRepository) Delete(id uint32) error {
|
|
return a.db.Transaction(func(tx *gorm.DB) error {
|
|
var found entity.Article
|
|
if err := tx.First(&found, id).Error; err != nil {
|
|
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
|
}
|
|
if found.SiteID != 0 {
|
|
if _, err := lockSite(tx, found.SiteID); err != nil {
|
|
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 {
|
|
return err
|
|
}
|
|
return closeEmptyStory(tx, found.StoryID)
|
|
})
|
|
}
|
|
|
|
func closeEmptyStory(tx *gorm.DB, storyID uint32) error {
|
|
if storyID == 0 {
|
|
return nil
|
|
}
|
|
var remaining int64
|
|
if err := tx.Model(&entity.Article{}).Where("story_id = ?", storyID).Count(&remaining).Error; err != nil || remaining > 0 {
|
|
return err
|
|
}
|
|
return tx.Delete(&entity.Story{}, storyID).Error
|
|
}
|
|
|
|
func (a *ArticleRepository) ListByStory(storyID uint32) ([]domain.Article, error) {
|
|
var found []entity.Article
|
|
if err := a.db.Model(&entity.Article{}).Where("story_id = ?", storyID).Order("id ASC").Find(&found).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return toDomainArticles(found), nil
|
|
}
|
|
|
|
func (a *ArticleRepository) MoveToSite(ids []uint32, siteID uint32) error {
|
|
return a.db.Transaction(func(tx *gorm.DB) error {
|
|
for _, id := range ids {
|
|
var moving entity.Article
|
|
if err := tx.First(&moving, id).Error; err != nil {
|
|
return mapNotFoundErr(err, domain.ErrArticleNotFound)
|
|
}
|
|
if err := ensureSlugFree(tx, siteID, moving.Lang, moving.Slug, id); err != nil {
|
|
return err
|
|
}
|
|
moved := 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
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) {
|
|
var entityArticles []entity.Article
|
|
err := a.db.Model(&entity.Article{}).Where("body LIKE ? ESCAPE '!'", containsPattern(filename)).Order("id asc").Find(&entityArticles).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return toDomainArticles(entityArticles), nil
|
|
}
|
|
|
|
func (a *ArticleRepository) FindByImage(imageID uint32) ([]domain.Article, error) {
|
|
var entityArticles []entity.Article
|
|
err := a.db.Model(&entity.Article{}).Where("cover_image_id = ? OR social_image_id = ?", imageID, imageID).Order("id asc").Find(&entityArticles).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toDomainArticles(entityArticles), nil
|
|
}
|
|
|
|
func (a *ArticleRepository) ClearImage(imageID uint32) (domain.ImageReferences, error) {
|
|
references := domain.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
|
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := clearColumn(tx, "cover_image_id", imageID, &references.Cover); err != nil {
|
|
return err
|
|
}
|
|
return clearColumn(tx, "social_image_id", imageID, &references.Social)
|
|
})
|
|
if err != nil {
|
|
return domain.ImageReferences{}, err
|
|
}
|
|
return references, nil
|
|
}
|
|
|
|
func clearColumn(tx *gorm.DB, column string, imageID uint32, cleared *[]uint32) error {
|
|
if err := tx.Model(&entity.Article{}).Where(column+" = ?", imageID).Order("id ASC").Pluck("id", cleared).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(*cleared) == 0 {
|
|
return nil
|
|
}
|
|
return tx.Model(&entity.Article{}).Where("id IN ?", *cleared).UpdateColumn(column, nil).Error
|
|
}
|
|
|
|
func (a *ArticleRepository) RestoreImage(references domain.ImageReferences, imageID uint32) error {
|
|
return a.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := assignColumn(tx, "cover_image_id", references.Cover, imageID); err != nil {
|
|
return err
|
|
}
|
|
return assignColumn(tx, "social_image_id", references.Social, imageID)
|
|
})
|
|
}
|
|
|
|
func assignColumn(tx *gorm.DB, column string, articleIDs []uint32, imageID uint32) error {
|
|
if len(articleIDs) == 0 {
|
|
return nil
|
|
}
|
|
return tx.Model(&entity.Article{}).Where("id IN ?", articleIDs).UpdateColumn(column, imageID).Error
|
|
}
|
|
|
|
func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
|
|
changed := []uint32{}
|
|
if len(fromImageIDs) == 0 {
|
|
return changed, nil
|
|
}
|
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ? OR social_image_id IN ?", fromImageIDs, fromImageIDs).Order("id ASC").Pluck("id", &changed).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&entity.Article{}).Where("cover_image_id IN ?", fromImageIDs).UpdateColumn("cover_image_id", toImageID).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&entity.Article{}).Where("social_image_id IN ?", fromImageIDs).UpdateColumn("social_image_id", toImageID).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
var _ domain.Repository = &ArticleRepository{}
|
|
|
|
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)
|
|
return int(result.RowsAffected), result.Error
|
|
}
|
|
|
|
func (a *ArticleRepository) ListForAccount(creatorID uint32, authorID *uint32) ([]domain.Article, error) {
|
|
query := a.db.Model(&entity.Article{})
|
|
if authorID == nil {
|
|
query = query.Where("created_by = ?", creatorID)
|
|
} else {
|
|
credited := a.db.Model(&entity.ArticleAuthor{}).Select("article_id").Where("author_id = ?", *authorID)
|
|
query = query.Where("created_by = ? OR (is_online = ? AND id IN (?))", creatorID, true, credited)
|
|
}
|
|
var entityArticles []entity.Article
|
|
if err := query.Order("id asc").Find(&entityArticles).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return a.withAuthors(a.db, toDomainArticles(entityArticles))
|
|
}
|