2181 lines
60 KiB
Go
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
|
|
}
|
|
}
|
|
}
|