package api import ( "context" "net/http" "strings" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/models" "gorm.io/gorm" ) // ListAutolinksInput is the query of GET /_seo/api/v1/autolinks. type ListAutolinksInput struct { Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"` PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"` Search string `json:"search,omitempty" query:"search" jsonschema:"text the phrase or the address contains"` Locale string `json:"locale,omitempty" query:"locale" jsonschema:"a content locale code, or all for the phrases of every locale"` Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, keyphrase or mcp"` Active string `json:"active,omitempty" query:"active" jsonschema:"true: active phrases only; false: inactive ones only"` } // CreateAutolinkInput is the body of POST /_seo/api/v1/autolinks. type CreateAutolinkInput struct { Phrase string `json:"phrase" jsonschema:"the phrase texts link at its first occurrence (whole words, any letter case), at least 3 characters"` URL string `json:"url" jsonschema:"the address it links to: a path such as /grzyby/lwia-grzywa or a full http(s) address"` Locale *string `json:"locale,omitempty" jsonschema:"the content locale it applies in; empty or all for every locale (the default)"` Enabled *bool `json:"enabled,omitempty" jsonschema:"whether texts link it (default true)"` Note *string `json:"note,omitempty" jsonschema:"an internal note"` } // UpdateAutolinkInput is PUT /_seo/api/v1/autolinks/{id}: only the fields // sent change. type UpdateAutolinkInput struct { ID uint `json:"id" path:"id" jsonschema:"the auto-link id"` Phrase *string `json:"phrase,omitempty" jsonschema:"the phrase, at least 3 characters"` URL *string `json:"url,omitempty" jsonschema:"a path or a full http(s) address"` Locale *string `json:"locale,omitempty" jsonschema:"a content locale code; empty or all for every locale"` Enabled *bool `json:"enabled,omitempty" jsonschema:"whether texts link it"` Note *string `json:"note,omitempty" jsonschema:"an internal note"` } // DeleteAutolinksInput is the query of DELETE /_seo/api/v1/autolinks. type DeleteAutolinksInput struct { IDs string `json:"ids" query:"ids" jsonschema:"the auto-link ids to delete, comma-separated, e.g. 3,4"` } // AutolinksFromKeyphrasesInput is POST /_seo/api/v1/autolinks/from-keyphrases // (no fields). type AutolinksFromKeyphrasesInput struct{} // AutolinkJSON is an auto-link in an answer; locale is "" for every // locale. func AutolinkJSON(a models.Autolink) map[string]any { out := classes.AutolinkValues(a) out["id"] = a.ID out["created_at"] = a.CreatedAt out["updated_at"] = a.UpdatedAt return out } // ListAutolinks serves GET /_seo/api/v1/autolinks: by phrase, filtered by // search text, locale, origin and the active state. func ListAutolinks(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { db, err := read(ctx, svc) if err != nil { return 0, nil, err } in := listParams(r) q := db.Model(&models.Autolink{}).Order("lower(phrase) ASC, locale ASC, id ASC") if in.Search != "" { p := like(in.Search) q = q.Where("lower(phrase) LIKE ? OR lower(url) LIKE ?", p, p) } query := r.URL.Query() if l := strings.TrimSpace(query.Get("locale")); l != "" { if l == models.AutolinkLocaleAll { l = "" } q = q.Where("locale = ?", l) } if o := strings.TrimSpace(query.Get("origin")); o != "" { q = q.Where("origin = ?", o) } switch strings.TrimSpace(query.Get("active")) { case "true", "1": q = q.Where("enabled") case "false", "0": q = q.Where("NOT enabled") } var rows []models.Autolink total, err := paginate(q, in, &rows) if err != nil { return 0, nil, err } out := make([]map[string]any, 0, len(rows)) for _, row := range rows { out = append(out, AutolinkJSON(row)) } return http.StatusOK, listBody(out, in, total), nil }) } // CreateAutolink serves POST /_seo/api/v1/autolinks: 201 with the phrase, // 422 validation_failed for a short phrase, an invalid address or a // phrase the locale has (UI-SPEC C22). The origin is mcp through MCP. func CreateAutolink(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { var body CreateAutolinkInput if err := decode(r, maxBody, &body); err != nil { return 0, nil, err } row := models.NewAutolink() row.Origin = classes.AutolinkOrigin(ctx) classes.AutolinkWrite{Phrase: &body.Phrase, URL: &body.URL, Locale: body.Locale, Enabled: body.Enabled, Note: body.Note}.Apply(&row) err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { return svc.SaveAutolink(ctx, tx, &row) }) if err != nil { return 0, nil, err } return http.StatusCreated, map[string]any{"data": AutolinkJSON(row)}, nil }) } // UpdateAutolink serves PUT /_seo/api/v1/autolinks/{id}: the fields sent // change, checked like a create. func UpdateAutolink(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { id, err := pathID(r, "id") if err != nil { return 0, nil, err } var body classes.AutolinkWrite if err := decode(r, maxBody, &body); err != nil { return 0, nil, err } var row models.Autolink err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error if row, err = classes.LoadAutolink(tx, id); err != nil { return err } body.Apply(&row) return svc.SaveAutolink(ctx, tx, &row) }) if err != nil { return 0, nil, err } return http.StatusOK, map[string]any{"data": AutolinkJSON(row)}, nil }) } // DeleteAutolinks serves DELETE /_seo/api/v1/autolinks?ids=: the phrases // named, each recorded in history; unknown ids are skipped. func DeleteAutolinks(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { ids, err := idList(r.URL.Query().Get("ids")) if err != nil { return 0, nil, err } var deleted []uint err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error deleted, err = svc.DeleteAutolinks(ctx, tx, ids) return err }) if err != nil { return 0, nil, err } return http.StatusOK, map[string]any{"deleted": deleted}, nil }) } // AutolinksFromKeyphrases serves POST // /_seo/api/v1/autolinks/from-keyphrases: one phrase per record whose // focus keyphrase in a locale is not yet a phrase there (origin // keyphrase), with the count added and the toast text. func AutolinksFromKeyphrases(service func() *classes.Service) http.HandlerFunc { return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { added := 0 err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { var err error added, err = svc.FromKeyphrases(ctx, tx) return err }) if err != nil { return 0, nil, err } message := svc.PhraseCtx(ctx, "golem15.seo::lang.autolinks.keyphrases_none", nil) if added > 0 { message = svc.ChoiceCtx(ctx, "golem15.seo::lang.autolinks.keyphrases_added", added, nil) } return http.StatusOK, map[string]any{"added": added, "message": message}, nil }) }