Get and auto-create categories and tags

This commit is contained in:
Dejvino 2026-07-30 19:09:50 +02:00
parent ebc2de4190
commit 072f74ed2d
6 changed files with 331 additions and 57 deletions

View File

@ -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`).

78
lib/config.sh Normal file
View File

@ -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'
}

64
post.sh
View File

@ -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)

68
terms.sh Executable file
View File

@ -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 "id<TAB>name<TAB>slug<TAB>count"
# --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

View File

@ -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

View File

@ -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."