diff --git a/README.md b/README.md index 005b4a0..03e3b4b 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ If you post to more than one WordPress instance, copy `site.sh.template` to one at run time with `--site NAME` (see Options below) — its values are layered on top of `config.sh`, overriding only what it sets. -Copy the posting script to e.g. `/usr/local/bin/wordpress-post` so that you can run it from anywhere. +Copy the scripts to e.g. `/usr/local/bin/wordpress-post` and `/usr/local/bin/wordpress-terms` so that you can run them from anywhere. Keep them in the same directory as `lib/config.sh`, or copy that directory alongside them — both scripts source it. ## Usage -* Run the script +* Run `post.sh` * Text editor opens. Type your post, save it. * Confirm the displayed post by pressing enter. * Done! @@ -40,10 +40,24 @@ Copy the posting script to e.g. `/usr/local/bin/wordpress-post` so that you can * `--publish` — shortcut for `STATUS=publish`, posts live immediately. * `--edit POST_ID` — update an existing post instead of creating a new one. * `--date DATE` — backdate the post to DATE, an ISO 8601 UTC timestamp (e.g. `2026-07-27T10:00:00Z`, matching a Gitea commit date) — sent as the post's `date_gmt`. +* `--category NAME` — attach category NAME by name (repeatable). Looked up on confirm; created automatically if it doesn't exist yet. Merged with any numeric IDs already in `CATEGORIES`. +* `--tag NAME` — same as `--category`, for tags (repeatable). Merged with `TAGS`. + +## Categories and tags +`post.sh`'s `CATEGORIES`/`TAGS` config vars are numeric IDs, but `--category`/`--tag` (see Options) let you pass names directly on the command line — no separate lookup step needed, they're resolved (or created if missing) as part of running `post.sh`. + +`terms.sh` is for inspecting or managing terms directly (same `--site NAME` config as `post.sh`): + +```bash +./terms.sh --list # id, name, slug, count — one per line +./terms.sh --type tag --list # tags instead of categories +./terms.sh --find "Hardware" # prints the id, or exits 1 if missing +./terms.sh --create "Hardware" # creates it, prints the new id +``` ## Testing -`tests/run_tests.sh` runs post.sh against a local mock of the WordPress REST -API (`tests/mock_wp_server.py`) — no real WordPress install or network access -needed. Requires the same dependencies as the script itself (`curl`, `jq`, -`python3-markdown2`). +`tests/run_tests.sh` runs post.sh and terms.sh against a local mock of the +WordPress REST API (`tests/mock_wp_server.py`) — no real WordPress install or +network access needed. Requires the same dependencies as the scripts +themselves (`curl`, `jq`, `python3-markdown2`). diff --git a/lib/config.sh b/lib/config.sh new file mode 100644 index 0000000..9dee881 --- /dev/null +++ b/lib/config.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Shared by post.sh and terms.sh: layered config loading and base URL +# construction. The caller sets SITE (possibly empty) and CONFIG_DIR before +# calling wp_load_config, and USER/PASSWORD/SERVER/... end up in scope. + +CONFIG_DIR="${WORDPRESS_REST_CURL_CONFIG_DIR:-$HOME/.config/wordpress-rest-curl}" + +wp_load_config() { + local default_config_file="$CONFIG_DIR/config.sh" + [[ -e "$default_config_file" ]] && source "$default_config_file" + + if [[ -n "$SITE" ]]; then + local site_config_file="$CONFIG_DIR/$SITE.sh" + [[ -e "$site_config_file" ]] || { echo "Config file not found: $site_config_file" >&2; exit 1; } + source "$site_config_file" + elif [[ ! -e "$default_config_file" ]]; then + echo "No site specified and no default config found. Pass --site NAME or create $default_config_file" >&2 + exit 1 + fi +} + +# Prints $SERVER normalized into a scheme-qualified, slash-trimmed base URL. +wp_base_url() { + local base_url="$SERVER" + [[ "$base_url" != http://* && "$base_url" != https://* ]] && base_url="https://$base_url" + echo "${base_url%/}" +} + +# Runs curl against the WP REST API with shared auth/UA/error-handling. +# Usage: wp_api_call METHOD PATH [curl-args...] +# Prints the response body on success; on a non-2xx status prints the error +# to stderr and returns 1 (does not exit, so callers can decide what to do). +wp_api_call() { + local method="$1" path="$2" + shift 2 + local response http_code body + response=$(curl --silent --show-error --user "$USER:$PASSWORD" -X "$method" \ + -A "$USER_AGENT" \ + -w '\n%{http_code}' \ + "$@" \ + "$(wp_base_url)$path") + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then + echo "Error: WordPress API returned HTTP $http_code" >&2 + echo "$body" >&2 + return 1 + fi + echo "$body" +} + +# Prints every item across all pages of a paginated WP list endpoint at +# PATH, one JSON object per line. +wp_api_list_all() { + local path="$1" page=1 count body + while :; do + body=$(wp_api_call GET "$path?per_page=100&page=$page") || exit 1 + count=$(echo "$body" | jq 'length') + [[ "$count" -eq 0 ]] && break + echo "$body" | jq -c '.[]' + [[ "$count" -lt 100 ]] && break + page=$((page + 1)) + done +} + +# Resolves NAME to a numeric term ID under taxonomy endpoint PATH (e.g. +# /wp-json/wp/v2/categories), creating the term first if it doesn't exist. +wp_resolve_term() { + local path="$1" name="$2" found_id payload body + found_id=$(wp_api_list_all "$path" | jq -r --arg name "$name" 'select(.name == $name) | .id' | head -n1) + if [[ -n "$found_id" ]]; then + echo "$found_id" + return + fi + payload=$(jq -n --arg name "$name" '{name: $name}') + body=$(wp_api_call POST "$path" -H "Content-Type: application/json" --data "$payload") || exit 1 + echo "$body" | jq -r '.id' +} diff --git a/post.sh b/post.sh index 62ed494..b0c3d19 100755 --- a/post.sh +++ b/post.sh @@ -12,7 +12,9 @@ CATEGORIES="" # comma separated integer IDs of categories TAGS="" # comma separated integer IDs of tags TMPFILE=/tmp/wordpress-post.txt # location of a temporary file with the post text USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) wordpress-rest-curl/1.0" # some hosts block curl's default UA as bot traffic -CONFIG_DIR="${WORDPRESS_REST_CURL_CONFIG_DIR:-$HOME/.config/wordpress-rest-curl}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/config.sh" # CLI options: # --site NAME after config.sh, also source $CONFIG_DIR/NAME.sh, whose @@ -25,11 +27,16 @@ CONFIG_DIR="${WORDPRESS_REST_CURL_CONFIG_DIR:-$HOME/.config/wordpress-rest-curl} # --date DATE backdate the post to DATE, an ISO 8601 UTC timestamp # (e.g. 2026-07-27T10:00:00Z, matching a Gitea commit # date) — sent as the post's date_gmt +# --category NAME attach category NAME, looking up its ID or creating it +# if it doesn't exist yet (repeatable) +# --tag NAME same as --category, for tags (repeatable) SITE="" POST_ID="" SOURCE_FILE="" PUBLISH=0 DATE="" +CATEGORY_NAMES=() +TAG_NAMES=() while [[ $# -gt 0 ]]; do case "$1" in --site) @@ -42,23 +49,17 @@ while [[ $# -gt 0 ]]; do SOURCE_FILE="$2"; shift 2 ;; --date) DATE="$2"; shift 2 ;; + --category) + CATEGORY_NAMES+=("$2"); shift 2 ;; + --tag) + TAG_NAMES+=("$2"); shift 2 ;; *) echo "Unknown argument: $1" >&2 exit 1 ;; esac done -DEFAULT_CONFIG_FILE="$CONFIG_DIR/config.sh" -[[ -e "$DEFAULT_CONFIG_FILE" ]] && source "$DEFAULT_CONFIG_FILE" - -if [[ -n "$SITE" ]]; then - SITE_CONFIG_FILE="$CONFIG_DIR/$SITE.sh" - [[ -e "$SITE_CONFIG_FILE" ]] || { echo "Config file not found: $SITE_CONFIG_FILE" >&2; exit 1; } - source "$SITE_CONFIG_FILE" -elif [[ ! -e "$DEFAULT_CONFIG_FILE" ]]; then - echo "No site specified and no default config found. Pass --site NAME or create $DEFAULT_CONFIG_FILE" >&2 - exit 1 -fi +wp_load_config [[ "$PUBLISH" -eq 1 ]] && STATUS="publish" @@ -108,8 +109,12 @@ echo "Title: $TITLE" echo "User: $USER" echo "Server: $SERVER" echo "Status: $STATUS" -echo "Categories: $CATEGORIES" -echo "Tags: $TAGS" +CATEGORIES_DISPLAY="$CATEGORIES" +[[ ${#CATEGORY_NAMES[@]} -gt 0 ]] && CATEGORIES_DISPLAY="$CATEGORIES_DISPLAY (+ ${CATEGORY_NAMES[*]}, resolved/created on confirm)" +echo "Categories: $CATEGORIES_DISPLAY" +TAGS_DISPLAY="$TAGS" +[[ ${#TAG_NAMES[@]} -gt 0 ]] && TAGS_DISPLAY="$TAGS_DISPLAY (+ ${TAG_NAMES[*]}, resolved/created on confirm)" +echo "Tags: $TAGS_DISPLAY" if [[ -n "$DATE" ]]; then echo "Date: $DATE" fi @@ -124,10 +129,19 @@ CATEGORIES_JSON="[]" if [[ -n "$CATEGORIES" ]]; then CATEGORIES_JSON=$(echo "$CATEGORIES" | tr ',' '\n' | jq -R 'select(length > 0) | tonumber' | jq -s '.') fi +for NAME in "${CATEGORY_NAMES[@]}"; do + ID=$(wp_resolve_term "/wp-json/wp/v2/categories" "$NAME") || exit 1 + CATEGORIES_JSON=$(jq -c --argjson id "$ID" '. + [$id] | unique' <<<"$CATEGORIES_JSON") +done + TAGS_JSON="[]" if [[ -n "$TAGS" ]]; then TAGS_JSON=$(echo "$TAGS" | tr ',' '\n' | jq -R 'select(length > 0) | tonumber' | jq -s '.') fi +for NAME in "${TAG_NAMES[@]}"; do + ID=$(wp_resolve_term "/wp-json/wp/v2/tags" "$NAME") || exit 1 + TAGS_JSON=$(jq -c --argjson id "$ID" '. + [$id] | unique' <<<"$TAGS_JSON") +done PAYLOAD=$(jq -n \ --arg title "$TITLE" \ @@ -139,27 +153,11 @@ PAYLOAD=$(jq -n \ '{title: $title, content: $content, status: $status, categories: $categories, tags: $tags} + (if $date_gmt != "" then {date_gmt: $date_gmt} else {} end)') -BASE_URL="$SERVER" -[[ "$BASE_URL" != http://* && "$BASE_URL" != https://* ]] && BASE_URL="https://$BASE_URL" -BASE_URL="${BASE_URL%/}" -URL="$BASE_URL/wp-json/wp/v2/posts/" -[[ -n "$POST_ID" ]] && URL="$BASE_URL/wp-json/wp/v2/posts/$POST_ID" +POST_PATH="/wp-json/wp/v2/posts/" +[[ -n "$POST_ID" ]] && POST_PATH="/wp-json/wp/v2/posts/$POST_ID" # push the post! -RESPONSE=$(curl --silent --show-error --user "$USER:$PASSWORD" -X POST \ - -H "Content-Type: application/json" \ - -A "$USER_AGENT" \ - --data "$PAYLOAD" \ - -w '\n%{http_code}' \ - "$URL") -HTTP_CODE=$(echo "$RESPONSE" | tail -n1) -BODY=$(echo "$RESPONSE" | sed '$d') - -if [[ "$HTTP_CODE" -lt 200 || "$HTTP_CODE" -ge 300 ]]; then - echo "Error: WordPress API returned HTTP $HTTP_CODE" >&2 - echo "$BODY" >&2 - exit 1 -fi +BODY=$(wp_api_call POST "$POST_PATH" -H "Content-Type: application/json" --data "$PAYLOAD") || exit 1 echo "$BODY" # backup the posted data (temporarily until it is auto-removed) diff --git a/terms.sh b/terms.sh new file mode 100755 index 0000000..731b12b --- /dev/null +++ b/terms.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Manage WordPress categories and tags: list existing ones or create new +# ones, so post.sh's CATEGORIES/TAGS (numeric IDs) can be filled in. + +USER="" # WP user +PASSWORD="" # application password generated for your WP user +SERVER="" # server hostname, optionally with subdirectories +USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) wordpress-rest-curl/1.0" # some hosts block curl's default UA as bot traffic + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/config.sh" + +# CLI options: +# --site NAME same config layering as post.sh +# --type TYPE 'category' (default) or 'tag' +# --list list existing terms as "idnameslugcount" +# --create NAME create a new term, print its id +# --find NAME print the id of an existing term with an exact name +# match, or exit 1 if none exists +SITE="" +TYPE="category" +ACTION="" +NAME="" +while [[ $# -gt 0 ]]; do + case "$1" in + --site) + SITE="$2"; shift 2 ;; + --type) + TYPE="$2"; shift 2 ;; + --list) + ACTION="list"; shift ;; + --create) + ACTION="create"; NAME="$2"; shift 2 ;; + --find) + ACTION="find"; NAME="$2"; shift 2 ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 ;; + esac +done + +[[ "$TYPE" == "category" || "$TYPE" == "tag" ]] || { echo "Unknown --type: $TYPE (expected 'category' or 'tag')" >&2; exit 1; } +[[ -n "$ACTION" ]] || { echo "Specify one of --list, --create NAME, --find NAME" >&2; exit 1; } + +wp_load_config + +ENDPOINT_PATH="/wp-json/wp/v2/categories" +[[ "$TYPE" == "tag" ]] && ENDPOINT_PATH="/wp-json/wp/v2/tags" + +case "$ACTION" in + list) + wp_api_list_all "$ENDPOINT_PATH" | jq -r '[.id, .name, .slug, .count] | @tsv' + ;; + find) + FOUND_ID=$(wp_api_list_all "$ENDPOINT_PATH" | jq -r --arg name "$NAME" 'select(.name == $name) | .id' | head -n1) + if [[ -n "$FOUND_ID" ]]; then + echo "$FOUND_ID" + else + echo "No $TYPE named '$NAME' found." >&2 + exit 1 + fi + ;; + create) + PAYLOAD=$(jq -n --arg name "$NAME" '{name: $name}') + BODY=$(wp_api_call POST "$ENDPOINT_PATH" -H "Content-Type: application/json" --data "$PAYLOAD") || exit 1 + echo "$BODY" | jq -r '.id' + ;; +esac diff --git a/tests/mock_wp_server.py b/tests/mock_wp_server.py index 652656c..7ae5354 100755 --- a/tests/mock_wp_server.py +++ b/tests/mock_wp_server.py @@ -1,28 +1,90 @@ #!/usr/bin/env python3 -# Minimal mock of the WordPress REST API /wp/v2/posts endpoint, for testing -# post.sh without a real WordPress install. +# Minimal mock of the WordPress REST API, for testing post.sh and terms.sh +# without a real WordPress install. # -# Behavior is controlled by two files (paths given via env vars): +# Handles POST /wp-json/wp/v2/posts(/ID) same as before, plus +# GET/POST /wp-json/wp/v2/categories and /wp-json/wp/v2/tags. +# +# Behavior is controlled by files (paths given via env vars): # MOCK_WP_CONTROL_FILE - optional JSON {"status": int, "body": {...}} that -# determines the response to the next request. If absent, responds -# 201 with the submitted fields echoed back under an "id". +# determines the response to the next POST /posts request. If absent, +# responds 201 with the submitted fields echoed back under an "id". # MOCK_WP_REQUEST_FILE - overwritten on every request with # {"path": ..., "payload": ..., "user_agent": ...} so a test can assert # on what was sent. +# MOCK_WP_TERMS_FILE - JSON {"categories": [...], "tags": [...]}, used as +# the backing store for the categories/tags endpoints. Created terms +# are appended and persisted back to this file. # # Listens on 127.0.0.1:$MOCK_WP_PORT (default 8899). import http.server import json import os +import re +from urllib.parse import urlparse, parse_qs PORT = int(os.environ.get("MOCK_WP_PORT", "8899")) CONTROL_FILE = os.environ["MOCK_WP_CONTROL_FILE"] REQUEST_FILE = os.environ["MOCK_WP_REQUEST_FILE"] +TERMS_FILE = os.environ["MOCK_WP_TERMS_FILE"] + +TERM_TYPES = { + "/wp-json/wp/v2/categories": "categories", + "/wp-json/wp/v2/tags": "tags", +} + + +def slugify(name): + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def load_terms(): + if os.path.exists(TERMS_FILE): + with open(TERMS_FILE) as f: + return json.load(f) + return {"categories": [], "tags": []} + + +def save_terms(terms): + with open(TERMS_FILE, "w") as f: + json.dump(terms, f) class Handler(http.server.BaseHTTPRequestHandler): + def _write_json(self, status, body): + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _record_request(self, path, data): + with open(REQUEST_FILE, "w") as f: + json.dump({ + "path": path, + "payload": data, + "user_agent": self.headers.get("User-Agent", ""), + }, f) + + def do_GET(self): + parsed = urlparse(self.path) + term_type = TERM_TYPES.get(parsed.path) + if term_type is None: + self._write_json(404, {"code": "not_found"}) + return + + qs = parse_qs(parsed.query) + per_page = int(qs.get("per_page", ["10"])[0]) + page = int(qs.get("page", ["1"])[0]) + + terms = load_terms()[term_type] + start = (page - 1) * per_page + self._write_json(200, terms[start:start + per_page]) + def do_POST(self): + parsed = urlparse(self.path) length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) try: @@ -30,12 +92,18 @@ class Handler(http.server.BaseHTTPRequestHandler): except json.JSONDecodeError: data = {"_raw": body.decode(errors="replace")} - with open(REQUEST_FILE, "w") as f: - json.dump({ - "path": self.path, - "payload": data, - "user_agent": self.headers.get("User-Agent", ""), - }, f) + self._record_request(self.path, data) + + term_type = TERM_TYPES.get(parsed.path) + if term_type is not None: + terms = load_terms() + next_id = max([t["id"] for t in terms[term_type]], default=0) + 1 + name = data.get("name", "") + term = {"id": next_id, "name": name, "slug": slugify(name), "count": 0} + terms[term_type].append(term) + save_terms(terms) + self._write_json(201, term) + return status, resp_body = 201, {"id": 1, **data} if os.path.exists(CONTROL_FILE): @@ -43,13 +111,7 @@ class Handler(http.server.BaseHTTPRequestHandler): control = json.load(f) status = control.get("status", status) resp_body = control.get("body", resp_body) - - payload = json.dumps(resp_body).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) + self._write_json(status, resp_body) def log_message(self, format, *args): pass diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 586cb42..cedf44a 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -16,6 +16,7 @@ trap 'kill $SERVER_PID 2>/dev/null; rm -rf "$WORKDIR"' EXIT export MOCK_WP_PORT=$PORT export MOCK_WP_CONTROL_FILE="$WORKDIR/control.json" export MOCK_WP_REQUEST_FILE="$WORKDIR/request.json" +export MOCK_WP_TERMS_FILE="$WORKDIR/terms.json" python3 -u tests/mock_wp_server.py & SERVER_PID=$! @@ -53,6 +54,10 @@ run_post_sh() { printf "" | "$PWD/post.sh" "$@" } +run_terms_sh() { + "$PWD/terms.sh" "$@" +} + FAIL=0 assert_eq() { local desc="$1" expected="$2" actual="$3" @@ -146,6 +151,55 @@ assert_eq "error: exit code" "1" "$?" assert_eq "error: HTTP status surfaced" "true" "$(grep -q "HTTP 401" <<<"$OUT" && echo true || echo false)" rm -f "$MOCK_WP_CONTROL_FILE" +# --- terms.sh: list starts empty --- +rm -f "$MOCK_WP_TERMS_FILE" +OUT=$(run_terms_sh --list) +assert_eq "terms list: exit code" "0" "$?" +assert_eq "terms list: empty when no categories exist" "" "$OUT" + +# --- terms.sh: create a category, then find and list it --- +OUT=$(run_terms_sh --create "Hardware") +assert_eq "terms create: exit code" "0" "$?" +assert_eq "terms create: prints the new id" "1" "$OUT" + +OUT=$(run_terms_sh --find "Hardware") +assert_eq "terms find: exit code" "0" "$?" +assert_eq "terms find: resolves name to id" "1" "$OUT" + +OUT=$(run_terms_sh --list) +assert_eq "terms list: shows created category" "1 Hardware hardware 0" "$OUT" + +# --- terms.sh: categories and tags are separate namespaces --- +OUT=$(run_terms_sh --type tag --create "arduino") +assert_eq "terms create tag: exit code" "0" "$?" +assert_eq "terms create tag: gets its own id sequence" "1" "$OUT" + +OUT=$(run_terms_sh --type tag --list) +assert_eq "terms list tags: shows only the tag, not the category" "1 arduino arduino 0" "$OUT" + +# --- terms.sh: --find on a name that doesn't exist --- +OUT=$(run_terms_sh --find "Does Not Exist" 2>&1) +assert_eq "terms find missing: exits non-zero" "1" "$?" +assert_eq "terms find missing: error message" "true" "$(grep -q "No category named" <<<"$OUT" && echo true || echo false)" + +# --- post.sh: --category/--tag by name resolve to an existing term, merged with config IDs --- +rm -f "$MOCK_WP_REQUEST_FILE" "$MOCK_WP_CONTROL_FILE" +run_post_sh --file "$POST_FILE" --category "Hardware" >/dev/null +assert_eq "post category resolve: exit code" "0" "$?" +REQ=$(cat "$MOCK_WP_REQUEST_FILE") +assert_eq "post category resolve: merges existing id with config CATEGORIES" "[1,3,7]" "$(jq -c '.payload.categories | sort' <<<"$REQ")" +assert_eq "post category resolve: does not create a duplicate" "1" "$(run_terms_sh --list | wc -l)" + +# --- post.sh: --category/--tag by a new name creates it, then attaches it --- +rm -f "$MOCK_WP_REQUEST_FILE" "$MOCK_WP_CONTROL_FILE" +run_post_sh --file "$POST_FILE" --category "Software" --tag "midi" >/dev/null +assert_eq "post category create: exit code" "0" "$?" +NEW_CAT_ID=$(run_terms_sh --find "Software") +NEW_TAG_ID=$(run_terms_sh --type tag --find "midi") +REQ=$(cat "$MOCK_WP_REQUEST_FILE") +assert_eq "post category create: includes the newly created category" "true" "$(jq --argjson id "$NEW_CAT_ID" '.payload.categories | index($id) != null' <<<"$REQ")" +assert_eq "post tag create: includes the newly created tag" "true" "$(jq --argjson id "$NEW_TAG_ID" '.payload.tags | index($id) != null' <<<"$REQ")" + echo if [[ $FAIL -eq 0 ]]; then echo "All tests passed."