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
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user