Files
WPSidekick/internal/api/server.go
T
alexandrev-tibco 3a66d687e1 Minor updates
2026-02-20 16:21:18 +01:00

2181 lines
60 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"golang.org/x/net/html"
"seo-optimizer/internal/brokenlinks"
"seo-optimizer/internal/config"
"seo-optimizer/internal/ideas"
"seo-optimizer/internal/imageaudit"
"seo-optimizer/internal/logger"
"seo-optimizer/internal/newsletter"
"seo-optimizer/internal/seo"
"seo-optimizer/internal/seoaudit"
"seo-optimizer/internal/storage"
"seo-optimizer/internal/translation"
"seo-optimizer/internal/wordpress"
)
// SiteContext holds services and state for a specific site.
type SiteContext struct {
ID string
Name string
Config *config.Config
Optimizer *seo.Optimizer
WPClient *wordpress.Client
Storage *storage.OptimizationStorage
Audit *storage.AuditStorage
IdeaSvc *ideas.Service
NewsSvc *newsletter.Service
LinkSvc *brokenlinks.Service
ImageSvc *imageaudit.Service
SEOAuditSvc *seoaudit.Service
TranslationSvc *translation.Service
// In-memory job tracking (simple implementation for MVP)
jobs map[string]*Job
jobsMu sync.RWMutex
brokenLinkMu sync.Mutex
brokenLinkRunning bool
imageAuditMu sync.Mutex
imageAuditRunning bool
seoAuditMu sync.Mutex
seoAuditRunning bool
}
// Server provides HTTP API for manual optimization triggers and status checks
type Server struct {
sites map[string]*SiteContext
defaultSite string
version string
port int
server *http.Server
logger *logger.Logger
}
// Job represents an optimization job (running or completed)
type Job struct {
ID string `json:"id"`
PostID int `json:"post_id"`
Language string `json:"language"`
Status string `json:"status"` // "running", "completed", "failed"
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Error string `json:"error,omitempty"`
Summary string `json:"summary,omitempty"`
RequestedBy string `json:"requested_by,omitempty"`
}
// NewServer creates a new API server
func NewServer(sites []*SiteContext, defaultSite, version string, port int, log *logger.Logger) *Server {
if log == nil {
log = logger.NewDefaultLogger()
}
siteMap := make(map[string]*SiteContext, len(sites))
for _, site := range sites {
if site == nil || site.ID == "" {
continue
}
if site.jobs == nil {
site.jobs = make(map[string]*Job)
}
siteMap[site.ID] = site
}
return &Server{
sites: siteMap,
defaultSite: defaultSite,
version: version,
port: port,
logger: log.WithComponent("api"),
}
}
func clientFromRequest(r *http.Request) string {
if r == nil {
return "system"
}
if client := strings.TrimSpace(r.Header.Get("X-WPSK-Client")); client != "" {
return strings.ToLower(client)
}
return "api"
}
func (s *Server) recordAudit(site *SiteContext, r *http.Request, record *storage.AuditRecord) {
if site == nil || site.Audit == nil || record == nil {
return
}
if record.Actor == "" {
record.Actor = clientFromRequest(r)
}
if err := site.Audit.Save(record); err != nil {
s.logger.Warn("Failed to save audit record", "error", err)
}
}
func (s *Server) resolveSite(r *http.Request) (*SiteContext, error) {
siteID := strings.TrimSpace(r.Header.Get("X-WPSK-Site"))
if siteID == "" {
siteID = strings.TrimSpace(r.URL.Query().Get("site"))
}
if siteID == "" {
siteID = s.defaultSite
}
if siteID == "" {
return nil, fmt.Errorf("site is required")
}
site, ok := s.sites[siteID]
if !ok {
return nil, fmt.Errorf("unknown site: %s", siteID)
}
return site, nil
}
func (s *Server) requireSite(w http.ResponseWriter, r *http.Request) (*SiteContext, bool) {
site, err := s.resolveSite(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return nil, false
}
return site, true
}
// Start starts the HTTP server
func (s *Server) Start() error {
router := mux.NewRouter()
// API endpoints (no authentication - local access only)
router.HandleFunc("/api/v1/sites", s.handleListSites).Methods("GET")
router.HandleFunc("/api/v1/optimize", s.handleOptimize).Methods("POST")
router.HandleFunc("/api/v1/optimize/feedback", s.handleOptimizeFeedback).Methods("POST")
router.HandleFunc("/api/v1/apply-draft", s.handleApplyDraft).Methods("POST")
router.HandleFunc("/api/v1/reject-draft", s.handleRejectDraft).Methods("POST")
router.HandleFunc("/api/v1/pending", s.handleListPending).Methods("GET")
router.HandleFunc("/api/v1/optimization/{post_id}", s.handleGetOptimization).Methods("GET")
router.HandleFunc("/api/v1/status/{job_id}", s.handleStatus).Methods("GET")
router.HandleFunc("/api/v1/health", s.handleHealth).Methods("GET")
router.HandleFunc("/api/v1/audit", s.handleListAudit).Methods("GET")
router.HandleFunc("/api/v1/compare/{draft_id}", s.handleGetComparison).Methods("GET")
router.HandleFunc("/api/v1/ideas", s.handleListIdeas).Methods("GET")
router.HandleFunc("/api/v1/ideas/generate", s.handleGenerateIdeas).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/draft", s.handleGenerateIdeaDraft).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/publish", s.handlePublishIdeaDraft).Methods("POST")
router.HandleFunc("/api/v1/ideas/{id}/delete", s.handleDeleteIdea).Methods("POST")
router.HandleFunc("/api/v1/drafts/{id}", s.handleGetDraft).Methods("GET")
router.HandleFunc("/api/v1/outreach", s.handleListOutreach).Methods("GET")
router.HandleFunc("/api/v1/outreach/generate", s.handleGenerateOutreach).Methods("POST")
router.HandleFunc("/api/v1/outreach/{id}/status", s.handleUpdateOutreachStatus).Methods("POST")
router.HandleFunc("/api/v1/outreach/{id}/delete", s.handleDeleteOutreach).Methods("POST")
router.HandleFunc("/api/v1/newsletter", s.handleListNewsletter).Methods("GET")
router.HandleFunc("/api/v1/newsletter/generate", s.handleGenerateNewsletter).Methods("POST")
router.HandleFunc("/api/v1/newsletter/{id}/delete", s.handleDeleteNewsletter).Methods("POST")
router.HandleFunc("/api/v1/broken-links", s.handleListBrokenLinks).Methods("GET")
router.HandleFunc("/api/v1/broken-links/scan", s.handleScanBrokenLinks).Methods("POST")
router.HandleFunc("/api/v1/image-audit", s.handleListImageAudit).Methods("GET")
router.HandleFunc("/api/v1/image-audit/scan", s.handleScanImageAudit).Methods("POST")
router.HandleFunc("/api/v1/seo-audit", s.handleListSEOAudit).Methods("GET")
router.HandleFunc("/api/v1/seo-audit/scan", s.handleScanSEOAudit).Methods("POST")
router.HandleFunc("/api/v1/search", s.handleSearchContent).Methods("POST")
router.HandleFunc("/api/v1/image-audit/fix", s.handleFixExternalImages).Methods("POST")
router.HandleFunc("/api/v1/image-audit/sync-featured", s.handleSyncFeaturedImage).Methods("POST")
router.HandleFunc("/api/v1/orphans", s.handleListOrphans).Methods("GET")
router.HandleFunc("/api/v1/translate", s.handleTranslatePost).Methods("POST")
uiServer, err := uiFileServer()
if err == nil {
router.HandleFunc("/ui", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusMovedPermanently)
})
router.PathPrefix("/ui/").Handler(http.StripPrefix("/ui/", uiServer))
} else {
s.logger.Warn("Failed to initialize UI file server", "error", err)
}
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.logger.Info("API server starting", "port", s.port)
return s.server.ListenAndServe()
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("API server shutting down")
return s.server.Shutdown(ctx)
}
// handleListSites returns configured sites for UI selection.
func (s *Server) handleListSites(w http.ResponseWriter, r *http.Request) {
type siteInfo struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
Languages []string `json:"languages"`
Features struct {
Ideas bool `json:"ideas"`
Outreach bool `json:"outreach"`
Newsletter bool `json:"newsletter"`
} `json:"features"`
}
sites := make([]siteInfo, 0, len(s.sites))
for _, site := range s.sites {
info := siteInfo{
ID: site.ID,
Name: site.Name,
BaseURL: site.Config.WordPress.BaseURL,
Languages: site.Config.Scheduler.Languages,
}
info.Features.Ideas = site.Config.Ideas.Enabled
info.Features.Outreach = site.Config.Outreach.Enabled
info.Features.Newsletter = site.Config.Newsletter.Enabled
sites = append(sites, info)
}
sort.Slice(sites, func(i, j int) bool {
return sites[i].ID < sites[j].ID
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"default_site": s.defaultSite,
"sites": sites,
})
}
// handleOptimize handles POST /api/v1/optimize
// Request: {"post_id": 123, "language": "es"}
// Response: 202 Accepted + {"job_id": "uuid", "status": "accepted"}
func (s *Server) handleOptimize(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
var req struct {
PostID int `json:"post_id"`
Language string `json:"language"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.PostID <= 0 {
http.Error(w, "post_id must be positive", http.StatusBadRequest)
return
}
if req.Language == "" {
req.Language = "en" // Default to English
}
// Create job
jobID := uuid.New().String()
job := &Job{
ID: jobID,
PostID: req.PostID,
Language: req.Language,
Status: "running",
StartedAt: time.Now(),
RequestedBy: clientFromRequest(r),
}
site.jobsMu.Lock()
site.jobs[jobID] = job
site.jobsMu.Unlock()
s.logger.Info("Optimization job started",
"job_id", jobID,
"post_id", req.PostID,
"language", req.Language,
"site", site.ID,
)
s.recordAudit(site, r, &storage.AuditRecord{
Action: "optimize_requested",
Summary: fmt.Sprintf("Optimization requested for post %d (%s)", req.PostID, req.Language),
PostID: req.PostID,
JobID: jobID,
Language: req.Language,
})
// Run optimization asynchronously
go s.runOptimizationJob(site, job)
// Return accepted response
w.WriteHeader(http.StatusAccepted)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "accepted",
})
}
// handleOptimizeFeedback handles POST /api/v1/optimize/feedback
// Request: {"draft_post_id": 456, "feedback": "string"}
// Response: 202 Accepted + {"job_id": "uuid", "status": "accepted"}
func (s *Server) handleOptimizeFeedback(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
var req struct {
DraftPostID int `json:"draft_post_id"`
Feedback string `json:"feedback"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
if strings.TrimSpace(req.Feedback) == "" {
http.Error(w, "feedback is required", http.StatusBadRequest)
return
}
ctx := r.Context()
draftPost, err := site.WPClient.GetPostForEdit(ctx, req.DraftPostID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch draft: %v", err), http.StatusInternalServerError)
return
}
originalPostID, err := s.getOriginalPostIDForDraft(site, ctx, req.DraftPostID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve original post: %v", err), http.StatusInternalServerError)
return
}
language := draftPost.Lang
if language == "" {
language = "en"
}
jobID := uuid.New().String()
job := &Job{
ID: jobID,
PostID: originalPostID,
Language: language,
Status: "running",
StartedAt: time.Now(),
RequestedBy: clientFromRequest(r),
}
site.jobsMu.Lock()
site.jobs[jobID] = job
site.jobsMu.Unlock()
s.logger.Info("Feedback optimization job started",
"job_id", jobID,
"draft_post_id", req.DraftPostID,
"post_id", originalPostID,
"language", language,
"site", site.ID,
)
s.recordAudit(site, r, &storage.AuditRecord{
Action: "optimize_feedback_requested",
Summary: fmt.Sprintf("Feedback optimization requested for draft %d (post %d)", req.DraftPostID, originalPostID),
PostID: originalPostID,
JobID: jobID,
Language: language,
})
go s.runFeedbackOptimizationJob(site, job, req.Feedback)
w.WriteHeader(http.StatusAccepted)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"job_id": jobID,
"status": "accepted",
})
}
// handleStatus handles GET /api/v1/status/:job_id
// Response: {"status": "running|completed|failed", "details": "..."}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
vars := mux.Vars(r)
jobID := vars["job_id"]
site.jobsMu.RLock()
job, exists := site.jobs[jobID]
site.jobsMu.RUnlock()
if !exists {
http.Error(w, "Job not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(job)
}
// handleApplyDraft handles POST /api/v1/apply-draft
// Request: {"draft_post_id": 456}
// Response: 200 OK + {"status": "applied", "message": "Draft applied to original post"}
func (s *Server) handleApplyDraft(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
var req struct {
DraftPostID int `json:"draft_post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
if site.Optimizer != nil && site.Optimizer.IsDryRun() {
http.Error(w, "dry-run enabled: apply-draft is disabled", http.StatusBadRequest)
return
}
s.logger.Info("Applying draft to original post",
"draft_post_id", req.DraftPostID,
)
// Apply draft to original post
ctx := context.Background()
originalPostID, err := s.getOriginalPostIDForDraft(site, ctx, req.DraftPostID)
if err != nil {
s.logger.Warn("Failed to resolve original post for draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
}
if err := site.WPClient.ApplyDraftToOriginal(ctx, req.DraftPostID); err != nil {
if err := s.applyDraftWithStorageFallback(site, ctx, req.DraftPostID, err); err != nil {
s.logger.Error("Failed to apply draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to apply draft: %v", err), http.StatusInternalServerError)
return
}
}
// Update storage status to "applied"
// Extract original post ID from draft to update storage
// We need to find the record by searching for the draft post ID
pendingRecords, err := site.Storage.ListPending()
if err != nil {
s.logger.Warn("Failed to list pending records", "error", err)
} else {
for _, record := range pendingRecords {
if record.DraftPostID == req.DraftPostID {
if err := site.Storage.UpdateStatus(record.OriginalPostID, "applied"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
break
}
}
}
if site.Optimizer.SyncTranslationsEnabled() && originalPostID > 0 {
if err := s.applyTranslationDrafts(site, ctx, originalPostID, req.DraftPostID); err != nil {
s.logger.Error("Failed to apply translation drafts",
"draft_post_id", req.DraftPostID,
"original_post_id", originalPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to apply translation drafts: %v", err), http.StatusInternalServerError)
return
}
}
s.logger.Info("Draft applied successfully",
"draft_post_id", req.DraftPostID,
)
summary := fmt.Sprintf("Applied draft %d", req.DraftPostID)
if originalPostID > 0 {
summary = fmt.Sprintf("Applied draft %d to original post %d", req.DraftPostID, originalPostID)
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "draft_applied",
Summary: summary,
PostID: originalPostID,
DraftPostID: req.DraftPostID,
})
// Return success response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "applied",
"message": "Draft applied to original post and draft deleted",
})
}
func (s *Server) applyDraftWithStorageFallback(site *SiteContext, ctx context.Context, draftPostID int, applyErr error) error {
if !strings.Contains(applyErr.Error(), "not linked to an original post") {
return applyErr
}
if site == nil {
return applyErr
}
pendingRecords, err := site.Storage.ListPending()
if err != nil {
return applyErr
}
for _, record := range pendingRecords {
if record.DraftPostID == draftPostID && record.OriginalPostID > 0 {
return site.WPClient.ApplyDraftToOriginalWithID(ctx, draftPostID, record.OriginalPostID)
}
}
return applyErr
}
func (s *Server) getOriginalPostIDForDraft(site *SiteContext, ctx context.Context, draftPostID int) (int, error) {
if site == nil {
return 0, fmt.Errorf("site is required")
}
draftPost, err := site.WPClient.GetPostForEdit(ctx, draftPostID, "")
if err == nil && draftPost != nil && draftPost.Meta != nil {
if raw, ok := draftPost.Meta[wordpress.MetaDraftOfPostID]; ok {
if id, ok := parsePostID(raw); ok && id > 0 {
return id, nil
}
}
}
pendingRecords, listErr := site.Storage.ListPending()
if listErr != nil {
if err != nil {
return 0, fmt.Errorf("failed to resolve original post: %w", err)
}
return 0, listErr
}
for _, record := range pendingRecords {
if record.DraftPostID == draftPostID && record.OriginalPostID > 0 {
return record.OriginalPostID, nil
}
}
if err != nil {
return 0, fmt.Errorf("failed to resolve original post: %w", err)
}
return 0, fmt.Errorf("original post id not found for draft %d", draftPostID)
}
func (s *Server) applyTranslationDrafts(site *SiteContext, ctx context.Context, originalPostID int, primaryDraftID int) error {
if site == nil {
return fmt.Errorf("site is required")
}
originalPost, err := site.WPClient.GetPost(ctx, originalPostID, "")
if err != nil {
return fmt.Errorf("failed to fetch original post %d: %w", originalPostID, err)
}
translationIDs := translationPostIDs(originalPost, originalPostID)
if len(translationIDs) == 0 {
return nil
}
pendingRecords, err := site.Storage.ListPending()
if err != nil {
return fmt.Errorf("failed to list pending records: %w", err)
}
pendingByOriginal := make(map[int]*storage.OptimizationRecord, len(pendingRecords))
for _, record := range pendingRecords {
pendingByOriginal[record.OriginalPostID] = record
}
var errs []error
for _, translationID := range translationIDs {
record, exists := pendingByOriginal[translationID]
if !exists || record.DraftPostID <= 0 || record.DraftPostID == primaryDraftID {
continue
}
s.logger.Info("Applying translation draft",
"draft_post_id", record.DraftPostID,
"original_post_id", record.OriginalPostID,
)
if err := site.WPClient.ApplyDraftToOriginal(ctx, record.DraftPostID); err != nil {
if err := s.applyDraftWithStorageFallback(site, ctx, record.DraftPostID, err); err != nil {
errs = append(errs, fmt.Errorf("translation draft %d (post %d): %w", record.DraftPostID, translationID, err))
continue
}
}
if err := site.Storage.UpdateStatus(record.OriginalPostID, "applied"); err != nil {
s.logger.Warn("Failed to update translation optimization record status", "error", err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
func parsePostID(raw interface{}) (int, bool) {
switch v := raw.(type) {
case float64:
return int(v), true
case int:
return v, true
case int64:
return int(v), true
case string:
var id int
if _, err := fmt.Sscanf(v, "%d", &id); err == nil {
return id, true
}
}
return 0, false
}
func getMetaString(meta map[string]interface{}, keys ...string) string {
if meta == nil {
return ""
}
for _, key := range keys {
raw, ok := meta[key]
if !ok {
continue
}
switch v := raw.(type) {
case string:
return v
case []interface{}:
if len(v) == 0 {
continue
}
if s, ok := v[0].(string); ok {
return s
}
case fmt.Stringer:
return v.String()
}
}
return ""
}
func translationPostIDs(post *wordpress.Post, excludeID int) []int {
if post == nil || len(post.Translations) == 0 {
return nil
}
ids := make([]int, 0, len(post.Translations))
for _, id := range post.Translations {
if id <= 0 || id == excludeID {
continue
}
ids = append(ids, id)
}
return ids
}
// handleRejectDraft handles POST /api/v1/reject-draft
// Request: {"draft_post_id": 456}
// Response: 200 OK + {"status": "rejected", "message": "Draft deleted"}
func (s *Server) handleRejectDraft(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
var req struct {
DraftPostID int `json:"draft_post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.DraftPostID <= 0 {
http.Error(w, "draft_post_id must be positive", http.StatusBadRequest)
return
}
if site.Optimizer != nil && site.Optimizer.IsDryRun() {
http.Error(w, "dry-run enabled: reject-draft is disabled", http.StatusBadRequest)
return
}
s.logger.Info("Rejecting draft post",
"draft_post_id", req.DraftPostID,
)
ctx := context.Background()
if err := site.WPClient.DeleteDraft(ctx, req.DraftPostID); err != nil {
s.logger.Error("Failed to delete draft",
"draft_post_id", req.DraftPostID,
"error", err,
)
http.Error(w, fmt.Sprintf("Failed to delete draft: %v", err), http.StatusInternalServerError)
return
}
originalPostID := 0
pendingRecords, err := site.Storage.ListPending()
if err != nil {
s.logger.Warn("Failed to list pending records", "error", err)
} else {
for _, record := range pendingRecords {
if record.DraftPostID == req.DraftPostID {
originalPostID = record.OriginalPostID
if err := site.Storage.UpdateStatus(record.OriginalPostID, "rejected"); err != nil {
s.logger.Warn("Failed to update optimization record status", "error", err)
}
break
}
}
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "draft_rejected",
Summary: fmt.Sprintf("Rejected draft %d", req.DraftPostID),
PostID: originalPostID,
DraftPostID: req.DraftPostID,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "rejected",
"message": "Draft deleted",
})
}
// handleListPending handles GET /api/v1/pending
// Response: 200 OK + [{"post_id": 123, "draft_post_id": 456, ...}, ...]
func (s *Server) handleListPending(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
s.logger.Debug("Listing pending optimizations")
records, err := site.Storage.ListPending()
if err != nil {
s.logger.Error("Failed to list pending optimizations", "error", err)
http.Error(w, fmt.Sprintf("Failed to list pending optimizations: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(records),
"records": records,
})
}
// handleGetOptimization handles GET /api/v1/optimization/:post_id
// Response: 200 OK + optimization details
func (s *Server) handleGetOptimization(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
vars := mux.Vars(r)
postIDStr := vars["post_id"]
postID, err := strconv.Atoi(postIDStr)
if err != nil {
http.Error(w, "Invalid post_id", http.StatusBadRequest)
return
}
s.logger.Debug("Getting optimization details", "post_id", postID)
record, err := site.Storage.Get(postID)
if err != nil {
s.logger.Error("Failed to get optimization record",
"post_id", postID,
"error", err,
)
http.Error(w, fmt.Sprintf("Optimization not found for post %d", postID), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(record)
}
// handleHealth handles GET /api/v1/health
// Response: {"status": "ok", "version": "1.0.0"}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"version": s.version,
"base_url": site.WPClient.BaseURL(),
})
}
// handleListAudit handles GET /api/v1/audit?limit=100
func (s *Server) handleListAudit(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.Audit == nil {
http.Error(w, "audit storage not configured", http.StatusNotImplemented)
return
}
limit := 100
if raw := r.URL.Query().Get("limit"); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
limit = parsed
}
}
if limit > 500 {
limit = 500
}
records, err := site.Audit.List(limit)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list audit records: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(records),
"records": records,
})
}
// handleListIdeas handles GET /api/v1/ideas?status=new
func (s *Server) handleListIdeas(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
status := r.URL.Query().Get("status")
ideas, err := site.IdeaSvc.ListIdeas(status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list ideas: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(ideas),
"ideas": ideas,
})
}
// handleGenerateIdeas handles POST /api/v1/ideas/generate
func (s *Server) handleGenerateIdeas(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
ideas, err := site.IdeaSvc.GenerateDailyIdeas(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate ideas: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "ideas_generated",
Summary: fmt.Sprintf("Generated %d ideas", len(ideas)),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(ideas),
"ideas": ideas,
})
}
// handleGenerateIdeaDraft handles POST /api/v1/ideas/{id}/draft
func (s *Server) handleGenerateIdeaDraft(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
idea, err := site.IdeaSvc.GenerateDrafts(r.Context(), id)
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate draft: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "idea_draft_generated",
Summary: fmt.Sprintf("Generated draft for idea %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(idea)
}
// handlePublishIdeaDraft handles POST /api/v1/ideas/{id}/publish
func (s *Server) handlePublishIdeaDraft(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
if site.Optimizer != nil && site.Optimizer.IsDryRun() {
http.Error(w, "dry-run enabled: publish is disabled", http.StatusBadRequest)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
idea, err := site.IdeaSvc.PublishDrafts(r.Context(), id)
if err != nil {
http.Error(w, fmt.Sprintf("failed to publish drafts: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "idea_published",
Summary: fmt.Sprintf("Published drafts for idea %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(idea)
}
// handleDeleteIdea handles POST /api/v1/ideas/{id}/delete
func (s *Server) handleDeleteIdea(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := site.IdeaSvc.DeleteIdea(r.Context(), id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete idea: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "idea_deleted",
Summary: fmt.Sprintf("Deleted idea %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "deleted",
})
}
// handleListOutreach handles GET /api/v1/outreach?status=new
func (s *Server) handleListOutreach(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
status := r.URL.Query().Get("status")
items, err := site.IdeaSvc.ListOutreach(status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list outreach suggestions: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(items),
"items": items,
})
}
// handleGenerateOutreach handles POST /api/v1/outreach/generate
func (s *Server) handleGenerateOutreach(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
items, err := site.IdeaSvc.GenerateOutreachSuggestions(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate outreach suggestions: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "outreach_generated",
Summary: fmt.Sprintf("Generated %d outreach suggestions", len(items)),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(items),
"items": items,
})
}
// handleDeleteOutreach handles POST /api/v1/outreach/{id}/delete
func (s *Server) handleDeleteOutreach(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := site.IdeaSvc.DeleteOutreach(id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete outreach suggestion: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "outreach_deleted",
Summary: fmt.Sprintf("Deleted outreach suggestion %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "deleted",
})
}
// handleUpdateOutreachStatus handles POST /api/v1/outreach/{id}/status
func (s *Server) handleUpdateOutreachStatus(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.IdeaSvc == nil {
http.Error(w, "idea service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request payload", http.StatusBadRequest)
return
}
item, err := site.IdeaSvc.UpdateOutreachStatus(id, req.Status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to update outreach suggestion status: %v", err), http.StatusBadRequest)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "outreach_status_updated",
Summary: fmt.Sprintf("Updated outreach suggestion %s to %s", id, item.Status),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// handleListNewsletter handles GET /api/v1/newsletter?status=draft
func (s *Server) handleListNewsletter(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.NewsSvc == nil {
http.Error(w, "newsletter service not configured", http.StatusNotImplemented)
return
}
status := r.URL.Query().Get("status")
items, err := site.NewsSvc.ListDrafts(status)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list newsletters: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(items),
"items": items,
})
}
// handleGenerateNewsletter handles POST /api/v1/newsletter/generate
func (s *Server) handleGenerateNewsletter(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.NewsSvc == nil {
http.Error(w, "newsletter service not configured", http.StatusNotImplemented)
return
}
item, err := site.NewsSvc.GenerateWeeklyDraft(r.Context())
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate newsletter: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "newsletter_generated",
Summary: fmt.Sprintf("Generated newsletter draft %s", item.ID),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
// handleDeleteNewsletter handles POST /api/v1/newsletter/{id}/delete
func (s *Server) handleDeleteNewsletter(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.NewsSvc == nil {
http.Error(w, "newsletter service not configured", http.StatusNotImplemented)
return
}
id := mux.Vars(r)["id"]
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := site.NewsSvc.DeleteDraft(id); err != nil {
http.Error(w, fmt.Sprintf("failed to delete newsletter: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "newsletter_deleted",
Summary: fmt.Sprintf("Deleted newsletter draft %s", id),
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "deleted",
})
}
// handleListBrokenLinks handles GET /api/v1/broken-links
func (s *Server) handleListBrokenLinks(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.LinkSvc == nil {
http.Error(w, "broken link service not configured", http.StatusNotImplemented)
return
}
report, err := site.LinkSvc.LatestReport()
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch broken link report: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"report": report,
})
}
// handleScanBrokenLinks handles POST /api/v1/broken-links/scan
func (s *Server) handleScanBrokenLinks(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.LinkSvc == nil {
http.Error(w, "broken link service not configured", http.StatusNotImplemented)
return
}
site.brokenLinkMu.Lock()
if site.brokenLinkRunning {
site.brokenLinkMu.Unlock()
http.Error(w, "broken link scan already running", http.StatusConflict)
return
}
site.brokenLinkRunning = true
site.brokenLinkMu.Unlock()
requestedBy := clientFromRequest(r)
go func() {
defer func() {
site.brokenLinkMu.Lock()
site.brokenLinkRunning = false
site.brokenLinkMu.Unlock()
}()
report, err := site.LinkSvc.RunScan(context.Background())
if err != nil {
s.logger.Error("Broken link scan failed", "error", err)
s.recordAudit(site, nil, &storage.AuditRecord{
Actor: requestedBy,
Action: "broken_links_scan_failed",
Summary: fmt.Sprintf("Broken link scan failed: %v", err),
})
return
}
s.recordAudit(site, nil, &storage.AuditRecord{
Actor: requestedBy,
Action: "broken_links_scanned",
Summary: fmt.Sprintf("Broken link scan completed (%d broken links)", report.TotalBrokenLinks),
})
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
})
}
// handleListImageAudit handles GET /api/v1/image-audit
func (s *Server) handleListImageAudit(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.ImageSvc == nil {
http.Error(w, "image audit service not configured", http.StatusNotImplemented)
return
}
report, err := site.ImageSvc.LatestReport()
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch image audit report: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"report": report,
})
}
// handleScanImageAudit handles POST /api/v1/image-audit/scan
func (s *Server) handleScanImageAudit(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.ImageSvc == nil {
http.Error(w, "image audit service not configured", http.StatusNotImplemented)
return
}
site.imageAuditMu.Lock()
if site.imageAuditRunning {
site.imageAuditMu.Unlock()
http.Error(w, "image audit already running", http.StatusConflict)
return
}
site.imageAuditRunning = true
site.imageAuditMu.Unlock()
requestedBy := clientFromRequest(r)
go func() {
defer func() {
site.imageAuditMu.Lock()
site.imageAuditRunning = false
site.imageAuditMu.Unlock()
}()
report, err := site.ImageSvc.RunAudit(context.Background())
if err != nil {
s.logger.Error("Image audit failed", "error", err)
s.recordAudit(site, nil, &storage.AuditRecord{
Actor: requestedBy,
Action: "image_audit_failed",
Summary: fmt.Sprintf("Image audit failed: %v", err),
})
return
}
s.recordAudit(site, nil, &storage.AuditRecord{
Actor: requestedBy,
Action: "image_audit_completed",
Summary: fmt.Sprintf("Image audit completed (%d external images)", report.TotalExternalImages),
})
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
})
}
// handleListSEOAudit handles GET /api/v1/seo-audit
func (s *Server) handleListSEOAudit(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.SEOAuditSvc == nil {
http.Error(w, "seo audit service not configured", http.StatusNotImplemented)
return
}
report, err := site.SEOAuditSvc.LatestReport()
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch seo audit report: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"report": report,
})
}
// handleScanSEOAudit handles POST /api/v1/seo-audit/scan
func (s *Server) handleScanSEOAudit(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.SEOAuditSvc == nil {
http.Error(w, "seo audit service not configured", http.StatusNotImplemented)
return
}
site.seoAuditMu.Lock()
if site.seoAuditRunning {
site.seoAuditMu.Unlock()
http.Error(w, "seo audit already running", http.StatusConflict)
return
}
site.seoAuditRunning = true
site.seoAuditMu.Unlock()
requestedBy := clientFromRequest(r)
go func() {
defer func() {
site.seoAuditMu.Lock()
site.seoAuditRunning = false
site.seoAuditMu.Unlock()
}()
report, err := site.SEOAuditSvc.RunScan(context.Background())
if err != nil {
s.logger.Error("SEO audit failed", "error", err)
s.recordAudit(site, nil, &storage.AuditRecord{
Actor: requestedBy,
Action: "seo_audit_failed",
Summary: fmt.Sprintf("SEO audit failed: %v", err),
})
return
}
s.recordAudit(site, nil, &storage.AuditRecord{
Actor: requestedBy,
Action: "seo_audit_completed",
Summary: fmt.Sprintf("SEO audit completed (%d posts)", report.TotalPosts),
})
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(map[string]string{
"status": "accepted",
})
}
// handleSearchContent handles POST /api/v1/search
// Request: {"query":"text","case_sensitive":false,"exact_match":true,"regex":false,"block_snippet":false,"max_results":50,"max_posts":0}
func (s *Server) handleSearchContent(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
var req struct {
Query string `json:"query"`
CaseSensitive bool `json:"case_sensitive"`
ExactMatch bool `json:"exact_match"`
Regex bool `json:"regex"`
BlockSnippet bool `json:"block_snippet"`
MaxResults int `json:"max_results"`
MaxPosts int `json:"max_posts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
query := strings.TrimSpace(req.Query)
if query == "" {
http.Error(w, "query is required", http.StatusBadRequest)
return
}
if req.MaxResults <= 0 {
req.MaxResults = 50
}
var matcher *regexp.Regexp
if req.Regex {
pattern := query
if !req.CaseSensitive {
pattern = "(?i)" + pattern
}
var err error
matcher, err = regexp.Compile(pattern)
if err != nil {
http.Error(w, fmt.Sprintf("invalid regex: %v", err), http.StatusBadRequest)
return
}
}
perPage := 100
page := 1
processed := 0
matches := make([]map[string]interface{}, 0)
for {
if req.MaxPosts > 0 && processed >= req.MaxPosts {
break
}
params := wordpress.ListParams{
Status: "publish",
OrderBy: "modified",
Order: "desc",
PerPage: perPage,
Page: page,
}
posts, err := site.WPClient.ListPosts(r.Context(), params)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list posts: %v", err), http.StatusInternalServerError)
return
}
if len(posts) == 0 {
break
}
for _, post := range posts {
if req.MaxPosts > 0 && processed >= req.MaxPosts {
break
}
processed += 1
content := post.Content.Rendered
if strings.TrimSpace(content) == "" {
continue
}
text := extractText(content)
haystack := text
needle := query
if !req.CaseSensitive && !req.Regex {
haystack = strings.ToLower(text)
needle = strings.ToLower(query)
}
var ok bool
var loc [2]int
if req.Regex {
idx := matcher.FindStringIndex(text)
if idx != nil {
ok = true
loc = [2]int{idx[0], idx[1]}
}
} else if req.ExactMatch {
if index := strings.Index(haystack, needle); index >= 0 {
ok = true
loc = [2]int{index, index + len(needle)}
}
} else {
ok = containsAllTokens(haystack, needle)
if ok {
if index := strings.Index(haystack, firstToken(needle)); index >= 0 {
loc = [2]int{index, index + len(firstToken(needle))}
}
}
}
if !ok {
continue
}
snippet := buildSnippet(text, loc[0], loc[1])
if req.BlockSnippet {
if block := extractBlockSnippet(content, query, req.CaseSensitive); block != "" {
snippet = block
}
}
matches = append(matches, map[string]interface{}{
"post_id": post.ID,
"title": post.Title.Rendered,
"language": post.Lang,
"link": post.Link,
"snippet": snippet,
})
if len(matches) >= req.MaxResults {
break
}
}
if len(posts) < perPage || len(matches) >= req.MaxResults {
break
}
page += 1
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"query": query,
"count": len(matches),
"matches": matches,
})
}
func extractText(content string) string {
root, err := html.Parse(strings.NewReader(content))
if err != nil {
return content
}
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
text := strings.TrimSpace(n.Data)
if text != "" {
if b.Len() > 0 {
b.WriteByte(' ')
}
b.WriteString(text)
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
return b.String()
}
func containsAllTokens(haystack, needle string) bool {
tokens := strings.Fields(needle)
if len(tokens) == 0 {
return false
}
for _, token := range tokens {
if !strings.Contains(haystack, token) {
return false
}
}
return true
}
func firstToken(value string) string {
tokens := strings.Fields(value)
if len(tokens) == 0 {
return ""
}
return tokens[0]
}
func buildSnippet(text string, start, end int) string {
if start < 0 || end <= start {
if len(text) > 180 {
return text[:180] + "..."
}
return text
}
window := 120
if len(text) <= window {
return text
}
snippetStart := start - window
if snippetStart < 0 {
snippetStart = 0
}
snippetEnd := end + window
if snippetEnd > len(text) {
snippetEnd = len(text)
}
prefix := ""
suffix := ""
if snippetStart > 0 {
prefix = "..."
}
if snippetEnd < len(text) {
suffix = "..."
}
return prefix + text[snippetStart:snippetEnd] + suffix
}
func extractBlockSnippet(content, query string, caseSensitive bool) string {
idx := contentIndex(content, query, caseSensitive)
if idx < 0 {
return ""
}
openIdx := strings.LastIndex(content[:idx], "<!-- wp:")
if openIdx == -1 {
return ""
}
closeIdx := strings.Index(content[idx:], "<!-- /wp:")
if closeIdx == -1 {
return ""
}
closeIdx += idx
endIdx := strings.Index(content[closeIdx:], "-->")
if endIdx == -1 {
return ""
}
block := content[openIdx : closeIdx+endIdx+3]
return buildSnippet(extractText(block), 0, 0)
}
func contentIndex(content, query string, caseSensitive bool) int {
if caseSensitive {
return strings.Index(content, query)
}
return strings.Index(strings.ToLower(content), strings.ToLower(query))
}
// handleFixExternalImages handles POST /api/v1/image-audit/fix
// Request: {"post_id": 123}
func (s *Server) handleFixExternalImages(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.ImageSvc == nil {
http.Error(w, "image audit service not configured", http.StatusNotImplemented)
return
}
var req struct {
PostID int `json:"post_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.PostID <= 0 {
http.Error(w, "post_id must be positive", http.StatusBadRequest)
return
}
draftID, replacements, err := site.ImageSvc.FixExternalImages(r.Context(), req.PostID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to fix external images: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "image_audit_fix_created",
Summary: fmt.Sprintf("Created draft %d to fix external images for post %d", draftID, req.PostID),
PostID: req.PostID,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"draft_post_id": draftID,
"replacements": replacements,
})
}
// handleSyncFeaturedImage handles POST /api/v1/image-audit/sync-featured
// Request: {"post_id": 123, "target_lang": "es"}
func (s *Server) handleSyncFeaturedImage(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.ImageSvc == nil {
http.Error(w, "image audit service not configured", http.StatusNotImplemented)
return
}
var req struct {
PostID int `json:"post_id"`
TargetLang string `json:"target_lang"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.PostID <= 0 {
http.Error(w, "post_id must be positive", http.StatusBadRequest)
return
}
targetID, err := site.ImageSvc.SyncFeaturedImage(r.Context(), req.PostID, req.TargetLang)
if err != nil {
http.Error(w, fmt.Sprintf("failed to sync featured image: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "featured_image_synced",
Summary: fmt.Sprintf("Synced featured image from post %d to post %d", req.PostID, targetID),
PostID: req.PostID,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "synced",
"target_post_id": targetID,
"source_post_id": req.PostID,
"target_language": req.TargetLang,
})
}
// handleListOrphans handles GET /api/v1/orphans?source=en&target=es
func (s *Server) handleListOrphans(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.TranslationSvc == nil {
http.Error(w, "translation service not configured", http.StatusNotImplemented)
return
}
if len(site.Config.Scheduler.Languages) < 2 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": 0,
"orphans": []interface{}{},
})
return
}
source := r.URL.Query().Get("source")
target := r.URL.Query().Get("target")
orphans, err := site.TranslationSvc.ListOrphanPosts(r.Context(), source, target)
if err != nil {
http.Error(w, fmt.Sprintf("failed to list orphan posts: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"count": len(orphans),
"orphans": orphans,
})
}
// handleTranslatePost handles POST /api/v1/translate
// Request: {"post_id": 123, "target_lang": "es"}
func (s *Server) handleTranslatePost(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
if site.TranslationSvc == nil {
http.Error(w, "translation service not configured", http.StatusNotImplemented)
return
}
var req struct {
PostID int `json:"post_id"`
TargetLang string `json:"target_lang"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if req.PostID <= 0 {
http.Error(w, "post_id must be positive", http.StatusBadRequest)
return
}
draftID, err := site.TranslationSvc.TranslatePost(r.Context(), req.PostID, req.TargetLang)
if err != nil {
http.Error(w, fmt.Sprintf("failed to translate post: %v", err), http.StatusInternalServerError)
return
}
s.recordAudit(site, r, &storage.AuditRecord{
Action: "translation_draft_created",
Summary: fmt.Sprintf("Created translation draft %d for post %d", draftID, req.PostID),
PostID: req.PostID,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"draft_post_id": draftID,
})
}
// handleGetDraft handles GET /api/v1/drafts/{id}
func (s *Server) handleGetDraft(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
idStr := mux.Vars(r)["id"]
if idStr == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
draftID, err := strconv.Atoi(idStr)
if err != nil || draftID <= 0 {
http.Error(w, "invalid draft id", http.StatusBadRequest)
return
}
post, err := site.WPClient.GetPostForEdit(r.Context(), draftID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch draft: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": post.ID,
"status": post.Status,
"title": post.Title.Rendered,
"excerpt": post.Excerpt.Rendered,
"content": post.Content.Rendered,
})
}
// handleGetComparison handles GET /api/v1/compare/{draft_id}
func (s *Server) handleGetComparison(w http.ResponseWriter, r *http.Request) {
site, ok := s.requireSite(w, r)
if !ok {
return
}
idStr := mux.Vars(r)["draft_id"]
if idStr == "" {
http.Error(w, "draft_id is required", http.StatusBadRequest)
return
}
draftID, err := strconv.Atoi(idStr)
if err != nil || draftID <= 0 {
http.Error(w, "invalid draft id", http.StatusBadRequest)
return
}
ctx := r.Context()
draftPost, err := site.WPClient.GetPostForEdit(ctx, draftID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch draft: %v", err), http.StatusInternalServerError)
return
}
record, recordErr := site.Storage.FindByDraftID(draftID)
originalPostID := 0
if recordErr == nil && record != nil {
originalPostID = record.OriginalPostID
}
if originalPostID == 0 {
originalPostID, err = s.getOriginalPostIDForDraft(site, ctx, draftID)
if err != nil {
http.Error(w, fmt.Sprintf("failed to resolve original post: %v", err), http.StatusInternalServerError)
return
}
}
originalPost, err := site.WPClient.GetPostForEdit(ctx, originalPostID, "")
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch original post: %v", err), http.StatusInternalServerError)
return
}
originalSEOTitle := getMetaString(originalPost.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
if originalSEOTitle == "" {
originalSEOTitle = originalPost.Title.Rendered
}
originalSEODescription := getMetaString(originalPost.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
if originalSEODescription == "" {
originalSEODescription = originalPost.Excerpt.Rendered
}
draftSEOTitle := getMetaString(draftPost.Meta, wordpress.MetaRankMathTitle, wordpress.MetaYoastTitle)
draftSEODescription := getMetaString(draftPost.Meta, wordpress.MetaRankMathDescription, wordpress.MetaYoastDescription)
if recordErr == nil && record != nil && record.Optimization != nil {
if record.Optimization.SEOMeta.Title != "" {
draftSEOTitle = record.Optimization.SEOMeta.Title
}
if record.Optimization.SEOMeta.Description != "" {
draftSEODescription = record.Optimization.SEOMeta.Description
}
}
if draftSEOTitle == "" {
draftSEOTitle = draftPost.Title.Rendered
}
if draftSEODescription == "" {
draftSEODescription = draftPost.Excerpt.Rendered
}
w.Header().Set("Content-Type", "application/json")
baseURL := ""
if site.WPClient != nil {
baseURL = site.WPClient.BaseURL()
}
json.NewEncoder(w).Encode(map[string]interface{}{
"draft": map[string]interface{}{
"id": draftPost.ID,
"title": draftPost.Title.Rendered,
"content": draftPost.Content.Rendered,
"seo_title": draftSEOTitle,
"seo_description": draftSEODescription,
"slug": draftPost.Slug,
},
"original": map[string]interface{}{
"id": originalPost.ID,
"title": originalPost.Title.Rendered,
"content": originalPost.Content.Rendered,
"seo_title": originalSEOTitle,
"seo_description": originalSEODescription,
"slug": originalPost.Slug,
},
"links": map[string]interface{}{
"draft_edit": fmt.Sprintf("%s/wp-admin/post.php?post=%d&action=edit", baseURL, draftPost.ID),
"original_edit": fmt.Sprintf("%s/wp-admin/post.php?post=%d&action=edit", baseURL, originalPost.ID),
},
})
}
// runOptimizationJob runs an optimization job in the background
func (s *Server) runOptimizationJob(site *SiteContext, job *Job) {
ctx := context.Background()
s.logger.Info("Running optimization job",
"job_id", job.ID,
"post_id", job.PostID,
"language", job.Language,
"site", site.ID,
)
// Run optimization
err := site.Optimizer.OptimizeSpecificPost(ctx, job.PostID, job.Language)
// Update job status
var auditRecord *storage.AuditRecord
site.jobsMu.Lock()
now := time.Now()
job.EndedAt = &now
if err != nil {
var skipErr *seo.SkipError
if errors.As(err, &skipErr) {
job.Status = "completed"
job.Summary = fmt.Sprintf("Skipped: %s", skipErr.Reason)
s.logger.Info("Optimization job skipped",
"job_id", job.ID,
"reason", skipErr.Reason,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_skipped",
Summary: job.Summary,
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
site.jobsMu.Unlock()
s.recordAudit(site, nil, auditRecord)
return
}
job.Status = "failed"
job.Error = err.Error()
s.logger.Error("Optimization job failed",
"job_id", job.ID,
"error", err,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_failed",
Summary: fmt.Sprintf("Optimization failed for post %d: %s", job.PostID, err.Error()),
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
} else {
job.Status = "completed"
job.Summary = "Optimization completed successfully"
s.logger.Info("Optimization job completed",
"job_id", job.ID,
"duration", now.Sub(job.StartedAt),
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_completed",
Summary: job.Summary,
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
}
site.jobsMu.Unlock()
if auditRecord != nil {
s.recordAudit(site, nil, auditRecord)
}
}
func (s *Server) runFeedbackOptimizationJob(site *SiteContext, job *Job, feedback string) {
ctx := context.Background()
s.logger.Info("Running feedback optimization job",
"job_id", job.ID,
"post_id", job.PostID,
"language", job.Language,
"site", site.ID,
)
err := site.Optimizer.OptimizeSpecificPostWithFeedback(ctx, job.PostID, job.Language, feedback)
var auditRecord *storage.AuditRecord
site.jobsMu.Lock()
now := time.Now()
job.EndedAt = &now
if err != nil {
var skipErr *seo.SkipError
if errors.As(err, &skipErr) {
job.Status = "completed"
job.Summary = fmt.Sprintf("Skipped: %s", skipErr.Reason)
s.logger.Info("Feedback optimization job skipped",
"job_id", job.ID,
"reason", skipErr.Reason,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_feedback_skipped",
Summary: job.Summary,
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
site.jobsMu.Unlock()
s.recordAudit(site, nil, auditRecord)
return
}
job.Status = "failed"
job.Error = err.Error()
s.logger.Error("Feedback optimization job failed",
"job_id", job.ID,
"error", err,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_feedback_failed",
Summary: fmt.Sprintf("Feedback optimization failed for post %d: %s", job.PostID, err.Error()),
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
} else {
job.Status = "completed"
job.Summary = "Feedback optimization completed successfully"
s.logger.Info("Feedback optimization job completed",
"job_id", job.ID,
"post_id", job.PostID,
)
auditRecord = &storage.AuditRecord{
Actor: job.RequestedBy,
Action: "optimize_feedback_completed",
Summary: fmt.Sprintf("Feedback optimization completed for post %d", job.PostID),
PostID: job.PostID,
JobID: job.ID,
Language: job.Language,
}
}
site.jobsMu.Unlock()
s.recordAudit(site, nil, auditRecord)
}
// CleanupOldJobs removes completed jobs older than the specified duration
// This prevents memory growth in long-running deployments
func (s *Server) CleanupOldJobs(maxAge time.Duration) {
cutoff := time.Now().Add(-maxAge)
for _, site := range s.sites {
site.jobsMu.Lock()
for id, job := range site.jobs {
if job.EndedAt != nil && job.EndedAt.Before(cutoff) {
delete(site.jobs, id)
}
}
site.jobsMu.Unlock()
}
}
// StartPeriodicCleanup starts a background goroutine that cleans up old jobs
func (s *Server) StartPeriodicCleanup(ctx context.Context, cleanupInterval, maxJobAge time.Duration) {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.CleanupOldJobs(maxJobAge)
s.logger.Debug("Cleaned up old jobs")
case <-ctx.Done():
return
}
}
}