558 lines
14 KiB
Go
558 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const version = "1.0.0"
|
|
|
|
type client struct {
|
|
baseURL string
|
|
http *http.Client
|
|
site string
|
|
}
|
|
|
|
func newClient(baseURL, site string) *client {
|
|
return &client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
http: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
site: strings.TrimSpace(site),
|
|
}
|
|
}
|
|
|
|
func (c *client) do(method, path string, payload interface{}) ([]byte, int, error) {
|
|
var body io.Reader
|
|
if payload != nil {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
body = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, c.baseURL+path, body)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("X-WPSK-Client", "cli")
|
|
if strings.TrimSpace(c.site) != "" {
|
|
req.Header.Set("X-WPSK-Site", c.site)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, err
|
|
}
|
|
|
|
return respBody, resp.StatusCode, nil
|
|
}
|
|
|
|
func main() {
|
|
defaultAPI := os.Getenv("WPSK_API_URL")
|
|
if defaultAPI == "" {
|
|
defaultAPI = "http://localhost:8080"
|
|
}
|
|
defaultSite := os.Getenv("WPSK_SITE")
|
|
apiURL := flag.String("api", defaultAPI, "API base URL")
|
|
siteID := flag.String("site", defaultSite, "Site ID")
|
|
versionFlag := flag.Bool("version", false, "Print CLI version")
|
|
usage := func() {
|
|
fmt.Fprintf(os.Stderr, "Usage: %s [--api URL] [--site SITE_ID] <command> [args]\n\n", os.Args[0])
|
|
fmt.Fprintln(os.Stderr, "Commands:")
|
|
fmt.Fprintln(os.Stderr, " health")
|
|
fmt.Fprintln(os.Stderr, " pending [summary|text <draft_post_id>]")
|
|
fmt.Fprintln(os.Stderr, " optimize <post_id> [language]")
|
|
fmt.Fprintln(os.Stderr, " status <job_id>")
|
|
fmt.Fprintln(os.Stderr, " changes <post_id>")
|
|
fmt.Fprintln(os.Stderr, " approve <draft_post_id>")
|
|
fmt.Fprintln(os.Stderr, " reject <draft_post_id>")
|
|
fmt.Fprintln(os.Stderr, " ideas [status]")
|
|
fmt.Fprintln(os.Stderr, " ideas generate")
|
|
fmt.Fprintln(os.Stderr, " ideas draft <idea_id>")
|
|
fmt.Fprintln(os.Stderr, " ideas publish <idea_id>")
|
|
fmt.Fprintln(os.Stderr, " ideas delete <idea_id>")
|
|
fmt.Fprintln(os.Stderr, " outreach [status]")
|
|
fmt.Fprintln(os.Stderr, " outreach generate")
|
|
fmt.Fprintln(os.Stderr, " outreach delete <id>")
|
|
fmt.Fprintln(os.Stderr, " newsletter [status]")
|
|
fmt.Fprintln(os.Stderr, " newsletter generate")
|
|
fmt.Fprintln(os.Stderr, " newsletter delete <id>")
|
|
fmt.Fprintln(os.Stderr, " orphans")
|
|
fmt.Fprintln(os.Stderr, " translate <post_id> [language]")
|
|
fmt.Fprintln(os.Stderr, " audit [limit]")
|
|
fmt.Fprintln(os.Stderr, " version")
|
|
fmt.Fprintln(os.Stderr, " shell")
|
|
fmt.Fprintln(os.Stderr, " help")
|
|
fmt.Fprintln(os.Stderr, "")
|
|
fmt.Fprintln(os.Stderr, "Examples:")
|
|
fmt.Fprintln(os.Stderr, " wp-sk-cli pending")
|
|
fmt.Fprintln(os.Stderr, " wp-sk-cli changes 123")
|
|
fmt.Fprintln(os.Stderr, " WPSK_API_URL=http://server:8080 wp-sk-cli pending")
|
|
fmt.Fprintln(os.Stderr, " wp-sk-cli approve 456 --api http://server:8080")
|
|
fmt.Fprintln(os.Stderr, " wp-sk-cli --site padre pending")
|
|
}
|
|
flag.Usage = usage
|
|
flag.Parse()
|
|
|
|
if *versionFlag {
|
|
fmt.Println(version)
|
|
return
|
|
}
|
|
|
|
args := flag.Args()
|
|
if len(args) == 0 {
|
|
usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
cmd := args[0]
|
|
args = args[1:]
|
|
if cmd == "help" || cmd == "-h" || cmd == "--help" {
|
|
usage()
|
|
return
|
|
}
|
|
|
|
c := newClient(*apiURL, *siteID)
|
|
if cmd != "version" && cmd != "help" {
|
|
warnPendingOutreach(c)
|
|
}
|
|
|
|
if cmd == "shell" {
|
|
runInteractive(c)
|
|
return
|
|
}
|
|
|
|
if err := executeCommand(c, cmd, args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func handleSimpleGet(c *client, path string) {
|
|
body, status, err := c.do(http.MethodGet, path, nil)
|
|
handleResponse(body, status, err)
|
|
}
|
|
|
|
func handleSimplePost(c *client, path string, payload interface{}) {
|
|
body, status, err := c.do(http.MethodPost, path, payload)
|
|
handleResponse(body, status, err)
|
|
}
|
|
|
|
func handleResponse(body []byte, status int, err error) {
|
|
if err != nil {
|
|
die(err.Error())
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status)
|
|
fmt.Fprintln(os.Stderr, string(body))
|
|
os.Exit(1)
|
|
}
|
|
|
|
var pretty bytes.Buffer
|
|
if json.Indent(&pretty, body, "", " ") == nil {
|
|
fmt.Println(pretty.String())
|
|
return
|
|
}
|
|
fmt.Println(string(body))
|
|
}
|
|
|
|
type pendingResponse struct {
|
|
Count int `json:"count"`
|
|
Records []pendingRecord `json:"records"`
|
|
}
|
|
|
|
type pendingRecord struct {
|
|
OriginalPostID int `json:"original_post_id"`
|
|
DraftPostID int `json:"draft_post_id"`
|
|
PostTitle string `json:"post_title"`
|
|
}
|
|
|
|
func handlePendingSummary(c *client) {
|
|
body, status, err := c.do(http.MethodGet, "/api/v1/pending", nil)
|
|
if err != nil {
|
|
die(err.Error())
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
fmt.Fprintf(os.Stderr, "Request failed: status %d\n", status)
|
|
fmt.Fprintln(os.Stderr, string(body))
|
|
os.Exit(1)
|
|
}
|
|
|
|
var resp pendingResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
die("failed to parse response")
|
|
}
|
|
|
|
if resp.Count == 0 || len(resp.Records) == 0 {
|
|
fmt.Println("No pending records.")
|
|
return
|
|
}
|
|
|
|
for _, record := range resp.Records {
|
|
if record.OriginalPostID > 0 {
|
|
fmt.Printf("%d\t%s (original %d)\n", record.DraftPostID, record.PostTitle, record.OriginalPostID)
|
|
} else {
|
|
fmt.Printf("%d\t%s\n", record.DraftPostID, record.PostTitle)
|
|
}
|
|
}
|
|
}
|
|
|
|
func executeCommand(c *client, cmd string, args []string) error {
|
|
switch cmd {
|
|
case "health":
|
|
handleSimpleGet(c, "/api/v1/health")
|
|
case "version":
|
|
return handleVersion(c)
|
|
case "pending":
|
|
if len(args) > 0 && args[0] == "summary" {
|
|
handlePendingSummary(c)
|
|
return nil
|
|
}
|
|
if len(args) > 0 && args[0] == "text" {
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("pending text requires <draft_post_id>")
|
|
}
|
|
draftID, err := parseIntArg("draft_post_id", args[1])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return handleDraftText(c, draftID)
|
|
}
|
|
handleSimpleGet(c, "/api/v1/pending")
|
|
case "status":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("status requires <job_id>")
|
|
}
|
|
handleSimpleGet(c, "/api/v1/status/"+args[0])
|
|
case "changes":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("changes requires <post_id>")
|
|
}
|
|
handleSimpleGet(c, "/api/v1/optimization/"+args[0])
|
|
case "optimize":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("optimize requires <post_id>")
|
|
}
|
|
postID, err := parseIntArg("post_id", args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
language := "en"
|
|
if len(args) > 1 {
|
|
language = args[1]
|
|
}
|
|
handleSimplePost(c, "/api/v1/optimize", map[string]interface{}{
|
|
"post_id": postID,
|
|
"language": language,
|
|
})
|
|
case "approve":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("approve requires <draft_post_id>")
|
|
}
|
|
draftID, err := parseIntArg("draft_post_id", args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
handleSimplePost(c, "/api/v1/apply-draft", map[string]interface{}{
|
|
"draft_post_id": draftID,
|
|
})
|
|
case "reject":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("reject requires <draft_post_id>")
|
|
}
|
|
draftID, err := parseIntArg("draft_post_id", args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
handleSimplePost(c, "/api/v1/reject-draft", map[string]interface{}{
|
|
"draft_post_id": draftID,
|
|
})
|
|
case "ideas":
|
|
if len(args) == 0 {
|
|
handleSimpleGet(c, "/api/v1/ideas")
|
|
return nil
|
|
}
|
|
switch args[0] {
|
|
case "generate":
|
|
handleSimplePost(c, "/api/v1/ideas/generate", nil)
|
|
case "draft":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("ideas draft requires <idea_id>")
|
|
}
|
|
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/draft", nil)
|
|
case "publish":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("ideas publish requires <idea_id>")
|
|
}
|
|
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/publish", nil)
|
|
case "delete":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("ideas delete requires <idea_id>")
|
|
}
|
|
handleSimplePost(c, "/api/v1/ideas/"+args[1]+"/delete", nil)
|
|
default:
|
|
status := args[0]
|
|
handleSimpleGet(c, "/api/v1/ideas?status="+status)
|
|
}
|
|
case "outreach":
|
|
if len(args) == 0 {
|
|
handleSimpleGet(c, "/api/v1/outreach")
|
|
return nil
|
|
}
|
|
switch args[0] {
|
|
case "generate":
|
|
handleSimplePost(c, "/api/v1/outreach/generate", nil)
|
|
case "delete":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("outreach delete requires <id>")
|
|
}
|
|
handleSimplePost(c, "/api/v1/outreach/"+args[1]+"/delete", nil)
|
|
default:
|
|
status := args[0]
|
|
handleSimpleGet(c, "/api/v1/outreach?status="+status)
|
|
}
|
|
case "newsletter":
|
|
if len(args) == 0 {
|
|
handleSimpleGet(c, "/api/v1/newsletter")
|
|
return nil
|
|
}
|
|
switch args[0] {
|
|
case "generate":
|
|
handleSimplePost(c, "/api/v1/newsletter/generate", nil)
|
|
case "delete":
|
|
if len(args) < 2 {
|
|
return fmt.Errorf("newsletter delete requires <id>")
|
|
}
|
|
handleSimplePost(c, "/api/v1/newsletter/"+args[1]+"/delete", nil)
|
|
default:
|
|
status := args[0]
|
|
handleSimpleGet(c, "/api/v1/newsletter?status="+status)
|
|
}
|
|
case "orphans":
|
|
handleSimpleGet(c, "/api/v1/orphans?source=en&target=es")
|
|
case "translate":
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("translate requires <post_id>")
|
|
}
|
|
postID, err := parseIntArg("post_id", args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
targetLang := "es"
|
|
if len(args) > 1 {
|
|
targetLang = args[1]
|
|
}
|
|
handleSimplePost(c, "/api/v1/translate", map[string]interface{}{
|
|
"post_id": postID,
|
|
"target_lang": targetLang,
|
|
})
|
|
case "audit":
|
|
limit := 100
|
|
if len(args) > 0 {
|
|
parsed, err := strconv.Atoi(args[0])
|
|
if err != nil || parsed <= 0 {
|
|
return fmt.Errorf("audit limit must be a positive integer")
|
|
}
|
|
limit = parsed
|
|
}
|
|
handleSimpleGet(c, fmt.Sprintf("/api/v1/audit?limit=%d", limit))
|
|
default:
|
|
return fmt.Errorf("unknown command: %s", cmd)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runInteractive(c *client) {
|
|
fmt.Println("WPSideKick interactive shell. Type 'help' or 'exit'.")
|
|
warnPendingOutreach(c)
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for {
|
|
fmt.Print("wpsk> ")
|
|
if !scanner.Scan() {
|
|
break
|
|
}
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if line == "exit" || line == "quit" {
|
|
return
|
|
}
|
|
if line == "help" {
|
|
fmt.Println("Commands: health, version, pending [summary|text <draft_id>], optimize <id> [lang], status <job_id>, changes <post_id>, approve <draft_id>, reject <draft_id>, ideas [status|generate|draft <id>|publish <id>|delete <id>], outreach [status|generate|delete <id>], newsletter [status|generate|delete <id>], orphans, translate <post_id> [lang], audit [limit], exit")
|
|
continue
|
|
}
|
|
tokens := strings.Fields(line)
|
|
if len(tokens) == 0 {
|
|
continue
|
|
}
|
|
cmd := tokens[0]
|
|
args := tokens[1:]
|
|
if err := executeCommand(c, cmd, args); err != nil {
|
|
fmt.Fprintln(os.Stderr, err.Error())
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
fmt.Fprintln(os.Stderr, "interactive error:", err.Error())
|
|
}
|
|
}
|
|
|
|
type draftResponse struct {
|
|
ID int `json:"id"`
|
|
Status string `json:"status"`
|
|
Title string `json:"title"`
|
|
Excerpt string `json:"excerpt"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type healthResponse struct {
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
type outreachResponse struct {
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func handleDraftText(c *client, draftID int) error {
|
|
body, status, err := c.do(http.MethodGet, fmt.Sprintf("/api/v1/drafts/%d", draftID), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("request failed: status %d: %s", status, string(body))
|
|
}
|
|
|
|
var resp draftResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return fmt.Errorf("failed to parse response")
|
|
}
|
|
|
|
fmt.Printf("Draft %d (%s)\n", resp.ID, resp.Status)
|
|
fmt.Println(strings.Repeat("=", 60))
|
|
fmt.Println(resp.Title)
|
|
fmt.Println(strings.Repeat("-", 60))
|
|
if strings.TrimSpace(resp.Excerpt) != "" {
|
|
fmt.Println(wrapText(stripHTML(resp.Excerpt), 100))
|
|
fmt.Println(strings.Repeat("-", 60))
|
|
}
|
|
fmt.Println(wrapText(stripHTML(resp.Content), 100))
|
|
fmt.Println()
|
|
return nil
|
|
}
|
|
|
|
func handleVersion(c *client) error {
|
|
fmt.Printf("CLI version: %s\n", version)
|
|
body, status, err := c.do(http.MethodGet, "/api/v1/health", nil)
|
|
if err != nil {
|
|
fmt.Printf("Server version: unavailable (%v)\n", err)
|
|
return nil
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
fmt.Printf("Server version: unavailable (status %d)\n", status)
|
|
return nil
|
|
}
|
|
|
|
var resp healthResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
fmt.Println("Server version: unavailable (invalid response)")
|
|
return nil
|
|
}
|
|
|
|
if resp.Version == "" {
|
|
fmt.Println("Server version: unavailable")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Server version: %s\n", resp.Version)
|
|
return nil
|
|
}
|
|
|
|
func warnPendingOutreach(c *client) {
|
|
body, status, err := c.do(http.MethodGet, "/api/v1/outreach?status=new", nil)
|
|
if err != nil || status < 200 || status >= 300 {
|
|
return
|
|
}
|
|
var resp outreachResponse
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return
|
|
}
|
|
if resp.Count > 0 {
|
|
fmt.Fprintf(os.Stderr, "⚠ Pending outreach suggestions: %d (run `wp-sk-cli outreach`)\n", resp.Count)
|
|
}
|
|
}
|
|
|
|
func parseIntArg(name, value string) (int, error) {
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be an integer", name)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func die(msg string) {
|
|
fmt.Fprintln(os.Stderr, msg)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func stripHTML(value string) string {
|
|
out := strings.Builder{}
|
|
inTag := false
|
|
for _, r := range value {
|
|
switch r {
|
|
case '<':
|
|
inTag = true
|
|
case '>':
|
|
inTag = false
|
|
default:
|
|
if !inTag {
|
|
out.WriteRune(r)
|
|
}
|
|
}
|
|
}
|
|
return strings.TrimSpace(out.String())
|
|
}
|
|
|
|
func wrapText(value string, width int) string {
|
|
if width <= 0 {
|
|
return value
|
|
}
|
|
words := strings.Fields(value)
|
|
if len(words) == 0 {
|
|
return ""
|
|
}
|
|
var lines []string
|
|
line := words[0]
|
|
for _, word := range words[1:] {
|
|
if len(line)+1+len(word) > width {
|
|
lines = append(lines, line)
|
|
line = word
|
|
} else {
|
|
line += " " + word
|
|
}
|
|
}
|
|
lines = append(lines, line)
|
|
return strings.Join(lines, "\n")
|
|
}
|