feat: storage, language rules and migration
This commit is contained in:
1 parent
8c4a1825ab
commit
0806733d5c
70 files changed
+1771
-132
No files matched your search
@@ -17,7 +17,7 @@ var (
|
||||
ErrArticleOffline = errors.New("article is offline")
|
||||
|
||||
ErrInvalidSlug = errors.New("invalid slug")
|
||||
ErrSlugTaken = errors.New("another article on this site already uses that slug")
|
||||
ErrSlugTaken = errors.New("another article on this site in that language already uses that slug")
|
||||
ErrInvalidCanonicalURL = errors.New("invalid canonical url")
|
||||
)
|
||||
|
||||
@@ -134,6 +134,9 @@ type Article struct {
|
||||
SEODismissed []string `json:"seo_dismissed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Lang string `json:"lang"`
|
||||
StoryID uint32 `json:"story_id"`
|
||||
ContentRevision uint32 `json:"-"`
|
||||
}
|
||||
|
||||
func (a Article) UsesImage(imageID uint32) bool {
|
||||
|
||||
@@ -102,7 +102,7 @@ type Repository interface {
|
||||
Delete(id uint32) error
|
||||
FindByBodyContaining(filename string) ([]Article, error)
|
||||
FindByImage(imageID uint32) ([]Article, error)
|
||||
SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error)
|
||||
SlugTaken(siteID uint32, lang string, slug string, exceptID uint32) (bool, error)
|
||||
ClearImage(imageID uint32) (ImageReferences, error)
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package language
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidLanguage = errors.New("invalid language tag")
|
||||
ErrUnsupportedLanguage = errors.New("unsupported language")
|
||||
ErrNoDefaultLanguage = errors.New("the site has no default language")
|
||||
)
|
||||
|
||||
const (
|
||||
Unassigned = ""
|
||||
MaxTagLength = 35
|
||||
)
|
||||
|
||||
var supported = []string{"en", "en-GB", "en-US", "fr", "fr-CA", "pt", "pt-BR", "es", "de", "ar"}
|
||||
|
||||
var tagPattern = regexp.MustCompile(`^([A-Za-z]{2,3})(?:-([A-Za-z]{2}))?$`)
|
||||
|
||||
func Supported() []string {
|
||||
return slices.Clone(supported)
|
||||
}
|
||||
|
||||
func Normalize(tag string) (string, error) {
|
||||
parts := tagPattern.FindStringSubmatch(strings.TrimSpace(tag))
|
||||
if parts == nil {
|
||||
return "", ErrInvalidLanguage
|
||||
}
|
||||
normalized := strings.ToLower(parts[1])
|
||||
if parts[2] != "" {
|
||||
normalized += "-" + strings.ToUpper(parts[2])
|
||||
}
|
||||
if !slices.Contains(supported, normalized) {
|
||||
return "", ErrUnsupportedLanguage
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
type Suggestion struct {
|
||||
Tag string
|
||||
Found bool
|
||||
Substituted bool
|
||||
}
|
||||
|
||||
func Suggest(locale *string) Suggestion {
|
||||
if locale == nil {
|
||||
return Suggestion{}
|
||||
}
|
||||
tag, err := Normalize(*locale)
|
||||
if err == nil {
|
||||
return Suggestion{Tag: tag, Found: true}
|
||||
}
|
||||
if !errors.Is(err, ErrUnsupportedLanguage) {
|
||||
return Suggestion{}
|
||||
}
|
||||
base, _, _ := strings.Cut(strings.TrimSpace(*locale), "-")
|
||||
if fallback, err := Normalize(base); err == nil {
|
||||
return Suggestion{Tag: fallback, Found: true, Substituted: true}
|
||||
}
|
||||
return Suggestion{}
|
||||
}
|
||||
|
||||
type SiteLanguage struct {
|
||||
SiteID uint32
|
||||
Tag string
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
Confirmed bool
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package language
|
||||
|
||||
type Repository interface {
|
||||
ListBySite(siteID uint32) ([]SiteLanguage, error)
|
||||
LockStartingLanguage(siteID uint32) (string, error)
|
||||
ConfirmDefault(siteID uint32) error
|
||||
}
|
||||
@@ -5,16 +5,18 @@ import (
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
)
|
||||
|
||||
type Repositories struct {
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Languages language.Repository
|
||||
}
|
||||
|
||||
type Transactor interface {
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
const (
|
||||
articleSlugIndexName = "idx_articles_site_slug"
|
||||
articleSlugBackfillBatchSize = 500
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)"
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, lang, slug)"
|
||||
articleSlugLanguageMarker = "article_slug_index_per_language"
|
||||
)
|
||||
|
||||
type slugOwner struct {
|
||||
siteID uint32
|
||||
lang string
|
||||
slug string
|
||||
}
|
||||
|
||||
@@ -26,7 +28,7 @@ type slugAssignment struct {
|
||||
|
||||
func BackfillArticleSlugs(db *gorm.DB) error {
|
||||
var rows []entity.Article
|
||||
if err := db.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
if err := db.Unscoped().Select("id", "site_id", "lang", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
assignments := planSlugAssignments(rows)
|
||||
@@ -60,7 +62,7 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
|
||||
}
|
||||
continue
|
||||
}
|
||||
owner := slugOwner{row.SiteID, row.Slug}
|
||||
owner := slugOwner{row.SiteID, row.Lang, row.Slug}
|
||||
if row.Slug == "" || claimed[owner] {
|
||||
pending = append(pending, row)
|
||||
continue
|
||||
@@ -74,25 +76,41 @@ func planSlugAssignments(rows []entity.Article) []slugAssignment {
|
||||
base = snapshotSlug(row.Title)
|
||||
attempt = 1
|
||||
}
|
||||
slug := firstFreeSnapshotSlug(claimed, row.SiteID, base, attempt)
|
||||
claimed[slugOwner{row.SiteID, slug}] = true
|
||||
slug := firstFreeSnapshotSlug(claimed, row.SiteID, row.Lang, base, attempt)
|
||||
claimed[slugOwner{row.SiteID, row.Lang, slug}] = true
|
||||
assignments = append(assignments, slugAssignment{row.ID, slug})
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
|
||||
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, base string, attempt int) string {
|
||||
func firstFreeSnapshotSlug(claimed map[slugOwner]bool, siteID uint32, lang string, base string, attempt int) string {
|
||||
for ; ; attempt++ {
|
||||
candidate := snapshotSlugWithSuffix(base, attempt)
|
||||
if !claimed[slugOwner{siteID, candidate}] {
|
||||
if !claimed[slugOwner{siteID, lang, candidate}] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureArticleSlugIndex(db *gorm.DB) error {
|
||||
if db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName) {
|
||||
replaced, err := markerApplied(db, articleSlugLanguageMarker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
indexed := db.Migrator().HasIndex(&entity.Article{}, articleSlugIndexName)
|
||||
if replaced && indexed {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(articleSlugIndexStatement).Error
|
||||
if indexed {
|
||||
if err := db.Migrator().DropIndex(&entity.Article{}, articleSlugIndexName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Exec(articleSlugIndexStatement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if replaced {
|
||||
return nil
|
||||
}
|
||||
return applyMarker(db, articleSlugLanguageMarker)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
articleStoriesMarker = "article_stories"
|
||||
articleStoryLanguageIndex = "idx_articles_story_lang"
|
||||
articleStoryLanguageIndexSQL = "CREATE UNIQUE INDEX idx_articles_story_lang ON articles (story_id, lang)"
|
||||
)
|
||||
|
||||
func BackfillArticleStories(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
applied, err := markerApplied(tx, articleStoriesMarker)
|
||||
if err != nil || applied {
|
||||
return err
|
||||
}
|
||||
var ids []uint32
|
||||
if err := tx.Unscoped().Model(&entity.Article{}).Where("story_id = ?", 0).Order("id ASC").Pluck("id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
story := entity.Story{}
|
||||
if err := tx.Create(&story).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("story_id", story.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return applyMarker(tx, articleStoriesMarker)
|
||||
})
|
||||
}
|
||||
|
||||
func ensureArticleStoryLanguageIndex(db *gorm.DB) error {
|
||||
if db.Migrator().HasIndex(&entity.Article{}, articleStoryLanguageIndex) {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(articleStoryLanguageIndexSQL).Error
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB, publicURL string) error {
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{})
|
||||
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{}, &models.MigrationMarker{}, &models.Story{}, &models.SiteLanguage{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,6 +35,15 @@ func Migrate(db *gorm.DB, publicURL string) error {
|
||||
if err := BackfillArticleSlugs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := BackfillArticleStories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := SeedSiteLanguageSuggestions(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureArticleStoryLanguageIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureArticleSlugIndex(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const siteLanguageSuggestionsMarker = "site_language_suggestions"
|
||||
|
||||
func SeedSiteLanguageSuggestions(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
applied, err := markerApplied(tx, siteLanguageSuggestionsMarker)
|
||||
if err != nil || applied {
|
||||
return err
|
||||
}
|
||||
var sites []entity.Site
|
||||
if err := tx.Order("id ASC").Find(&sites).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, site := range sites {
|
||||
suggestion := language.Suggest(site.Locale)
|
||||
if !suggestion.Found {
|
||||
continue
|
||||
}
|
||||
row := entity.SiteLanguage{SiteID: site.ID, Lang: suggestion.Tag, IsDefault: true, Enabled: true, Confirmed: false}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return applyMarker(tx, siteLanguageSuggestionsMarker)
|
||||
})
|
||||
}
|
||||
@@ -52,6 +52,9 @@ func ArticleToDomain(m models.Article) article.Article {
|
||||
SEODismissed: decodeDismissed(m.SEODismissed),
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
Lang: m.Lang,
|
||||
StoryID: m.StoryID,
|
||||
ContentRevision: m.ContentRevision,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,5 +79,8 @@ func ArticleToModel(a article.Article) models.Article {
|
||||
SEODismissed: encodeDismissed(a.SEODismissed),
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
Lang: a.Lang,
|
||||
StoryID: a.StoryID,
|
||||
ContentRevision: a.ContentRevision,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func SiteLanguageToDomain(m models.SiteLanguage) language.SiteLanguage {
|
||||
return language.SiteLanguage{
|
||||
SiteID: m.SiteID,
|
||||
Tag: m.Lang,
|
||||
IsDefault: m.IsDefault,
|
||||
Enabled: m.Enabled,
|
||||
Confirmed: m.Confirmed,
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,7 @@ type Article struct {
|
||||
SEODismissed string `gorm:"column:seo_dismissed;type:text"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
Lang string `gorm:"size:35;not null;default:''"`
|
||||
StoryID uint32 `gorm:"not null;default:0"`
|
||||
ContentRevision uint32 `gorm:"not null;default:1"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SiteLanguage struct {
|
||||
SiteID uint32 `gorm:"primaryKey;autoIncrement:false"`
|
||||
Lang string `gorm:"primaryKey;size:35;not null"`
|
||||
IsDefault bool `gorm:"not null;default:false"`
|
||||
Enabled bool `gorm:"not null;default:true"`
|
||||
Confirmed bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Story struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
@@ -216,7 +216,12 @@ func (a *ArticleRepository) withAuthorsOne(db *gorm.DB, article domain.Article)
|
||||
return loaded[0], nil
|
||||
}
|
||||
|
||||
const idChunkSize = 500
|
||||
var preservedArticleColumns = []string{"lang", "story_id", "content_revision"}
|
||||
|
||||
const (
|
||||
idChunkSize = 500
|
||||
initialContentRevision = 1
|
||||
)
|
||||
|
||||
func chunkIDs(ids []uint32) [][]uint32 {
|
||||
chunks := make([][]uint32, 0, len(ids)/idChunkSize+1)
|
||||
@@ -316,13 +321,17 @@ func (a *ArticleRepository) GetByName(name string) (domain.Article, error) {
|
||||
|
||||
func (a *ArticleRepository) Create(article domain.Article) (domain.Article, error) {
|
||||
created := mappers.ArticleToModel(article)
|
||||
created.ContentRevision = initialContentRevision
|
||||
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||
if article.SiteID != 0 {
|
||||
if _, err := lockSite(tx, article.SiteID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ensureSlugFree(tx, article.SiteID, article.Slug, 0); err != nil {
|
||||
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 {
|
||||
@@ -331,22 +340,34 @@ func (a *ArticleRepository) Create(article domain.Article) (domain.Article, erro
|
||||
return replaceCredits(tx, created.ID, authordomain.BylineIDs(article.Authors))
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, a.explainSlugFailure(article.SiteID, article.Slug, 0, err)
|
||||
return domain.Article{}, a.explainSlugFailure(created.SiteID, created.Lang, created.Slug, 0, err)
|
||||
}
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(created))
|
||||
}
|
||||
|
||||
func articleSlugInUse(db *gorm.DB, siteID uint32, slug string, exceptID uint32) (bool, error) {
|
||||
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 slug = ? AND id <> ?", siteID, slug, exceptID).Count(&total).Error
|
||||
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, slug string, exceptID uint32) error {
|
||||
func ensureSlugFree(db *gorm.DB, siteID uint32, lang string, slug string, exceptID uint32) error {
|
||||
if slug == "" {
|
||||
return nil
|
||||
}
|
||||
taken, err := articleSlugInUse(db, siteID, slug, exceptID)
|
||||
taken, err := articleSlugInLanguage(db, siteID, lang, slug, exceptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -356,18 +377,18 @@ func ensureSlugFree(db *gorm.DB, siteID uint32, slug string, exceptID uint32) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) explainSlugFailure(siteID uint32, slug string, exceptID uint32, cause error) error {
|
||||
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, slug, exceptID); err != nil && errors.Is(err, domain.ErrSlugTaken) {
|
||||
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, slug string, exceptID uint32) (bool, error) {
|
||||
return articleSlugInUse(a.db, siteID, slug, exceptID)
|
||||
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) {
|
||||
@@ -393,7 +414,7 @@ func (a *ArticleRepository) UpdateBody(id uint32, body string) (domain.Article,
|
||||
}
|
||||
|
||||
localArticle.Body = body
|
||||
err = a.db.Save(&localArticle).Error
|
||||
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
@@ -419,11 +440,11 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
}
|
||||
applyUpdate(&localArticle, update)
|
||||
if update.Slug != nil || update.SiteID != nil {
|
||||
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Slug, id); err != nil {
|
||||
if err := ensureSlugFree(tx, localArticle.SiteID, localArticle.Lang, localArticle.Slug, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Save(&localArticle).Error; err != nil {
|
||||
if err := tx.Omit(preservedArticleColumns...).Save(&localArticle).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if update.AuthorIDs != nil {
|
||||
@@ -432,7 +453,7 @@ func (a *ArticleRepository) Update(id uint32, update domain.Update) (domain.Arti
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Slug, id, err)
|
||||
return domain.Article{}, a.explainSlugFailure(localArticle.SiteID, localArticle.Lang, localArticle.Slug, id, err)
|
||||
}
|
||||
return a.withAuthorsOne(a.db, mappers.ArticleToDomain(localArticle))
|
||||
}
|
||||
@@ -478,7 +499,7 @@ func (a *ArticleRepository) UpdateIsOnline(id uint32, isOnline bool) (domain.Art
|
||||
}
|
||||
|
||||
localArticle.IsOnline = isOnline
|
||||
err = a.db.Save(&localArticle).Error
|
||||
err = a.db.Omit(preservedArticleColumns...).Save(&localArticle).Error
|
||||
if err != nil {
|
||||
return domain.Article{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SiteLanguageRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSiteLanguageRepository(db *gorm.DB) *SiteLanguageRepository {
|
||||
return &SiteLanguageRepository{db}
|
||||
}
|
||||
|
||||
func (s *SiteLanguageRepository) ListBySite(siteID uint32) ([]domain.SiteLanguage, error) {
|
||||
var found []entity.SiteLanguage
|
||||
if err := s.db.Where("site_id = ?", siteID).Order("is_default DESC, lang ASC").Find(&found).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
languages := make([]domain.SiteLanguage, 0, len(found))
|
||||
for _, row := range found {
|
||||
languages = append(languages, mappers.SiteLanguageToDomain(row))
|
||||
}
|
||||
return languages, nil
|
||||
}
|
||||
|
||||
func (s *SiteLanguageRepository) LockStartingLanguage(siteID uint32) (string, error) {
|
||||
if _, err := lockSite(s.db, siteID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defaultLanguage, found, err := findDefaultLanguage(s.db, siteID)
|
||||
if err != nil || !found || !defaultLanguage.Confirmed {
|
||||
return domain.Unassigned, err
|
||||
}
|
||||
return defaultLanguage.Lang, nil
|
||||
}
|
||||
|
||||
func (s *SiteLanguageRepository) ConfirmDefault(siteID uint32) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if _, err := lockSite(tx, siteID); err != nil {
|
||||
return err
|
||||
}
|
||||
defaultLanguage, err := requireDefaultLanguage(tx, siteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if defaultLanguage.Confirmed {
|
||||
return nil
|
||||
}
|
||||
if err := markConfirmed(tx, defaultLanguage); err != nil {
|
||||
return err
|
||||
}
|
||||
return assignUnassignedArticles(tx, defaultLanguage)
|
||||
})
|
||||
}
|
||||
|
||||
func findDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, bool, error) {
|
||||
var defaults []entity.SiteLanguage
|
||||
if err := db.Where("site_id = ? AND is_default = ?", siteID, true).Limit(1).Find(&defaults).Error; err != nil || len(defaults) == 0 {
|
||||
return entity.SiteLanguage{}, false, err
|
||||
}
|
||||
return defaults[0], true, nil
|
||||
}
|
||||
|
||||
func requireDefaultLanguage(db *gorm.DB, siteID uint32) (entity.SiteLanguage, error) {
|
||||
defaultLanguage, found, err := findDefaultLanguage(db, siteID)
|
||||
if err != nil {
|
||||
return entity.SiteLanguage{}, err
|
||||
}
|
||||
if !found {
|
||||
return entity.SiteLanguage{}, domain.ErrNoDefaultLanguage
|
||||
}
|
||||
return defaultLanguage, nil
|
||||
}
|
||||
|
||||
func markConfirmed(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error {
|
||||
return tx.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, defaultLanguage.Lang).UpdateColumn("confirmed", true).Error
|
||||
}
|
||||
|
||||
func assignUnassignedArticles(tx *gorm.DB, defaultLanguage entity.SiteLanguage) error {
|
||||
return tx.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", defaultLanguage.SiteID, domain.Unassigned).UpdateColumn("lang", defaultLanguage.Lang).Error
|
||||
}
|
||||
|
||||
var _ domain.Repository = &SiteLanguageRepository{}
|
||||
@@ -20,11 +20,12 @@ func NewTransactor(db *gorm.DB) *Transactor {
|
||||
func (t *Transactor) Run(ctx context.Context, work func(domain.Repositories) error) error {
|
||||
return t.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return work(domain.Repositories{
|
||||
Articles: NewArticleRepository(tx),
|
||||
Sites: NewSiteRepository(tx),
|
||||
Authors: NewAuthorRepository(tx),
|
||||
Entries: NewEntryRepository(tx),
|
||||
Images: NewImageRepository(tx),
|
||||
Articles: NewArticleRepository(tx),
|
||||
Sites: NewSiteRepository(tx),
|
||||
Authors: NewAuthorRepository(tx),
|
||||
Entries: NewEntryRepository(tx),
|
||||
Images: NewImageRepository(tx),
|
||||
Languages: NewSiteLanguageRepository(tx),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -126,11 +126,11 @@ func (u *CheckArticleSEOUseCase) checkSlug(candidate article.Article) (article.S
|
||||
if candidate.Slug == "" {
|
||||
return available, nil
|
||||
}
|
||||
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Slug, candidate.ID)
|
||||
taken, err := u.articleRepository.SlugTaken(candidate.SiteID, candidate.Lang, candidate.Slug, candidate.ID)
|
||||
if err != nil || !taken {
|
||||
return available, err
|
||||
}
|
||||
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Slug, candidate.ID, firstSuggestedAttempt)
|
||||
suggestion, err := freeSlug(u.articleRepository, candidate.SiteID, candidate.Lang, candidate.Slug, candidate.ID, firstSuggestedAttempt)
|
||||
if err != nil {
|
||||
return article.SEOSlugCheck{}, err
|
||||
}
|
||||
|
||||
@@ -7,16 +7,17 @@ import (
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/domain/transaction"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type CreateArticleUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
transactor transaction.Transactor
|
||||
bodyRenderer article.BodyRenderer
|
||||
recorder audit.Recorder
|
||||
sites site.Repository
|
||||
authors author.Repository
|
||||
}
|
||||
|
||||
type CreateArticleCommand struct {
|
||||
@@ -26,8 +27,8 @@ type CreateArticleCommand struct {
|
||||
Caller ContentCaller
|
||||
}
|
||||
|
||||
func NewCreateArticleUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
|
||||
func NewCreateArticleUseCase(transactor transaction.Transactor, bodyRenderer article.BodyRenderer, recorder audit.Recorder, sites site.Repository, authors author.Repository) *CreateArticleUseCase {
|
||||
return &CreateArticleUseCase{transactor: transactor, bodyRenderer: bodyRenderer, recorder: recorder, sites: sites, authors: authors}
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) defaultSiteID() (uint32, error) {
|
||||
@@ -71,7 +72,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return article.Article{}, err
|
||||
}
|
||||
|
||||
created, err := g.createWithFreeSlug(article.Article{
|
||||
created, err := g.createWithFreeSlug(ctx, article.Article{
|
||||
Title: createArticle.Title,
|
||||
Body: createArticle.Body,
|
||||
IsOnline: false,
|
||||
@@ -90,15 +91,9 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
|
||||
return renderArticle(g.bodyRenderer, created, createArticle.Format)
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (article.Article, error) {
|
||||
base := article.GenerateSlug(draft.Title)
|
||||
func (g *CreateArticleUseCase) createWithFreeSlug(ctx context.Context, draft article.Article) (article.Article, error) {
|
||||
for attempt := 0; attempt < maxCreateSlugAttempts; attempt++ {
|
||||
slug, err := freeSlug(g.articleRepository, draft.SiteID, base, 0, firstSlugAttempt)
|
||||
if err != nil {
|
||||
return article.Article{}, err
|
||||
}
|
||||
draft.Slug = slug
|
||||
created, err := g.articleRepository.Create(draft)
|
||||
created, err := g.createInTransaction(ctx, draft)
|
||||
if !isSlugTakenError(err) {
|
||||
return created, err
|
||||
}
|
||||
@@ -106,6 +101,24 @@ func (g *CreateArticleUseCase) createWithFreeSlug(draft article.Article) (articl
|
||||
return article.Article{}, article.ErrSlugTaken
|
||||
}
|
||||
|
||||
func (g *CreateArticleUseCase) createInTransaction(ctx context.Context, draft article.Article) (article.Article, error) {
|
||||
var created article.Article
|
||||
err := g.transactor.Run(ctx, func(tx transaction.Repositories) error {
|
||||
lang, err := tx.Languages.LockStartingLanguage(draft.SiteID)
|
||||
if 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
|
||||
}
|
||||
created, err = tx.Articles.Create(draft)
|
||||
return err
|
||||
})
|
||||
return created, err
|
||||
}
|
||||
|
||||
func articleTarget(a article.Article) audit.Target {
|
||||
return audit.NewTarget(audit.TargetArticle, a.ID, a.Title)
|
||||
}
|
||||
@@ -156,12 +156,12 @@ func (g *UpdateArticleUseCase) resolveSlug(before article.Article, command Updat
|
||||
if slug == "" || (slug == before.Slug && target == before.SiteID) {
|
||||
return nil, nil
|
||||
}
|
||||
taken, err := g.articleRepository.SlugTaken(target, slug, before.ID)
|
||||
taken, err := g.articleRepository.SlugTaken(target, before.Lang, slug, before.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken {
|
||||
return nil, slugConflict(g.articleRepository, target, slug, before.ID)
|
||||
return nil, slugConflict(g.articleRepository, target, before.Lang, slug, before.ID)
|
||||
}
|
||||
if slug == before.Slug {
|
||||
return nil, nil
|
||||
@@ -174,7 +174,7 @@ func (g *UpdateArticleUseCase) raceConflict(before article.Article, command Upda
|
||||
if chosen != nil {
|
||||
slug = *chosen
|
||||
}
|
||||
return slugConflict(g.articleRepository, targetSiteID(before, command), slug, before.ID)
|
||||
return slugConflict(g.articleRepository, targetSiteID(before, command), before.Lang, slug, before.ID)
|
||||
}
|
||||
|
||||
func (g *UpdateArticleUseCase) destinationSite(command UpdateArticleCommand) (*site.Site, error) {
|
||||
|
||||
@@ -12,10 +12,10 @@ const (
|
||||
maxFreeSlugAttempts = 100
|
||||
)
|
||||
|
||||
func freeSlug(articles article.Repository, siteID uint32, base string, exceptID uint32, firstAttempt int) (string, error) {
|
||||
func freeSlug(articles article.Repository, siteID uint32, lang string, base string, exceptID uint32, firstAttempt int) (string, error) {
|
||||
for attempt := firstAttempt; attempt <= maxFreeSlugAttempts; attempt++ {
|
||||
candidate := article.SlugWithSuffix(base, attempt)
|
||||
taken, err := articles.SlugTaken(siteID, candidate, exceptID)
|
||||
taken, err := articles.SlugTaken(siteID, lang, candidate, exceptID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -26,8 +26,8 @@ func freeSlug(articles article.Repository, siteID uint32, base string, exceptID
|
||||
return "", article.ErrSlugTaken
|
||||
}
|
||||
|
||||
func slugConflict(articles article.Repository, siteID uint32, slug string, exceptID uint32) error {
|
||||
suggestion, err := freeSlug(articles, siteID, slug, exceptID, firstSuggestedAttempt)
|
||||
func slugConflict(articles article.Repository, siteID uint32, lang string, slug string, exceptID uint32) error {
|
||||
suggestion, err := freeSlug(articles, siteID, lang, slug, exceptID, firstSuggestedAttempt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user