feat: site languages, setup and site URL settings
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user