Files
WPSidekick/internal/wordpress/client.go
T
2026-01-25 08:57:55 +01:00

626 lines
16 KiB
Go

package wordpress
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"seo-optimizer/internal/logger"
)
// Client is a WordPress REST API client with Application Password authentication
type Client struct {
baseURL string
username string
appPassword string
authHeader string
httpClient *http.Client
logger *logger.Logger
}
// NewClient creates a new WordPress REST API client
func NewClient(baseURL, username, appPassword string, timeout time.Duration, log *logger.Logger) *Client {
// Ensure base URL doesn't have trailing slash
baseURL = strings.TrimRight(baseURL, "/")
// Create Basic Auth header
auth := username + ":" + appPassword
authHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
if log == nil {
log = logger.NewDefaultLogger()
}
return &Client{
baseURL: baseURL,
username: username,
appPassword: appPassword,
authHeader: authHeader,
httpClient: &http.Client{
Timeout: timeout,
},
logger: log.WithComponent("wordpress"),
}
}
// GetPost retrieves a single post by ID
func (c *Client) GetPost(ctx context.Context, postID int, lang string) (*Post, error) {
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
params := url.Values{}
if lang != "" {
params.Set("lang", lang)
}
var post Post
if err := c.get(ctx, endpoint, params, &post); err != nil {
return nil, fmt.Errorf("failed to get post %d: %w", postID, err)
}
return &post, nil
}
// GetPostForEdit retrieves a single post by ID with edit context (includes raw content).
func (c *Client) GetPostForEdit(ctx context.Context, postID int, lang string) (*Post, error) {
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
params := url.Values{}
params.Set("context", "edit")
if lang != "" {
params.Set("lang", lang)
}
var post Post
if err := c.get(ctx, endpoint, params, &post); err != nil {
return nil, fmt.Errorf("failed to get post %d for edit: %w", postID, err)
}
return &post, nil
}
// ListPosts retrieves a list of posts with the specified parameters
func (c *Client) ListPosts(ctx context.Context, params ListParams) ([]*Post, error) {
endpoint := "/wp-json/wp/v2/posts"
queryParams := url.Values{}
if params.Lang != "" {
queryParams.Set("lang", params.Lang)
}
if params.Status != "" {
queryParams.Set("status", params.Status)
}
if params.OrderBy != "" {
queryParams.Set("orderby", params.OrderBy)
}
if params.Order != "" {
queryParams.Set("order", params.Order)
}
if params.PerPage > 0 {
queryParams.Set("per_page", strconv.Itoa(params.PerPage))
}
if params.Page > 0 {
queryParams.Set("page", strconv.Itoa(params.Page))
}
if params.After != nil {
queryParams.Set("after", params.After.Format(time.RFC3339))
}
if params.Before != nil {
queryParams.Set("before", params.Before.Format(time.RFC3339))
}
if params.Search != "" {
queryParams.Set("search", params.Search)
}
if len(params.Categories) > 0 {
catIDs := make([]string, len(params.Categories))
for i, catID := range params.Categories {
catIDs[i] = strconv.Itoa(catID)
}
queryParams.Set("categories", strings.Join(catIDs, ","))
}
if len(params.Tags) > 0 {
tagIDs := make([]string, len(params.Tags))
for i, tagID := range params.Tags {
tagIDs[i] = strconv.Itoa(tagID)
}
queryParams.Set("tags", strings.Join(tagIDs, ","))
}
var posts []*Post
if err := c.get(ctx, endpoint, queryParams, &posts); err != nil {
return nil, fmt.Errorf("failed to list posts: %w", err)
}
return posts, nil
}
// GetPostMeta retrieves a specific meta field value for a post
func (c *Client) GetPostMeta(ctx context.Context, postID int, metaKey string) (interface{}, error) {
post, err := c.GetPost(ctx, postID, "")
if err != nil {
return nil, err
}
if post.Meta == nil {
return nil, nil
}
return post.Meta[metaKey], nil
}
// UpdatePostMeta updates a specific meta field for a post
func (c *Client) UpdatePostMeta(ctx context.Context, postID int, metaKey string, value interface{}) error {
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", postID)
update := map[string]interface{}{
"meta": map[string]interface{}{
metaKey: value,
},
}
if err := c.post(ctx, endpoint, update, nil); err != nil {
return fmt.Errorf("failed to update post meta %s: %w", metaKey, err)
}
return nil
}
// CreateDraftRevision creates a linked draft copy of an existing post
// This allows reviewing changes without unpublishing the original
// The draft is linked via meta field _draft_of_post_id
func (c *Client) CreateDraftRevision(ctx context.Context, postID int, updates PostUpdate) (int, error) {
// First, get the original post to copy metadata
originalPost, err := c.GetPost(ctx, postID, "")
if err != nil {
return 0, fmt.Errorf("failed to get original post: %w", err)
}
// Create a new post with draft status
endpoint := "/wp-json/wp/v2/posts"
// Build the draft post payload
draftPost := map[string]interface{}{
"status": "draft",
"title": updates.Title,
"content": updates.Content,
"excerpt": updates.Excerpt,
"lang": originalPost.Lang, // Preserve language
"meta": updates.Meta,
}
// Add link to original post in meta
if draftPost["meta"] == nil {
draftPost["meta"] = make(map[string]interface{})
}
metaMap := draftPost["meta"].(map[string]interface{})
metaMap[MetaDraftOfPostID] = postID
c.logger.Info("Creating linked draft copy",
"original_post_id", postID,
"has_title", updates.Title != "",
"has_content", updates.Content != "",
"has_excerpt", updates.Excerpt != "",
"meta_fields", len(updates.Meta),
)
var result Post
if err := c.post(ctx, endpoint, draftPost, &result); err != nil {
return 0, fmt.Errorf("failed to create draft copy: %w", err)
}
c.logger.Info("Draft copy created successfully",
"draft_post_id", result.ID,
"original_post_id", postID,
)
return result.ID, nil
}
// ApplyDraftToOriginal applies the draft changes to the original post and deletes the draft
func (c *Client) ApplyDraftToOriginal(ctx context.Context, draftPostID int) error {
// Get the draft post
draftPost, err := c.GetPostForEdit(ctx, draftPostID, "")
if err != nil {
return fmt.Errorf("failed to get draft post: %w", err)
}
// Verify this is actually a draft
if draftPost.Status != "draft" {
return fmt.Errorf("post %d is not a draft (status: %s)", draftPostID, draftPost.Status)
}
// Get the original post ID from meta
originalPostIDRaw, exists := draftPost.Meta[MetaDraftOfPostID]
if !exists {
return fmt.Errorf("draft post %d is not linked to an original post", draftPostID)
}
// Convert to int (might be float64 from JSON)
var originalPostID int
switch v := originalPostIDRaw.(type) {
case float64:
originalPostID = int(v)
case int:
originalPostID = v
default:
return fmt.Errorf("invalid original post ID type: %T", originalPostIDRaw)
}
c.logger.Info("Applying draft to original post",
"draft_post_id", draftPostID,
"original_post_id", originalPostID,
)
// Update the original post with draft content
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", originalPostID)
update := map[string]interface{}{
"title": rawOrRendered(draftPost.Title),
"content": rawOrRendered(draftPost.Content),
"excerpt": rawOrRendered(draftPost.Excerpt),
"status": "publish", // Keep it published
"meta": draftPost.Meta,
}
// Remove the draft link meta from the update
if metaMap, ok := update["meta"].(map[string]interface{}); ok {
delete(metaMap, MetaDraftOfPostID)
}
if err := c.post(ctx, endpoint, update, nil); err != nil {
return fmt.Errorf("failed to update original post: %w", err)
}
c.logger.Info("Original post updated successfully, deleting draft",
"original_post_id", originalPostID,
)
// Delete the draft post
deleteEndpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d?force=true", draftPostID)
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+deleteEndpoint, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
resp, err := c.doWithRetry(req)
if err != nil {
return fmt.Errorf("failed to delete draft post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to delete draft post: status %d, body: %s", resp.StatusCode, string(body))
}
c.logger.Info("Draft post deleted successfully",
"draft_post_id", draftPostID,
)
return nil
}
// ApplyDraftToOriginalWithID applies a draft to a specific original post ID.
// This is used when the draft link meta is missing but the mapping is known.
func (c *Client) ApplyDraftToOriginalWithID(ctx context.Context, draftPostID, originalPostID int) error {
draftPost, err := c.GetPostForEdit(ctx, draftPostID, "")
if err != nil {
return fmt.Errorf("failed to get draft post: %w", err)
}
if draftPost.Status != "draft" {
return fmt.Errorf("post %d is not a draft (status: %s)", draftPostID, draftPost.Status)
}
c.logger.Info("Applying draft to original post (explicit original ID)",
"draft_post_id", draftPostID,
"original_post_id", originalPostID,
)
endpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d", originalPostID)
update := map[string]interface{}{
"title": rawOrRendered(draftPost.Title),
"content": rawOrRendered(draftPost.Content),
"excerpt": rawOrRendered(draftPost.Excerpt),
"status": "publish",
"meta": draftPost.Meta,
}
if metaMap, ok := update["meta"].(map[string]interface{}); ok {
delete(metaMap, MetaDraftOfPostID)
}
if err := c.post(ctx, endpoint, update, nil); err != nil {
return fmt.Errorf("failed to update original post: %w", err)
}
c.logger.Info("Original post updated successfully, deleting draft",
"original_post_id", originalPostID,
)
return c.DeleteDraft(ctx, draftPostID)
}
func rawOrRendered(field Rendered) string {
if strings.TrimSpace(field.Raw) != "" {
return field.Raw
}
return field.Rendered
}
// DeleteDraft deletes a draft post without applying it
func (c *Client) DeleteDraft(ctx context.Context, draftPostID int) error {
draftPost, err := c.GetPost(ctx, draftPostID, "")
if err != nil {
return fmt.Errorf("failed to get draft post: %w", err)
}
if draftPost.Status != "draft" {
return fmt.Errorf("post %d is not a draft (status: %s)", draftPostID, draftPost.Status)
}
deleteEndpoint := fmt.Sprintf("/wp-json/wp/v2/posts/%d?force=true", draftPostID)
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+deleteEndpoint, nil)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
resp, err := c.doWithRetry(req)
if err != nil {
return fmt.Errorf("failed to delete draft post: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to delete draft post: status %d, body: %s", resp.StatusCode, string(body))
}
c.logger.Info("Draft post deleted successfully",
"draft_post_id", draftPostID,
)
return nil
}
// GetInternalPosts retrieves all published posts for internal linking suggestions
func (c *Client) GetInternalPosts(ctx context.Context, lang string) ([]*InternalPostReference, error) {
params := ListParams{
Lang: lang,
Status: "publish",
PerPage: 100, // Get max allowed per request
OrderBy: "date",
Order: "desc",
}
var allPosts []*InternalPostReference
page := 1
// Fetch all posts (paginated)
for {
params.Page = page
posts, err := c.ListPosts(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to fetch internal posts: %w", err)
}
if len(posts) == 0 {
break
}
// Convert to internal post references
for _, post := range posts {
ref := &InternalPostReference{
ID: post.ID,
Title: post.Title.Rendered,
Slug: post.Slug,
Link: post.Link,
Categories: post.Categories,
Tags: post.Tags,
Excerpt: post.Excerpt.Rendered,
}
allPosts = append(allPosts, ref)
}
// If we got less than per_page results, we're on the last page
if len(posts) < params.PerPage {
break
}
page++
// Safety limit to prevent infinite loops
if page > 10 {
c.logger.Warn("Reached page limit for internal posts", "pages", page)
break
}
}
c.logger.Info("Retrieved internal posts for linking",
"language", lang,
"count", len(allPosts),
)
return allPosts, nil
}
// GetCategories retrieves all categories
func (c *Client) GetCategories(ctx context.Context) ([]Category, error) {
endpoint := "/wp-json/wp/v2/categories"
params := url.Values{}
params.Set("per_page", "100")
var categories []Category
if err := c.get(ctx, endpoint, params, &categories); err != nil {
return nil, fmt.Errorf("failed to get categories: %w", err)
}
return categories, nil
}
// GetTags retrieves all tags
func (c *Client) GetTags(ctx context.Context) ([]Tag, error) {
endpoint := "/wp-json/wp/v2/tags"
params := url.Values{}
params.Set("per_page", "100")
var tags []Tag
if err := c.get(ctx, endpoint, params, &tags); err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
return tags, nil
}
// get performs a GET request to the WordPress API
func (c *Client) get(ctx context.Context, endpoint string, params url.Values, result interface{}) error {
fullURL := c.baseURL + endpoint
if len(params) > 0 {
fullURL += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
c.logger.Debug("WordPress API GET request",
"url", fullURL,
"endpoint", endpoint,
)
resp, err := c.doWithRetry(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("WordPress API error: status %d, body: %s", resp.StatusCode, string(body))
}
if result != nil {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return nil
}
// post performs a POST/PUT request to the WordPress API
func (c *Client) post(ctx context.Context, endpoint string, payload interface{}, result interface{}) error {
fullURL := c.baseURL + endpoint
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", "application/json")
c.logger.Debug("WordPress API POST request",
"url", fullURL,
"endpoint", endpoint,
"payload_size", len(jsonData),
)
resp, err := c.doWithRetry(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("WordPress API error: status %d, body: %s", resp.StatusCode, string(body))
}
if result != nil {
if err := json.Unmarshal(body, result); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return nil
}
// doWithRetry executes an HTTP request with exponential backoff retry logic
func (c *Client) doWithRetry(req *http.Request) (*http.Response, error) {
maxRetries := 3
baseDelay := 1 * time.Second
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 1s, 2s, 4s
delay := baseDelay * time.Duration(1<<uint(attempt-1))
c.logger.Debug("Retrying request",
"attempt", attempt,
"delay", delay,
)
time.Sleep(delay)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = err
c.logger.Warn("Request failed",
"attempt", attempt+1,
"error", err,
)
continue
}
// Retry on 429 (rate limit) or 5xx errors
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
c.logger.Warn("Retryable error",
"attempt", attempt+1,
"status", resp.StatusCode,
)
continue
}
return resp, nil
}
return nil, fmt.Errorf("request failed after %d attempts: %w", maxRetries+1, lastErr)
}