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

@@ -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)
}
+6
View File
@@ -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)
}
+9
View File
@@ -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 {