package classes import ( "context" "strconv" "strings" "time" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // AddressChange is one record's public address change in one locale: the // URL its SEO entry last saw (Old) and the URL its content descriptor // answers now (New). type AddressChange struct { TypeKey string ModelType string RecordID string Locale string Old string New string } // AddressResult is what AddressChanged did: the redirect from the old to // the new address, and how many earlier redirects now point straight to // the new address. type AddressResult struct { Locale string Old string New string Redirect *models.Redirect Repointed int } // AddressChanged applies a record's address change inside tx, the // caller's transaction (D-18, P-20): // 1. exact redirects whose source is the new address are deleted, so the // record is never shadowed (this also undoes an earlier change back); // 2. old → new is created as a 301 (origin slug), or the existing exact // rule of the old address is pointed at the new one; // 3. redirects whose target is the old address are pointed at the new // one, so no chain forms (A→B, then B→C makes A→C); // 4. redirects that now point to themselves are deleted; // 5. the entry's URL becomes the new address. // // Every redirect write is recorded in history with via auto. Nothing // happens when either address is empty or they are equal. func (s *Service) AddressChanged(ctx context.Context, tx *gorm.DB, change AddressChange) (AddressResult, error) { res := AddressResult{Locale: change.Locale, Old: strings.TrimSpace(change.Old), New: strings.TrimSpace(change.New)} if res.Old == "" || res.New == "" || res.Old == res.New { return res, nil } ctx = WithVia(ctx, models.ViaAuto) now := time.Now().UTC() var shadowing []models.Redirect if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.New).Find(&shadowing).Error; err != nil { return res, err } for _, r := range shadowing { if err := s.deleteRedirect(ctx, tx, r); err != nil { return res, err } } var existing []models.Redirect if err := tx.Where("match = ? AND source = ?", models.RedirectExact, res.Old).Limit(1).Find(&existing).Error; err != nil { return res, err } var redirect models.Redirect if len(existing) > 0 { redirect = existing[0] before := RedirectValues(redirect) redirect.Target, redirect.Status, redirect.UpdatedAt = res.New, models.Status301, &now if err := tx.Save(&redirect).Error; err != nil { return res, err } if err := s.recordRedirect(ctx, tx, redirect, before, RedirectValues(redirect)); err != nil { return res, err } } else { redirect = models.NewRedirect() redirect.Source, redirect.Target, redirect.Origin = res.Old, res.New, models.OriginSlug redirect.CreatedAt, redirect.UpdatedAt = &now, &now if err := tx.Create(&redirect).Error; err != nil { return res, err } if err := s.recordRedirect(ctx, tx, redirect, nil, RedirectValues(redirect)); err != nil { return res, err } } res.Redirect = &redirect var pointing []models.Redirect targets := []string{res.Old} if site := s.SiteURL(); site != "" { targets = append(targets, site+res.Old) } if err := tx.Where("target IN ? AND id <> ?", targets, redirect.ID).Find(&pointing).Error; err != nil { return res, err } for _, r := range pointing { before := RedirectValues(r) r.Target, r.UpdatedAt = res.New, &now if r.Match == models.RedirectExact && s.samePath(r.Target, r.Source) { if err := s.deleteRedirect(ctx, tx, r); err != nil { return res, err } continue } if err := tx.Save(&r).Error; err != nil { return res, err } if err := s.recordRedirect(ctx, tx, r, before, RedirectValues(r)); err != nil { return res, err } res.Repointed++ } if change.ModelType != "" && change.RecordID != "" { if err := tx.Model(&models.Entry{}). Where("model_type = ? AND record_id = ? AND locale = ?", change.ModelType, change.RecordID, change.Locale). Updates(map[string]any{"url": res.New, "updated_at": now}).Error; err != nil { return res, err } } return res, nil } // deleteRedirect deletes r inside tx and records it in history. func (s *Service) deleteRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect) error { if err := tx.Delete(&models.Redirect{}, r.ID).Error; err != nil { return err } return s.recordRedirect(ctx, tx, r, RedirectValues(r), nil) } // recordRedirect writes the history row of one redirect write. func (s *Service) recordRedirect(ctx context.Context, tx *gorm.DB, r models.Redirect, before, after map[string]any) error { return s.Record(ctx, tx, Change{ Kind: models.SubjectRedirect, SubjectID: strconv.FormatUint(uint64(r.ID), 10), What: s.redirectWhat(r), Before: before, After: after, }) } // AddressChanges compares the addresses a record's content descriptor // answers now with the URLs its SEO entries last saw, inside tx (D-18), // and returns the changes that need a redirect: the changed locales of a // published record, each to be applied with AddressChanged. A changed // locale of an unpublished record only takes its new URL, without a // redirect, and a locale without an entry gets one with its URL, so the // next change can be detected. func (s *Service) AddressChanges(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string) ([]AddressChange, error) { reg := s.Registry() if reg == nil || typ.Content == nil { return nil, nil } rec, err := reg.Describe(ctx, typ.Key, id, s.UnprefixedLocale()) if err != nil || rec == nil { return nil, err } model := typ.Content.Model var out []AddressChange for _, l := range rec.Locales { url := strings.TrimSpace(l.URL) if url == "" { continue } entry, found, err := LoadEntry(tx, model, id, l.Locale) if err != nil { return nil, err } switch { case !found: row := models.Entry{ModelType: model, RecordID: id, Locale: l.Locale, TypeKey: typ.Key, URL: url} if err := tx.Create(&row).Error; err != nil { return nil, err } case entry.URL == url: case entry.URL == "" || !rec.Published: if err := tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{"url": url, "type_key": typ.Key}).Error; err != nil { return nil, err } default: out = append(out, AddressChange{ TypeKey: typ.Key, ModelType: model, RecordID: id, Locale: l.Locale, Old: entry.URL, New: url, }) } } return out, nil } // AddressNotices are the save notices of address changes // (redirects.auto_created, one per changed locale), on the owner field // named after a placeholder of the type's pattern that the save changed // (for example slug), else on fallback. func AddressNotices(typ pact.MenuItemType, changes []pact.RecordChange, results []AddressResult, fallback string) map[string][]pact.FormFieldNotice { if len(results) == 0 { return nil } field := fallback for _, c := range changes { for _, p := range typ.Placeholders { if strings.TrimPrefix(p, ":") == c.Attribute { field = c.Attribute } } if field != fallback { break } } out := map[string][]pact.FormFieldNotice{} for _, r := range results { out[field] = append(out[field], pact.FormFieldNotice{ Message: "golem15.seo::lang.redirects.auto_created", Params: map[string]string{"old": r.Old, "new": r.New, "count": strconv.Itoa(r.Repointed)}, }) } return out }