package classes import ( "context" "errors" "net/url" "strings" "time" "unicode" "unicode/utf8" "git.golem15.com/golem15/sm-seo-plugin/models" "gorm.io/gorm" ) // MaxPageAutolinks caps the phrases one resolve answer carries. const MaxPageAutolinks = 1000 // AutolinkLink is one phrase of a resolve answer's autolinks (D-82): the // layer links the phrase's first occurrence in a text to URL. type AutolinkLink struct { Phrase string `json:"phrase"` URL string `json:"url"` } // AutolinkValues are an auto-link's values for history and the API. func AutolinkValues(a models.Autolink) map[string]any { return map[string]any{ "phrase": a.Phrase, "url": a.URL, "locale": a.Locale, "enabled": a.Enabled, "origin": a.Origin, "note": a.Note, } } // AutolinkWrite is a create or update of an auto-link: only the fields // set change. type AutolinkWrite struct { Phrase *string `json:"phrase,omitempty"` URL *string `json:"url,omitempty"` Locale *string `json:"locale,omitempty"` Enabled *bool `json:"enabled,omitempty"` Note *string `json:"note,omitempty"` } // Apply copies the fields set into a. func (w AutolinkWrite) Apply(a *models.Autolink) { if w.Phrase != nil { a.Phrase = *w.Phrase } if w.URL != nil { a.URL = *w.URL } if w.Locale != nil { a.Locale = *w.Locale } if w.Enabled != nil { a.Enabled = *w.Enabled } if w.Note != nil { a.Note = *w.Note } } // NormalizeAutolink trims the phrase (inner whitespace collapsed to one // space), the address, the locale ("all" is every locale) and the note. func NormalizeAutolink(a *models.Autolink) { a.Phrase = strings.Join(strings.Fields(a.Phrase), " ") a.URL = strings.TrimSpace(a.URL) a.Locale = strings.TrimSpace(a.Locale) if a.Locale == models.AutolinkLocaleAll { a.Locale = "" } a.Note = strings.TrimSpace(a.Note) if a.Origin == "" { a.Origin = models.AutolinkOriginManual } } // ValidAutolinkURL reports whether u is a path on the site (starting with // one /) or an absolute http(s) address, without whitespace or control // characters (T-33-50: javascript:, data: and protocol-relative addresses // are refused). func ValidAutolinkURL(u string) bool { if u == "" || len(u) > models.MaxAutolinkURL || !utf8.ValidString(u) { return false } for _, r := range u { if unicode.IsSpace(r) || unicode.IsControl(r) { return false } } if strings.HasPrefix(u, "/") { return !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, `/\`) } parsed, err := url.Parse(u) if err != nil { return false } scheme := strings.ToLower(parsed.Scheme) return (scheme == "http" || scheme == "https") && parsed.Host != "" && parsed.User == nil } // AutolinkProblems refuses a phrase under 3 characters (or over 200), an // address that is not a path or an http(s) address, an unknown locale, // and a phrase another row already has in the same locale (a phrase of // every locale meets the phrases of each locale), naming that row's // address (UI-SPEC C22). a is normalised first. func (s *Service) AutolinkProblems(ctx context.Context, db *gorm.DB, a *models.Autolink) (map[string][]models.Problem, error) { NormalizeAutolink(a) out := map[string][]models.Problem{} n := utf8.RuneCountInString(a.Phrase) switch { case n < models.MinAutolinkPhrase: out["phrase"] = append(out["phrase"], models.Problem{Key: "golem15.seo::lang.autolinks.phrase_short"}) case n > models.MaxAutolinkPhrase: out["phrase"] = append(out["phrase"], models.Problem{Key: "lagoon::validation.max.string", Params: map[string]string{"attribute": "phrase", "max": "200"}}) } if !ValidAutolinkURL(a.URL) { out["url"] = append(out["url"], models.Problem{Key: "golem15.seo::lang.autolinks.url_invalid"}) } if a.Locale != "" { known := false for _, code := range s.Locales(ctx, db) { if code == a.Locale { known = true break } } if !known { out["locale"] = append(out["locale"], models.Problem{Key: "golem15.seo::lang.api.value_invalid"}) } } if len(out["phrase"]) == 0 { q := db.Model(&models.Autolink{}).Where("lower(phrase) = lower(?)", a.Phrase) if a.Locale != "" { q = q.Where("locale = ? OR locale = ''", a.Locale) } if a.ID != 0 { q = q.Where("id <> ?", a.ID) } var rows []models.Autolink if err := q.Order("id ASC").Limit(1).Find(&rows).Error; err != nil { return nil, err } if len(rows) > 0 { out["phrase"] = append(out["phrase"], models.Problem{Key: "golem15.seo::lang.autolinks.phrase_taken", Params: map[string]string{"url": rows[0].URL}}) } } return out, nil } // LoadAutolink returns auto-link id; ErrNoRecord when there is none. func LoadAutolink(db *gorm.DB, id uint) (models.Autolink, error) { var rows []models.Autolink if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil { return models.Autolink{}, err } if len(rows) == 0 { return models.Autolink{}, ErrNoRecord } return rows[0], nil } // AutolinkOrigin is the origin of a phrase created through ctx: mcp // through MCP, else manual. func AutolinkOrigin(ctx context.Context) string { if ViaOf(ctx) == models.ViaMCP { return models.AutolinkOriginMCP } return models.AutolinkOriginManual } // autolinkWhat is an auto-link's history description. func (s *Service) autolinkWhat(a models.Autolink) string { return s.Phrase("golem15.seo::lang.history.what_autolink", map[string]string{"phrase": a.Phrase}) } // RecordAutolink records an auto-link's change in history inside tx; the // write purges the site's cached pages, since the phrase may appear in any // text. before is nil for a created phrase. func (s *Service) RecordAutolink(ctx context.Context, tx *gorm.DB, before map[string]any, a models.Autolink, deleted bool) error { c := Change{Kind: models.SubjectAutolink, SubjectID: a.IDText(), What: s.autolinkWhat(a), Before: before} if !deleted { c.After = AutolinkValues(a) } return s.Record(ctx, tx, c) } // SaveAutolink checks and writes an auto-link inside tx and records it in // history; a refused phrase is a *ProblemsError. func (s *Service) SaveAutolink(ctx context.Context, tx *gorm.DB, a *models.Autolink) error { if a == nil { return errors.New("golem15.seo: no auto-link") } problems, err := s.AutolinkProblems(ctx, tx, a) if err != nil { return err } if len(problems) > 0 { return &ProblemsError{Problems: problems} } now := time.Now().UTC() var before map[string]any if a.ID == 0 { a.CreatedAt, a.UpdatedAt = &now, &now if err := tx.Create(a).Error; err != nil { return err } } else { old, err := LoadAutolink(tx, a.ID) if err != nil { return err } before = AutolinkValues(old) a.CreatedAt, a.UpdatedAt = old.CreatedAt, &now if err := tx.Save(a).Error; err != nil { return err } } return s.RecordAutolink(ctx, tx, before, *a, false) } // DeleteAutolinks deletes the auto-links ids inside tx, records each in // history and returns the ids deleted (unknown ids are skipped). func (s *Service) DeleteAutolinks(ctx context.Context, tx *gorm.DB, ids []uint) ([]uint, error) { if len(ids) == 0 { return []uint{}, nil } var rows []models.Autolink if err := tx.Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil { return nil, err } out := make([]uint, 0, len(rows)) for _, row := range rows { if err := tx.Delete(&models.Autolink{}, row.ID).Error; err != nil { return nil, err } if err := s.RecordAutolink(ctx, tx, AutolinkValues(row), row, true); err != nil { return nil, err } out = append(out, row.ID) } return out, nil } // autolinkPath is the site path an auto-link's address names: the path of // a / address, or of an absolute address on the site's host (its www twin // included); "" for another site. func (s *Service) autolinkPath(target string) string { if strings.HasPrefix(target, "/") { path, _, _ := strings.Cut(target, "#") path, _, _ = strings.Cut(path, "?") return path } u, err := url.Parse(target) if err != nil { return "" } site := strings.TrimPrefix(strings.ToLower(s.host()), "www.") if site == "" || strings.TrimPrefix(strings.ToLower(u.Host), "www.") != site { return "" } if u.Path == "" { return "/" } return u.Path } // ForPage lists the auto-link phrases of a page in locale (D-82): the // active phrases of that locale and of every locale, without those that // link to the page itself (selfPath, a trailing slash ignored), longest // phrase first so a longer phrase wins over a phrase it contains, one row // per phrase, at most MaxPageAutolinks. func (s *Service) ForPage(ctx context.Context, db *gorm.DB, locale, selfPath string) ([]AutolinkLink, error) { var rows []models.Autolink err := db.WithContext(ctx).Where("enabled AND (locale = '' OR locale = ?)", locale). Order("char_length(phrase) DESC, lower(phrase) ASC, locale DESC, id ASC"). Limit(MaxPageAutolinks).Find(&rows).Error if err != nil { return nil, err } self := trimSlash(selfPath) seen := map[string]bool{} out := make([]AutolinkLink, 0, len(rows)) for _, row := range rows { key := strings.ToLower(row.Phrase) if seen[key] { continue } if p := s.autolinkPath(row.URL); p != "" && trimSlash(p) == self { continue } seen[key] = true out = append(out, AutolinkLink{Phrase: row.Phrase, URL: row.URL}) } return out, nil } // FromKeyphrases adds one auto-link per record whose focus keyphrase in a // locale is not yet a phrase in that locale (UI-SPEC C22): the record's // address in that locale, origin keyphrase, active. A keyphrase another // row already has, one under 3 characters or one whose address is not a // path is skipped. It runs inside tx and returns the rows added; a second // run adds nothing. func (s *Service) FromKeyphrases(ctx context.Context, tx *gorm.DB) (int, error) { var rows []struct { Keyphrase string Locale string URL string } err := tx.Raw(`SELECT keyphrase, locale, url FROM golem15_seo_entries e WHERE e.keyphrase <> '' AND e.url <> '' AND NOT EXISTS (SELECT 1 FROM golem15_seo_autolinks a WHERE lower(a.phrase) = lower(e.keyphrase) AND (a.locale = e.locale OR a.locale = '')) ORDER BY e.locale ASC, e.updated_at DESC, e.id DESC`).Scan(&rows).Error if err != nil { return 0, err } added := 0 for _, row := range rows { a := models.Autolink{Phrase: row.Keyphrase, URL: row.URL, Locale: row.Locale, Enabled: true, Origin: models.AutolinkOriginKeyphrase} problems, err := s.AutolinkProblems(ctx, tx, &a) if err != nil { return added, err } if len(problems) > 0 { continue } if err := s.SaveAutolink(ctx, tx, &a); err != nil { return added, err } added++ } return added, nil }