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,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