feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

@@ -23,6 +23,7 @@ type Config struct {
type Driver interface {
Open(source string) (*gorm.DB, error)
NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup
IsLockContention(err error) bool
}
var drivers = map[string]Driver{
@@ -31,6 +32,15 @@ var drivers = map[string]Driver{
DriverPostgres: postgresDriver{},
}
func IsLockContentionFor(driverName string, err error) bool {
driver, ok := drivers[driverName]
return ok && err != nil && driver.IsLockContention(err)
}
func IsLockContention(db *gorm.DB, err error) bool {
return IsLockContentionFor(db.Name(), err)
}
type Connection struct {
DB *gorm.DB
driver Driver
@@ -2,6 +2,7 @@ package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"errors"
"fmt"
"net"
"strings"
@@ -22,6 +23,8 @@ const (
mysqlSessionSQLMode = "'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'"
mysqlTableOptionsKey = "gorm:table_options"
redactedPassword = "***"
mysqlLockNowait = 3572
mysqlLockWaitTimeout = 1205
)
type mysqlDriver struct{}
@@ -84,3 +87,8 @@ func tableOptionsFor(db *gorm.DB) *gorm.DB {
}
return db
}
func (mysqlDriver) IsLockContention(err error) bool {
var failure *mysqldriver.MySQLError
return errors.As(err, &failure) && (failure.Number == mysqlLockNowait || failure.Number == mysqlLockWaitTimeout)
}
@@ -2,6 +2,7 @@ package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"errors"
"fmt"
"net"
"net/url"
@@ -21,6 +22,7 @@ const (
postgresScheme = "postgres"
postgresUTCCallback = "renewcms:postgres_utc"
gormQueryCallback = "gorm:query"
postgresLockNotAvailable = "55P03"
)
var timeType = reflect.TypeOf(time.Time{})
@@ -106,3 +108,8 @@ func convertStructTimesToUTC(value reflect.Value) {
func (postgresDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewPostgresBackup(db, directory, now)
}
func (postgresDriver) IsLockContention(err error) bool {
var failure *pgconn.PgError
return errors.As(err, &failure) && failure.Code == postgresLockNotAvailable
}
@@ -2,6 +2,7 @@ package database
import (
domainUpdate "RenewCMS/internal/domain/update"
"errors"
"fmt"
"os"
"path/filepath"
@@ -13,7 +14,12 @@ import (
const DriverSQLite = "sqlite"
const sqliteBusyTimeoutMs = 5000
const (
sqliteBusyTimeoutMs = 5000
sqliteResultBusy = 5
sqliteResultLocked = 6
sqlitePrimaryMask = 0xff
)
type sqliteDriver struct{}
@@ -31,3 +37,12 @@ func (sqliteDriver) Open(file string) (*gorm.DB, error) {
func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup {
return NewSQLiteBackup(db, directory, now)
}
func (sqliteDriver) IsLockContention(err error) bool {
var coded interface{ Code() int }
if !errors.As(err, &coded) {
return false
}
primary := coded.Code() & sqlitePrimaryMask
return primary == sqliteResultBusy || primary == sqliteResultLocked
}
@@ -0,0 +1,27 @@
package mappers
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/infrastructure/persistence/models"
)
func LocalizationRowToDomain(m models.SiteLanguage) localization.Row {
return localization.Row{
Tag: m.Lang,
IsDefault: m.IsDefault,
Enabled: m.Enabled,
Confirmed: m.Confirmed,
SocialLocale: m.SocialLocale,
}
}
func LocalizationRowToModel(siteID uint32, row localization.Row) models.SiteLanguage {
return models.SiteLanguage{
SiteID: siteID,
Lang: row.Tag,
IsDefault: row.IsDefault,
Enabled: row.Enabled,
Confirmed: row.Confirmed,
SocialLocale: row.SocialLocale,
}
}
@@ -23,6 +23,7 @@ func SiteToDomain(m models.Site, articleCount int) site.Site {
SiteName: m.SiteName,
TitleTemplate: titleTemplate,
DefaultDescription: m.DefaultDescription,
PublicURLPattern: m.PublicURLPattern,
}
}
@@ -39,5 +40,6 @@ func SiteToModel(s site.Site) models.Site {
SiteName: s.SiteName,
TitleTemplate: s.TitleTemplate,
DefaultDescription: s.DefaultDescription,
PublicURLPattern: s.PublicURLPattern,
}
}
@@ -3,11 +3,12 @@ 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"`
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"`
SocialLocale *string `gorm:"size:35"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -14,6 +14,7 @@ type Site struct {
SiteName string `gorm:"size:100;not null;default:''"`
TitleTemplate string `gorm:"size:200"`
DefaultDescription string `gorm:"size:320;not null;default:''"`
PublicURLPattern *string `gorm:"size:512"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
@@ -3,6 +3,7 @@ package persistence
import (
domain "RenewCMS/internal/domain/article"
authordomain "RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"errors"
@@ -605,6 +606,11 @@ func (a *ArticleRepository) RepointImage(fromImageIDs []uint32, toImageID uint32
var _ domain.Repository = &ArticleRepository{}
func (a *ArticleRepository) AssignUnassignedLanguage(siteID uint32, lang string) (int, error) {
result := a.db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang = ?", siteID, language.Unassigned).UpdateColumn("lang", lang)
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 {
@@ -0,0 +1,149 @@
package persistence
import (
domain "RenewCMS/internal/domain/localization"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"gorm.io/gorm"
)
type LocalizationRepository struct {
db *gorm.DB
}
func NewLocalizationRepository(db *gorm.DB) *LocalizationRepository {
return &LocalizationRepository{db}
}
type languageTotal struct {
Lang string
Total int
}
func (l *LocalizationRepository) Rows(siteID uint32) ([]domain.Row, error) {
var found []entity.SiteLanguage
if err := l.db.Where("site_id = ?", siteID).Order("lang ASC").Find(&found).Error; err != nil {
return nil, err
}
rows := make([]domain.Row, 0, len(found))
for _, row := range found {
rows = append(rows, mappers.LocalizationRowToDomain(row))
}
return rows, nil
}
func (l *LocalizationRepository) ConfirmedSites() (map[uint32]bool, error) {
var ids []uint32
err := l.db.Model(&entity.SiteLanguage{}).Where("is_default = ? AND confirmed = ?", true, true).Distinct().Pluck("site_id", &ids).Error
if err != nil {
return nil, err
}
confirmed := make(map[uint32]bool, len(ids))
for _, id := range ids {
confirmed[id] = true
}
return confirmed, nil
}
func (l *LocalizationRepository) VersionCounts(siteID uint32) (map[string]domain.VersionCount, error) {
totals, err := l.countByLanguage(l.db.Where("site_id = ?", siteID))
if err != nil {
return nil, err
}
published, err := l.countByLanguage(l.db.Where("site_id = ? AND is_online = ?", siteID, true))
if err != nil {
return nil, err
}
trashed, err := l.countByLanguage(l.db.Unscoped().Where("site_id = ? AND deleted_at IS NOT NULL", siteID))
if err != nil {
return nil, err
}
counts := make(map[string]domain.VersionCount, len(totals))
for lang, total := range totals {
counts[lang] = domain.VersionCount{Total: total, Published: published[lang]}
}
for lang, count := range trashed {
kept := counts[lang]
kept.Trashed = count
counts[lang] = kept
}
return counts, nil
}
func (l *LocalizationRepository) countByLanguage(scope *gorm.DB) (map[string]int, error) {
var totals []languageTotal
err := scope.Model(&entity.Article{}).Select("lang, COUNT(*) AS total").Group("lang").Scan(&totals).Error
if err != nil {
return nil, err
}
counted := make(map[string]int, len(totals))
for _, total := range totals {
counted[total.Lang] = total.Total
}
return counted, nil
}
func (l *LocalizationRepository) Replace(siteID uint32, rows []domain.Row) error {
existing, err := l.Rows(siteID)
if err != nil {
return err
}
known := make(map[string]domain.Row, len(existing))
for _, row := range existing {
known[row.Tag] = row
}
wanted := make([]string, 0, len(rows))
for _, row := range rows {
wanted = append(wanted, row.Tag)
}
for _, row := range existing {
if !slices.Contains(wanted, row.Tag) {
if err := l.db.Where("site_id = ? AND lang = ?", siteID, row.Tag).Delete(&entity.SiteLanguage{}).Error; err != nil {
return err
}
}
}
if err := l.clearOtherDefaults(siteID, rows); err != nil {
return err
}
for _, row := range rows {
if err := l.save(siteID, row, known); err != nil {
return err
}
}
return nil
}
func (l *LocalizationRepository) clearOtherDefaults(siteID uint32, rows []domain.Row) error {
for _, row := range rows {
if row.IsDefault {
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND is_default = ? AND lang <> ?", siteID, true, row.Tag).UpdateColumn("is_default", false).Error
}
}
return nil
}
func (l *LocalizationRepository) save(siteID uint32, row domain.Row, known map[string]domain.Row) error {
before, exists := known[row.Tag]
if !exists {
model := mappers.LocalizationRowToModel(siteID, row)
return l.db.Create(&model).Error
}
if before.Equal(row) {
return nil
}
changes := map[string]any{"is_default": row.IsDefault, "enabled": row.Enabled, "confirmed": row.Confirmed, "social_locale": row.SocialLocale}
return l.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", siteID, row.Tag).Updates(changes).Error
}
func (l *LocalizationRepository) ConfirmSetup(siteID uint32, tag string) error {
if err := l.db.Where("site_id = ? AND confirmed = ?", siteID, false).Delete(&entity.SiteLanguage{}).Error; err != nil {
return err
}
confirmed := entity.SiteLanguage{SiteID: siteID, Lang: tag, IsDefault: true, Enabled: true, Confirmed: true}
return l.db.Create(&confirmed).Error
}
var _ domain.Repository = &LocalizationRepository{}
@@ -4,6 +4,7 @@ import (
domain "RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"slices"
"strings"
"gorm.io/gorm"
@@ -166,6 +167,24 @@ func lockSite(tx *gorm.DB, id uint32) (entity.Site, error) {
return locked, nil
}
func (s *SiteRepository) LockSite(id uint32) error {
_, err := lockSite(s.db, id)
return err
}
func (s *SiteRepository) TryLockSites(ids []uint32) error {
ordered := slices.Clone(ids)
slices.Sort(ordered)
for _, id := range ordered {
var locked entity.Site
err := s.db.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).First(&locked, id).Error
if err != nil {
return mapNotFoundErr(err, domain.ErrSiteNotFound)
}
}
return nil
}
func unflagDefaults(tx *gorm.DB, exceptID uint32) error {
return tx.Model(&entity.Site{}).Where("is_default = ? AND id <> ?", true, exceptID).UpdateColumn("is_default", false).Error
}
@@ -180,6 +199,7 @@ func (s *SiteRepository) Update(site domain.Site) (domain.Site, error) {
"site_name": site.SiteName,
"title_template": site.TitleTemplate,
"default_description": site.DefaultDescription,
"public_url_pattern": site.PublicURLPattern,
}
if _, err := s.Get(site.ID); err != nil {
return domain.Site{}, err
@@ -2,7 +2,9 @@ package persistence
import (
domain "RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/persistence/database"
"context"
"fmt"
"gorm.io/gorm"
)
@@ -18,14 +20,19 @@ 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 {
err := 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),
Languages: NewSiteLanguageRepository(tx),
Articles: NewArticleRepository(tx),
Sites: NewSiteRepository(tx),
Authors: NewAuthorRepository(tx),
Entries: NewEntryRepository(tx),
Images: NewImageRepository(tx),
Languages: NewSiteLanguageRepository(tx),
Localization: NewLocalizationRepository(tx),
})
})
if database.IsLockContention(t.db, err) {
return fmt.Errorf("%w: %w", domain.ErrLockContention, err)
}
return err
}
@@ -0,0 +1,162 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"fmt"
"slices"
)
type ApplyLanguageSetupUseCase struct {
transactor transaction.Transactor
recorder audit.Recorder
}
func NewApplyLanguageSetupUseCase(transactor transaction.Transactor, recorder audit.Recorder) *ApplyLanguageSetupUseCase {
return &ApplyLanguageSetupUseCase{transactor, recorder}
}
func (a *ApplyLanguageSetupUseCase) Apply(ctx context.Context, choices []localization.SetupChoice) (localization.SetupResult, error) {
event := audit.NewEvent(audit.ActionSiteLanguageSetup, audit.CategorySites)
result, confirmed, err := a.confirm(ctx, choices)
if errors.Is(err, transaction.ErrLockContention) {
err = fmt.Errorf("%w: %w", localization.ErrSetupInProgress, err)
}
if err != nil {
recordFailure(ctx, a.recorder, event, err)
return localization.SetupResult{}, err
}
for _, found := range confirmed {
a.recordConfirmed(ctx, found, result)
}
return result, nil
}
func (a *ApplyLanguageSetupUseCase) confirm(ctx context.Context, choices []localization.SetupChoice) (localization.SetupResult, []site.Site, error) {
chosen, err := normalizeSetupChoices(choices)
if err != nil {
return localization.SetupResult{}, nil, err
}
var result localization.SetupResult
var confirmed []site.Site
err = a.transactor.Run(ctx, func(tx transaction.Repositories) error {
result = localization.SetupResult{Sites: []localization.SetupAssignment{}, Skipped: []string{}}
confirmed = nil
for key := range chosen {
if _, err := tx.Sites.GetByKey(key); err != nil {
return err
}
}
pending, err := lockPendingSites(tx)
if err != nil {
return err
}
for _, found := range pending {
if _, ok := chosen[found.Key]; !ok {
return localization.ErrSetupIncomplete
}
}
for _, found := range pending {
assignment, err := confirmSite(tx, found, chosen[found.Key])
if err != nil {
return err
}
result.Assigned += assignment.Assigned
result.Sites = append(result.Sites, assignment)
confirmed = append(confirmed, found)
}
result.Skipped = skippedChoices(chosen, pending)
return nil
})
if err != nil {
return localization.SetupResult{}, nil, err
}
return result, confirmed, nil
}
func (a *ApplyLanguageSetupUseCase) recordConfirmed(ctx context.Context, found site.Site, result localization.SetupResult) {
event := audit.NewEvent(audit.ActionSiteLanguageSetup, audit.CategorySites)
event.Target = siteTarget(found)
for _, assignment := range result.Sites {
if assignment.Key == found.Key {
event.Changes = []audit.Change{audit.TextChange(audit.FieldDefaultLanguage, "", assignment.Lang)}
event.Params[audit.ParamAssigned] = assignment.Assigned
}
}
a.recorder.Record(ctx, event)
}
func lockPendingSites(tx transaction.Repositories) ([]site.Site, error) {
locked := map[uint32]bool{}
for {
pending, err := pendingSites(tx.Sites, tx.Localization)
if err != nil {
return nil, err
}
missing := []uint32{}
for _, id := range siteIDs(pending) {
if !locked[id] {
missing = append(missing, id)
}
}
if len(missing) == 0 {
return pending, nil
}
if err := tx.Sites.TryLockSites(missing); err != nil {
return nil, err
}
for _, id := range missing {
locked[id] = true
}
}
}
func confirmSite(tx transaction.Repositories, found site.Site, tag string) (localization.SetupAssignment, error) {
if err := tx.Localization.ConfirmSetup(found.ID, tag); err != nil {
return localization.SetupAssignment{}, err
}
assigned, err := tx.Articles.AssignUnassignedLanguage(found.ID, tag)
if err != nil {
return localization.SetupAssignment{}, err
}
return localization.SetupAssignment{Key: found.Key, Lang: tag, Assigned: assigned}, nil
}
func skippedChoices(chosen map[string]string, pending []site.Site) []string {
skipped := []string{}
for key := range chosen {
if !slices.ContainsFunc(pending, func(found site.Site) bool { return found.Key == key }) {
skipped = append(skipped, key)
}
}
slices.Sort(skipped)
return skipped
}
func normalizeSetupChoices(choices []localization.SetupChoice) (map[string]string, error) {
chosen := make(map[string]string, len(choices))
for _, choice := range choices {
tag, err := language.Normalize(choice.Lang)
if err != nil {
return nil, err
}
if _, repeated := chosen[choice.Key]; repeated {
return nil, localization.ErrDuplicateSite
}
chosen[choice.Key] = tag
}
return chosen, nil
}
func siteIDs(sites []site.Site) []uint32 {
ids := make([]uint32, 0, len(sites))
for _, found := range sites {
ids = append(ids, found.ID)
}
return ids
}
@@ -0,0 +1,48 @@
package useCases
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"cmp"
"slices"
)
type GetLanguageSetupUseCase struct {
sites site.Repository
localization localization.Repository
}
func NewGetLanguageSetupUseCase(sites site.Repository, localization localization.Repository) *GetLanguageSetupUseCase {
return &GetLanguageSetupUseCase{sites, localization}
}
func (g *GetLanguageSetupUseCase) Get() (localization.SetupView, error) {
pending, err := pendingSites(g.sites, g.localization)
if err != nil {
return localization.SetupView{}, err
}
view := localization.SetupView{Sites: []localization.PendingSite{}, Available: localization.Available()}
for _, found := range pending {
view.Sites = append(view.Sites, localization.NewPendingSite(found))
}
return view, nil
}
func pendingSites(sites site.Repository, repository localization.Repository) ([]site.Site, error) {
all, err := sites.List()
if err != nil {
return nil, err
}
confirmed, err := repository.ConfirmedSites()
if err != nil {
return nil, err
}
pending := []site.Site{}
for _, candidate := range all {
if !confirmed[candidate.ID] {
pending = append(pending, candidate)
}
}
slices.SortFunc(pending, func(left site.Site, right site.Site) int { return cmp.Compare(left.ID, right.ID) })
return pending, nil
}
@@ -0,0 +1,35 @@
package useCases
import (
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
)
type GetSiteLanguagesUseCase struct {
sites site.Repository
localization localization.Repository
}
func NewGetSiteLanguagesUseCase(sites site.Repository, localization localization.Repository) *GetSiteLanguagesUseCase {
return &GetSiteLanguagesUseCase{sites, localization}
}
func (g *GetSiteLanguagesUseCase) Get(key string) (localization.View, error) {
found, err := g.sites.GetByKey(key)
if err != nil {
return localization.View{}, err
}
return readSiteLanguages(g.localization, found.ID)
}
func readSiteLanguages(repository localization.Repository, siteID uint32) (localization.View, error) {
rows, err := repository.Rows(siteID)
if err != nil {
return localization.View{}, err
}
counts, err := repository.VersionCounts(siteID)
if err != nil {
return localization.View{}, err
}
return localization.BuildView(rows, counts), nil
}
@@ -92,6 +92,13 @@ func (u *UpdateSiteUseCase) apply(before site.Site, patch site.Patch) (site.Site
if patch.DefaultDescription != nil {
after.DefaultDescription = *patch.DefaultDescription
}
if patch.PublicURLPattern.Set {
pattern, err := site.NormalizePublicURLPattern(patch.PublicURLPattern.Value)
if err != nil {
return site.Site{}, err
}
after.PublicURLPattern = pattern
}
return after, nil
}
@@ -107,6 +114,7 @@ func (u *UpdateSiteUseCase) changes(before site.Site, after site.Site) []audit.C
changes = audit.AppendTextChange(changes, audit.FieldSiteName, before.SiteName, after.SiteName)
changes = audit.AppendTextChange(changes, audit.FieldTitleTemplate, before.TitleTemplate, after.TitleTemplate)
changes = audit.AppendTextChange(changes, audit.FieldDefaultDescription, before.DefaultDescription, after.DefaultDescription)
changes = audit.AppendTextChange(changes, audit.FieldPublicURLPattern, optionalText(before.PublicURLPattern), optionalText(after.PublicURLPattern))
return changes
}
@@ -0,0 +1,128 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/transaction"
"context"
"errors"
"fmt"
"slices"
"strings"
)
type UpdateSiteLanguagesUseCase struct {
transactor transaction.Transactor
recorder audit.Recorder
}
type SiteLanguageEntry struct {
Lang string
Enabled bool
SocialLocale *string
}
type UpdateSiteLanguagesCommand struct {
Key string
Revision string
Default string
Languages []SiteLanguageEntry
}
func NewUpdateSiteLanguagesUseCase(transactor transaction.Transactor, recorder audit.Recorder) *UpdateSiteLanguagesUseCase {
return &UpdateSiteLanguagesUseCase{transactor, recorder}
}
func (u *UpdateSiteLanguagesUseCase) Update(ctx context.Context, command UpdateSiteLanguagesCommand) (localization.View, error) {
event := audit.NewEvent(audit.ActionSiteLanguagesEdit, audit.CategorySites)
var view localization.View
var before []localization.Row
var after []localization.Row
err := u.transactor.Run(ctx, func(tx transaction.Repositories) error {
found, err := tx.Sites.GetByKey(command.Key)
if err != nil {
return err
}
event.Target = siteTarget(found)
if err := tx.Sites.LockSite(found.ID); err != nil {
return err
}
before, err = tx.Localization.Rows(found.ID)
if err != nil {
return err
}
if localization.NeedsSetup(before) {
return localization.ErrSetupRequired
}
if localization.Revision(before) != command.Revision {
return localization.ErrLanguagesChanged
}
counts, err := tx.Localization.VersionCounts(found.ID)
if err != nil {
return err
}
after, err = localization.Plan(before, counts, command.change())
if err != nil {
return err
}
if !localization.SameRows(before, after) {
if err := tx.Localization.Replace(found.ID, after); err != nil {
return err
}
}
view, err = readSiteLanguages(tx.Localization, found.ID)
return err
})
if errors.Is(err, transaction.ErrLockContention) {
err = fmt.Errorf("%w: %w", localization.ErrLanguagesChanged, err)
}
if err != nil {
recordFailure(ctx, u.recorder, event, err)
return localization.View{}, err
}
if changes := languageChanges(before, after); len(changes) > 0 {
event.Changes = changes
u.recorder.Record(ctx, event)
}
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 defaultTag(rows []localization.Row) string {
for _, row := range rows {
if row.IsDefault {
return row.Tag
}
}
return ""
}
func languagesLabel(rows []localization.Row) string {
ordered := slices.Clone(rows)
slices.SortFunc(ordered, func(left localization.Row, right localization.Row) int { return strings.Compare(left.Tag, right.Tag) })
labels := make([]string, 0, len(ordered))
for _, row := range ordered {
label := row.Tag
if !row.Enabled {
label += " (off)"
}
if row.SocialLocale != nil {
label += " [" + *row.SocialLocale + "]"
}
labels = append(labels, label)
}
return strings.Join(labels, ", ")
}
func (c UpdateSiteLanguagesCommand) change() localization.Change {
entries := make([]localization.Entry, 0, len(c.Languages))
for _, entry := range c.Languages {
entries = append(entries, localization.Entry{Tag: entry.Lang, Enabled: entry.Enabled, SocialLocale: entry.SocialLocale})
}
return localization.Change{Default: c.Default, Languages: entries}
}
@@ -6,8 +6,11 @@ import (
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/language"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/purge"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"context"
@@ -76,6 +79,18 @@ var failureReasons = []failureReason{
{site.ErrInvalidTitleTemplate, "The title template must contain %title%."},
{site.ErrUseSetDefault, "Use the default site action to change the default."},
{site.ErrInvalidReassign, "The destination site is not valid."},
{localization.ErrSetupRequired, "The site has no confirmed default language yet."},
{localization.ErrLanguagesChanged, "The languages changed since they were read."},
{localization.ErrLanguageInUse, "A language still used by a version cannot be removed."},
{localization.ErrDefaultNotEnabled, "The default language must be an enabled language."},
{localization.ErrNoEnabledLanguage, "At least one language must stay enabled."},
{localization.ErrDuplicateLanguage, "The same language is listed twice."},
{localization.ErrInvalidSocialLocale, "The social locale is not valid."},
{localization.ErrSetupIncomplete, "Every site that needs a language must be listed."},
{localization.ErrDuplicateSite, "The same site is listed twice."},
{language.ErrInvalidLanguage, "The language tag is not valid."},
{language.ErrUnsupportedLanguage, "That language is not supported."},
{transaction.ErrLockContention, "Another change is running on this site. Please try again."},
{author.ErrSlugTaken, "That slug is already used by another author."},
{author.ErrUserAlreadyLinked, "That user already has an author."},
{author.ErrAuthorAlreadyLinked, "That author is already linked to a user."},
@@ -45,3 +45,10 @@ func localeLabel(locale *string) string {
}
return *locale
}
func optionalText(text *string) string {
if text == nil {
return ""
}
return *text
}