626 lines
16 KiB
Go
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)
|
|
}
|