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

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