// Categories: list (GET /categories.json) and create (POST /categories.json). // Creation needs an admin-scoped key — the current MOPAC key gets 403 // there (ErrForbidden); listing works with a standard key. package discourse import ( "context" "fmt" "net/url" "strings" ) // Category is the subset of the Discourse category payload callers use. type Category struct { ID int `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Color string `json:"color"` TextColor string `json:"text_color"` Position int `json:"position"` TopicCount int `json:"topic_count"` } // CreateCategoryRequest is the POST /categories.json body. Permissions // maps a GROUP NAME to an access level: 1 = reply/see, 2 = create posts, // 3 = full (the security group numbers Discourse's admin UI uses). Empty // means "default: everyone full" — for restricted categories set it // explicitly, e.g. {"staff": 3, "trust_level_0": 1}. type CreateCategoryRequest struct { Name string `json:"name"` Color string `json:"color"` // 6 hex digits, no '#' TextColor string `json:"text_color"` // 6 hex digits, no '#' Permissions map[string]int `json:"permissions,omitempty"` Position int `json:"position,omitempty"` ParentCategoryID int `json:"parent_category_id,omitempty"` Description string `json:"description,omitempty"` } // ListCategories returns the instance's categories (one request; the // endpoint returns the full list). func (c *Client) ListCategories(ctx context.Context) ([]Category, error) { var payload struct { CategoryList struct { Categories []Category `json:"categories"` } `json:"category_list"` } if err := c.Get(ctx, "/categories.json", &payload); err != nil { return nil, err } return payload.CategoryList.Categories, nil } // FindCategory resolves a category by id or by slug (case-insensitive). // One of the two must be non-zero/non-empty. func (c *Client) FindCategory(ctx context.Context, id int, slug string) (*Category, error) { cats, err := c.ListCategories(ctx) if err != nil { return nil, err } for i := range cats { if (id != 0 && cats[i].ID == id) || (slug != "" && strings.EqualFold(cats[i].Slug, slug)) { return &cats[i], nil } } return nil, &APIError{ Method: "GET", Path: "/categories.json", Status: 404, Errors: []string{fmt.Sprintf("category id=%d slug=%q not found", id, slug)}, } } // CreateCategory creates a category and returns the created record. // Colors are normalized (uppercased, '#' stripped); Discourse requires // valid hex or it 422s. func (c *Client) CreateCategory(ctx context.Context, req CreateCategoryRequest) (*Category, error) { if strings.TrimSpace(req.Name) == "" { return nil, fmt.Errorf("%w: category name is required", ErrInvalidRequest) } req.Color = normHex(req.Color, "3AB54A") req.TextColor = normHex(req.TextColor, "FFFFFF") var created Category if err := c.Post(ctx, "/categories.json", req, &created); err != nil { return nil, err } return &created, nil } func normHex(v, fallback string) string { v = strings.TrimPrefix(strings.TrimSpace(strings.ToUpper(v)), "#") if v == "" { return fallback } return v } // quotePathSegment escapes a path segment (slugs in /c//.json). func quotePathSegment(s string) string { return url.PathEscape(s) }