feat: management API and an MCP tool per SEO admin operation
- /_seo/api/v1 routes for the effective SEO of a URL with sources and owner, URL rules, redirects (loops refused with redirect_loop), the 404 log actions, robots.txt and its tester, settings and history - the CMS-data issue report: missing and duplicate titles and descriptions, rules over records, redirect chains, canonicals to noindex or redirected addresses, noindex addresses robots.txt blocks - 24 MCP tools on those routes (backend guard, golem15.seo.manage) - AdminOperations maps every admin operation to its tool; TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift - robots.txt writes go through classes.SaveRobots from the admin too
This commit is contained in:
@@ -183,3 +183,57 @@ func write(ctx context.Context, svc *classes.Service, fn func(ctx context.Contex
|
||||
func read(ctx context.Context, svc *classes.Service) (*gorm.DB, error) {
|
||||
return svc.DB(ctx)
|
||||
}
|
||||
|
||||
// The page sizes of the list endpoints.
|
||||
const (
|
||||
defaultPerPage = 50
|
||||
maxPerPage = 200
|
||||
)
|
||||
|
||||
// ListInput is the paging and search query every list tool shares.
|
||||
type ListInput 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 address or value contains"`
|
||||
}
|
||||
|
||||
// listParams reads page, per_page and search.
|
||||
func listParams(r *http.Request) ListInput {
|
||||
q := r.URL.Query()
|
||||
in := ListInput{Search: strings.TrimSpace(q.Get("search"))}
|
||||
in.Page, _ = strconv.Atoi(q.Get("page"))
|
||||
in.PerPage, _ = strconv.Atoi(q.Get("per_page"))
|
||||
if in.Page < 1 {
|
||||
in.Page = 1
|
||||
}
|
||||
if in.PerPage < 1 {
|
||||
in.PerPage = defaultPerPage
|
||||
}
|
||||
if in.PerPage > maxPerPage {
|
||||
in.PerPage = maxPerPage
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// paginate counts q and loads one page of it into dest.
|
||||
func paginate(q *gorm.DB, in ListInput, dest any) (int64, error) {
|
||||
var total int64
|
||||
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := q.Offset((in.Page - 1) * in.PerPage).Limit(in.PerPage).Find(dest).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// listBody is a list answer: the rows and the paging.
|
||||
func listBody(rows any, in ListInput, total int64) map[string]any {
|
||||
return map[string]any{"data": rows, "page": in.Page, "per_page": in.PerPage, "total": total}
|
||||
}
|
||||
|
||||
// like is a case-insensitive LIKE pattern of text.
|
||||
func like(text string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return "%" + strings.ToLower(r.Replace(text)) + "%"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user