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
@@ -106,4 +106,5 @@ type Repository interface {
|
||||
ClearImage(imageID uint32) (ImageReferences, error)
|
||||
RestoreImage(references ImageReferences, imageID uint32) error
|
||||
RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error)
|
||||
AssignUnassignedLanguage(siteID uint32, lang string) (int, error)
|
||||
}
|
||||
@@ -78,6 +78,8 @@ const (
|
||||
ActionSiteEdit = "site.edit"
|
||||
ActionSiteSetDefault = "site.set_default"
|
||||
ActionSiteDelete = "site.delete"
|
||||
ActionSiteLanguagesEdit = "site.languages_edit"
|
||||
ActionSiteLanguageSetup = "site.language_setup"
|
||||
ActionAuthorCreate = "author.create"
|
||||
ActionAuthorEdit = "author.edit"
|
||||
ActionAuthorDelete = "author.delete"
|
||||
@@ -208,6 +210,10 @@ const (
|
||||
FieldSiteName = "site_name"
|
||||
FieldTitleTemplate = "title_template"
|
||||
FieldDefaultDescription = "default_description"
|
||||
FieldPublicURLPattern = "public_url_pattern"
|
||||
FieldLanguages = "languages"
|
||||
FieldDefaultLanguage = "default_language"
|
||||
ParamAssigned = "assigned"
|
||||
maxNameLength = 255
|
||||
maxActionLength = 64
|
||||
maxFailureReasonSize = 255
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package localization
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrSetupRequired = errors.New("the site has no confirmed default language")
|
||||
ErrLanguagesChanged = errors.New("the languages changed since they were read")
|
||||
ErrLanguageInUse = errors.New("a language still used by a version cannot be removed")
|
||||
ErrDefaultNotEnabled = errors.New("the default language must be one of the enabled languages")
|
||||
ErrNoEnabledLanguage = errors.New("at least one language must stay enabled")
|
||||
ErrDuplicateLanguage = errors.New("the same language is listed twice")
|
||||
ErrInvalidSocialLocale = errors.New("invalid social locale")
|
||||
ErrSetupInProgress = errors.New("another language setup is running")
|
||||
ErrSetupIncomplete = errors.New("every pending site needs a language")
|
||||
ErrDuplicateSite = errors.New("the same site is listed twice")
|
||||
)
|
||||
|
||||
const (
|
||||
SocialLocaleSet = "set"
|
||||
SocialLocaleDerived = "derived"
|
||||
SocialLocaleNotSet = "not_set"
|
||||
)
|
||||
|
||||
type Row struct {
|
||||
Tag string
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
Confirmed bool
|
||||
SocialLocale *string
|
||||
}
|
||||
|
||||
type VersionCount struct {
|
||||
Total int
|
||||
Published int
|
||||
Trashed int
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
Tag string
|
||||
Enabled bool
|
||||
SocialLocale *string
|
||||
}
|
||||
|
||||
type Change struct {
|
||||
Default string
|
||||
Languages []Entry
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package localization
|
||||
|
||||
type Repository interface {
|
||||
Rows(siteID uint32) ([]Row, error)
|
||||
ConfirmedSites() (map[uint32]bool, error)
|
||||
VersionCounts(siteID uint32) (map[string]VersionCount, error)
|
||||
Replace(siteID uint32, rows []Row) error
|
||||
ConfirmSetup(siteID uint32, tag string) error
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package localization
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Revision(rows []Row) string {
|
||||
ordered := slices.Clone(rows)
|
||||
slices.SortFunc(ordered, func(left Row, right Row) int { return strings.Compare(left.Tag, right.Tag) })
|
||||
digest := sha256.New()
|
||||
for _, row := range ordered {
|
||||
digest.Write([]byte(row.Tag + "|" + strconv.FormatBool(row.IsDefault) + "|" + strconv.FormatBool(row.Enabled) + "|" + socialText(row.SocialLocale) + "\n"))
|
||||
}
|
||||
return hex.EncodeToString(digest.Sum(nil))
|
||||
}
|
||||
|
||||
func SameRows(left []Row, right []Row) bool {
|
||||
return Revision(left) == Revision(right)
|
||||
}
|
||||
|
||||
func (r Row) Equal(other Row) bool {
|
||||
return r.Tag == other.Tag && r.IsDefault == other.IsDefault && r.Enabled == other.Enabled && r.Confirmed == other.Confirmed && sameSocialLocale(r.SocialLocale, other.SocialLocale)
|
||||
}
|
||||
|
||||
func sameSocialLocale(left *string, right *string) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func socialText(social *string) string {
|
||||
if social == nil {
|
||||
return ""
|
||||
}
|
||||
return *social
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package localization
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var socialLocalePattern = regexp.MustCompile(`^([A-Za-z]{2,3})_([A-Za-z]{2})$`)
|
||||
|
||||
func NormalizeSocialLocale(tag string, social *string) (*string, error) {
|
||||
if social == nil {
|
||||
return nil, nil
|
||||
}
|
||||
parts := socialLocalePattern.FindStringSubmatch(*social)
|
||||
if parts == nil {
|
||||
return nil, ErrInvalidSocialLocale
|
||||
}
|
||||
tagLanguage, _, _ := strings.Cut(tag, "-")
|
||||
if !strings.EqualFold(parts[1], tagLanguage) {
|
||||
return nil, ErrInvalidSocialLocale
|
||||
}
|
||||
normalized := strings.ToLower(parts[1]) + "_" + strings.ToUpper(parts[2])
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func SocialLocaleState(row Row) string {
|
||||
if row.SocialLocale != nil {
|
||||
return SocialLocaleSet
|
||||
}
|
||||
if _, region, found := strings.Cut(row.Tag, "-"); found && region != "" {
|
||||
return SocialLocaleDerived
|
||||
}
|
||||
return SocialLocaleNotSet
|
||||
}
|
||||
|
||||
func EffectiveSocialLocale(row Row) *string {
|
||||
switch SocialLocaleState(row) {
|
||||
case SocialLocaleSet:
|
||||
return row.SocialLocale
|
||||
case SocialLocaleDerived:
|
||||
derived := strings.Replace(row.Tag, "-", "_", 1)
|
||||
return &derived
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEntries(entries []Entry) ([]Entry, error) {
|
||||
normalized := make([]Entry, 0, len(entries))
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
tag, err := language.Normalize(entry.Tag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[tag] {
|
||||
return nil, ErrDuplicateLanguage
|
||||
}
|
||||
seen[tag] = true
|
||||
social, err := NormalizeSocialLocale(tag, entry.SocialLocale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized = append(normalized, Entry{Tag: tag, Enabled: entry.Enabled, SocialLocale: social})
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func Plan(current []Row, counts map[string]VersionCount, change Change) ([]Row, error) {
|
||||
entries, err := normalizeEntries(change.Languages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.ContainsFunc(entries, func(entry Entry) bool { return entry.Enabled }) {
|
||||
return nil, ErrNoEnabledLanguage
|
||||
}
|
||||
defaultTag, err := language.Normalize(change.Default)
|
||||
if err != nil || !slices.ContainsFunc(entries, func(entry Entry) bool { return entry.Tag == defaultTag && entry.Enabled }) {
|
||||
return nil, ErrDefaultNotEnabled
|
||||
}
|
||||
confirmed := map[string]bool{}
|
||||
for _, row := range current {
|
||||
confirmed[row.Tag] = row.Confirmed
|
||||
if !slices.ContainsFunc(entries, func(entry Entry) bool { return entry.Tag == row.Tag }) && counts[row.Tag].Total+counts[row.Tag].Trashed > 0 {
|
||||
return nil, ErrLanguageInUse
|
||||
}
|
||||
}
|
||||
next := make([]Row, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
isDefault := entry.Tag == defaultTag
|
||||
next = append(next, Row{Tag: entry.Tag, IsDefault: isDefault, Enabled: entry.Enabled, Confirmed: confirmed[entry.Tag] || isDefault, SocialLocale: entry.SocialLocale})
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package localization
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/site"
|
||||
)
|
||||
|
||||
type SetupSuggestion struct {
|
||||
Lang string `json:"lang"`
|
||||
Substituted bool `json:"substituted"`
|
||||
}
|
||||
|
||||
type PendingSite struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Locale *string `json:"locale"`
|
||||
ArticleCount int `json:"article_count"`
|
||||
Suggestion *SetupSuggestion `json:"suggestion"`
|
||||
}
|
||||
|
||||
type SetupView struct {
|
||||
Sites []PendingSite `json:"sites"`
|
||||
Available []Option `json:"available"`
|
||||
}
|
||||
|
||||
type SetupChoice struct {
|
||||
Key string
|
||||
Lang string
|
||||
}
|
||||
|
||||
type SetupAssignment struct {
|
||||
Key string `json:"key"`
|
||||
Lang string `json:"lang"`
|
||||
Assigned int `json:"assigned"`
|
||||
}
|
||||
|
||||
type SetupResult struct {
|
||||
Assigned int `json:"assigned"`
|
||||
Sites []SetupAssignment `json:"sites"`
|
||||
Skipped []string `json:"skipped"`
|
||||
}
|
||||
|
||||
func NewPendingSite(s site.Site) PendingSite {
|
||||
pending := PendingSite{Key: s.Key, Name: s.Name, Locale: s.Locale, ArticleCount: s.ArticleCount}
|
||||
if suggestion := language.Suggest(s.Locale); suggestion.Found {
|
||||
pending.Suggestion = &SetupSuggestion{Lang: suggestion.Tag, Substituted: suggestion.Substituted}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package localization
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/language"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LanguageView struct {
|
||||
Lang string `json:"lang"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
VersionCount int `json:"version_count"`
|
||||
PublishedCount int `json:"published_count"`
|
||||
SocialLocale *string `json:"social_locale"`
|
||||
SocialLocaleState string `json:"social_locale_state"`
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
Lang string `json:"lang"`
|
||||
Substitute *string `json:"substitute"`
|
||||
}
|
||||
|
||||
type View struct {
|
||||
Revision string `json:"revision"`
|
||||
Default *string `json:"default"`
|
||||
NeedsSetup bool `json:"needs_setup"`
|
||||
Languages []LanguageView `json:"languages"`
|
||||
Available []Option `json:"available"`
|
||||
}
|
||||
|
||||
var unsupportedVariants = []string{"de-AT", "de-DE", "es-ES", "es-MX", "fr-BE", "fr-FR", "pt-PT"}
|
||||
|
||||
func NeedsSetup(rows []Row) bool {
|
||||
return !slices.ContainsFunc(rows, func(row Row) bool { return row.IsDefault && row.Confirmed })
|
||||
}
|
||||
|
||||
func Available() []Option {
|
||||
options := []Option{}
|
||||
for _, tag := range language.Supported() {
|
||||
options = append(options, Option{Lang: tag})
|
||||
}
|
||||
for _, tag := range unsupportedVariants {
|
||||
base, _, _ := strings.Cut(tag, "-")
|
||||
substitute := base
|
||||
options = append(options, Option{Lang: tag, Substitute: &substitute})
|
||||
}
|
||||
slices.SortFunc(options, func(left Option, right Option) int { return strings.Compare(left.Lang, right.Lang) })
|
||||
return options
|
||||
}
|
||||
|
||||
func BuildView(rows []Row, counts map[string]VersionCount) View {
|
||||
view := View{Revision: Revision(rows), NeedsSetup: NeedsSetup(rows), Languages: []LanguageView{}, Available: Available()}
|
||||
if view.NeedsSetup {
|
||||
return view
|
||||
}
|
||||
ordered := slices.Clone(rows)
|
||||
slices.SortFunc(ordered, compareRows)
|
||||
for _, row := range ordered {
|
||||
if row.IsDefault {
|
||||
tag := row.Tag
|
||||
view.Default = &tag
|
||||
}
|
||||
view.Languages = append(view.Languages, LanguageView{
|
||||
Lang: row.Tag,
|
||||
Enabled: row.Enabled,
|
||||
IsDefault: row.IsDefault,
|
||||
VersionCount: counts[row.Tag].Total,
|
||||
PublishedCount: counts[row.Tag].Published,
|
||||
SocialLocale: row.SocialLocale,
|
||||
SocialLocaleState: SocialLocaleState(row),
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func compareRows(left Row, right Row) int {
|
||||
if left.IsDefault != right.IsDefault {
|
||||
if left.IsDefault {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if left.Enabled != right.Enabled {
|
||||
if left.Enabled {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(left.Tag, right.Tag)
|
||||
}
|
||||
@@ -20,6 +20,13 @@ var (
|
||||
ErrInvalidReassign = errors.New("invalid reassign destination")
|
||||
|
||||
ErrInvalidTitleTemplate = errors.New("the title template must contain %title%")
|
||||
|
||||
ErrInvalidPublicURLPattern = errors.New("invalid public url pattern")
|
||||
ErrPublicURLScheme = errors.New("the public url pattern must start with https:// or http://")
|
||||
ErrPublicURLHost = errors.New("the public url pattern must have a host")
|
||||
ErrPublicURLPlaceholder = errors.New("the public url pattern has an unknown placeholder")
|
||||
ErrPublicURLNotEncoded = errors.New("the public url pattern has characters that need encoding")
|
||||
ErrPublicURLMissingSlug = errors.New("the public url pattern must contain {slug}")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -60,6 +67,7 @@ type Site struct {
|
||||
SiteName string `json:"site_name"`
|
||||
TitleTemplate string `json:"title_template"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
PublicURLPattern *string `json:"public_url_pattern"`
|
||||
}
|
||||
|
||||
type Nullable[T any] struct {
|
||||
@@ -78,6 +86,7 @@ type Patch struct {
|
||||
SiteName *string
|
||||
TitleTemplate *string
|
||||
DefaultDescription *string
|
||||
PublicURLPattern Nullable[string]
|
||||
}
|
||||
|
||||
func IsValidKey(key string) bool {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package site
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxPublicURLPatternRunes = 512
|
||||
|
||||
LangPlaceholder = "{lang}"
|
||||
SlugPlaceholder = "{slug}"
|
||||
|
||||
placeholderFiller = "x"
|
||||
allowedURLSymbols = "-._~:/?#[]@!$&'()*+,;=%"
|
||||
)
|
||||
|
||||
func NormalizePublicURLPattern(pattern *string) (*string, error) {
|
||||
if pattern == nil {
|
||||
return nil, nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*pattern)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if utf8.RuneCountInString(trimmed) > MaxPublicURLPatternRunes {
|
||||
return nil, ErrInvalidPublicURLPattern
|
||||
}
|
||||
if err := checkPublicURLScheme(trimmed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPlaceholders(trimmed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filled := fillPlaceholders(trimmed)
|
||||
if err := checkEncoding(filled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.Contains(trimmed, SlugPlaceholder) {
|
||||
return nil, ErrPublicURLMissingSlug
|
||||
}
|
||||
if err := checkPublicURLHost(filled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &trimmed, nil
|
||||
}
|
||||
|
||||
func checkPublicURLScheme(pattern string) error {
|
||||
lowered := strings.ToLower(pattern)
|
||||
if !strings.HasPrefix(lowered, "https://") && !strings.HasPrefix(lowered, "http://") {
|
||||
return ErrPublicURLScheme
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPlaceholders(pattern string) error {
|
||||
remaining := pattern
|
||||
for {
|
||||
open := strings.IndexAny(remaining, "{}")
|
||||
if open < 0 {
|
||||
return nil
|
||||
}
|
||||
if remaining[open] == '}' {
|
||||
return ErrPublicURLPlaceholder
|
||||
}
|
||||
rest := remaining[open:]
|
||||
switch {
|
||||
case strings.HasPrefix(rest, LangPlaceholder):
|
||||
remaining = rest[len(LangPlaceholder):]
|
||||
case strings.HasPrefix(rest, SlugPlaceholder):
|
||||
remaining = rest[len(SlugPlaceholder):]
|
||||
default:
|
||||
return ErrPublicURLPlaceholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fillPlaceholders(pattern string) string {
|
||||
return strings.NewReplacer(LangPlaceholder, placeholderFiller, SlugPlaceholder, placeholderFiller).Replace(pattern)
|
||||
}
|
||||
|
||||
func checkEncoding(filled string) error {
|
||||
for index := 0; index < len(filled); index++ {
|
||||
character := filled[index]
|
||||
if character == '%' {
|
||||
if index+2 >= len(filled) || !isHex(filled[index+1]) || !isHex(filled[index+2]) {
|
||||
return ErrPublicURLNotEncoded
|
||||
}
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if !isAllowedURLByte(character) {
|
||||
return ErrPublicURLNotEncoded
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isHex(character byte) bool {
|
||||
return character >= '0' && character <= '9' || character >= 'a' && character <= 'f' || character >= 'A' && character <= 'F'
|
||||
}
|
||||
|
||||
func isAllowedURLByte(character byte) bool {
|
||||
if character >= '0' && character <= '9' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' {
|
||||
return true
|
||||
}
|
||||
return strings.IndexByte(allowedURLSymbols, character) >= 0
|
||||
}
|
||||
|
||||
func checkPublicURLHost(filled string) error {
|
||||
parsed, err := url.Parse(filled)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return ErrPublicURLHost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type Repository interface {
|
||||
Create(site Site) (Site, error)
|
||||
Update(site Site) (Site, error)
|
||||
SetDefault(id uint32) (Site, error)
|
||||
LockSite(id uint32) error
|
||||
TryLockSites(ids []uint32) error
|
||||
DeleteMovingArticles(id uint32, reassignTo *uint32) error
|
||||
ClearImage(imageID uint32) ([]uint32, error)
|
||||
AssignImage(siteIDs []uint32, imageID uint32) error
|
||||
|
||||
@@ -6,17 +6,22 @@ import (
|
||||
"RenewCMS/internal/domain/entry"
|
||||
"RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrLockContention = errors.New("the database could not lock the rows in time")
|
||||
|
||||
type Repositories struct {
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Languages language.Repository
|
||||
Articles article.Repository
|
||||
Sites site.Repository
|
||||
Authors author.Repository
|
||||
Entries entry.Repository
|
||||
Images image.Repository
|
||||
Languages language.Repository
|
||||
Localization localization.Repository
|
||||
}
|
||||
|
||||
type Transactor interface {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user