Files
claudetools/.claude/commands/forum-post.md
Mike Swanson 517278f66f sync: auto-sync from DESKTOP-0O8A1RL at 2026-05-20 14:38:07
Author: Mike Swanson
Machine: DESKTOP-0O8A1RL
Timestamp: 2026-05-20 14:38:07
2026-05-20 14:38:15 -07:00

11 KiB

Post a technical article to community.azcomputerguru.com (Flarum forum).

Converts markdown to Flarum's s9e TextFormatter XML format and inserts directly into the database via paramiko SSH to IX. Shows a preview and waits for user confirmation before posting.


Usage

/forum-post                    Draft interactively — Claude asks for title, content, tag
/forum-post <topic hint>       Claude drafts from conversation context, then confirms

Arguments are optional. With no args, Claude uses the current conversation context to determine what to post (the most recent technical problem solved, fix documented, etc.).


Infrastructure

Item Value
Forum URL https://community.azcomputerguru.com
DB host localhost (on IX)
DB name azcompu_flarum
DB user azcompu_flarum
DB pass Fl@rum2026!CGS
IX SSH root@172.16.3.10 — password from vault: infrastructure/ix-server.sops.yaml credentials.password
Admin user_id 1 (MikeSwanson)

Get the IX SSH password via vault before connecting:

bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" get-field infrastructure/ix-server.sops.yaml credentials.password

Known Tags

tag_id Name When to use
7 How-Tos & Tips Technical fixes, how-tos, diagnostics, field notes

To see all current tags: query SELECT id, name_singular FROM tags; on azcompu_flarum. Default to tag_id=7 unless the user specifies otherwise.


Phase 1: Gather Inputs

Collect in one pass — do not ask field by field:

  1. Title — descriptive, search-friendly, sentence case. Include the technology/product. Good: OneDrive KFM Fails After Folder Redirection Migration -- Here's What's Actually Going On Bad: OneDrive issue fix

  2. Content — full markdown body. Minimum useful length: 3-4 paragraphs with concrete detail. Write from the conversation context: what was the problem, what was tried, what actually worked, what to remember.

  3. Tag — default How-Tos & Tips (7) unless user says otherwise.

Generate the slug from the title: lowercase, spaces/punctuation to hyphens, remove apostrophes, collapse multiple hyphens. Max ~90 chars. Example: onedrive-kfm-fails-after-folder-redirection-migration-heres-whats-actually-going-on


Phase 2: Show Preview

Before posting, show:

FORUM POST PREVIEW
------------------
Title:  <title>
Tag:    How-Tos & Tips
Slug:   <slug>
URL:    https://community.azcomputerguru.com/d/<next_id>-<slug>

--- Content ---
<first 500 chars of markdown>
...

Post this? (yes/no)

Wait for explicit confirmation before executing.


Phase 3: Execute

Write and run a Python script (use py on Windows). The script must:

  1. Get IX SSH password from vault (see Infrastructure above)
  2. Connect via paramiko (AutoAddPolicy, password auth)
  3. Generate the s9e XML from the markdown (see Converter section below)
  4. Build the PHP insert script (see PHP Template below)
  5. SFTP upload the PHP script to /tmp/flarum_post_<timestamp>.php on IX
  6. Run php /tmp/flarum_post_<timestamp>.php 2>&1 via SSH
  7. Parse output — look for Discussion ID: N and Post ID: N
  8. Clean uprm /tmp/flarum_post_<timestamp>.php
  9. Report the live URL

Markdown → s9e XML Converter

Flarum stores post content as s9e TextFormatter XML, not raw markdown. The stored format must match what Flarum's TextFormatter produces. Based on confirmed existing posts:

Inline elements

Markdown s9e XML
**bold** <STRONG><s>**</s>bold<e>**</e></STRONG>
*italic* <EM><s>*</s>italic<e>*</e></EM>
`code` <C><s>code</e></C>

XML-escape &&amp;, <&lt;, >&gt; in all text content and code spans.

Block elements

Markdown s9e XML
## Heading <H2><s>## </s>Heading</H2>
### Heading <H3><s>### </s>Heading</H3>
Paragraph <p>text</p>
- item (list) <LIST><LI><s>- </s>item</LI>\n<LI>...</LI></LIST>
1. item (ordered) <LIST type="decimal"><LI><s>1. </s>item</LI>\n...</LIST>
```lang fenced block <CODE lang="lang"><s>```lang</s><i>\n</i>code\n<e>```</e></CODE>

Block elements are separated by \n\n (two real newlines) inside the <r> root. List items are separated by \n (one newline). Entire content is wrapped: <r>...</r>.

Python converter (copy this directly into the py script)

import re

def xml_escape(t):
    return t.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')

def inline_to_xml(text):
    result = ''
    i = 0
    while i < len(text):
        if text[i:i+2] == '**':
            end = text.find('**', i+2)
            if end != -1:
                inner = inline_to_xml(text[i+2:end])
                result += '<STRONG><s>**</s>' + inner + '<e>**</e></STRONG>'
                i = end + 2; continue
        if text[i] == '`':
            end = text.find('`', i+1)
            if end != -1:
                result += '<C><s>`</s>' + xml_escape(text[i+1:end]) + '<e>`</e></C>'
                i = end + 1; continue
        if text[i] == '*' and text[i:i+2] != '**':
            j = i + 1; end = -1
            while j < len(text):
                if text[j] == '*' and text[j:j+2] != '**': end = j; break
                j += 1
            if end != -1:
                result += '<EM><s>*</s>' + xml_escape(text[i+1:end]) + '<e>*</e></EM>'
                i = end + 1; continue
        result += xml_escape(text[i]); i += 1
    return result

def md_to_s9e(md):
    lines = md.split('\n')
    elements = []
    i = 0
    while i < len(lines):
        line = lines[i]
        if not line.strip(): i += 1; continue
        if line.startswith('## '):
            elements.append('<H2><s>## </s>' + inline_to_xml(line[3:]) + '</H2>'); i += 1
        elif line.startswith('### '):
            elements.append('<H3><s>### </s>' + inline_to_xml(line[4:]) + '</H3>'); i += 1
        elif line.startswith('- '):
            items = []
            while i < len(lines) and lines[i].startswith('- '):
                items.append('<LI><s>- </s>' + inline_to_xml(lines[i][2:]) + '</LI>'); i += 1
            elements.append('<LIST>' + '\n'.join(items) + '</LIST>')
        elif re.match(r'^\d+\. ', line):
            items = []
            while i < len(lines) and re.match(r'^\d+\. ', lines[i]):
                m = re.match(r'^(\d+)\. (.*)', lines[i])
                items.append('<LI><s>' + m.group(1) + '. </s>' + inline_to_xml(m.group(2)) + '</LI>'); i += 1
            elements.append('<LIST type="decimal">' + '\n'.join(items) + '</LIST>')
        elif line.startswith('```'):
            lang = line[3:].strip(); code_lines = []; i += 1
            while i < len(lines) and not lines[i].startswith('```'):
                code_lines.append(xml_escape(lines[i])); i += 1
            i += 1
            tag = f'<CODE lang="{lang}">' if lang else '<CODE>'
            elements.append(tag + f'<s>```{lang}</s><i>\n</i>' + '\n'.join(code_lines) + '\n<e>```</e></CODE>')
        else:
            para_lines = []
            while i < len(lines) and lines[i].strip():
                l = lines[i]
                if l.startswith('## ') or l.startswith('### ') or l.startswith('- ') or l.startswith('```') or re.match(r'^\d+\. ', l): break
                para_lines.append(l); i += 1
            elements.append('<p>' + inline_to_xml('\n'.join(para_lines)) + '</p>')
    return '<r>' + '\n\n'.join(elements) + '</r>'

PHP Insert Template

Use %%XML_CONTENT%% as the placeholder — replace with the generated s9e XML before uploading. The closing nowdoc marker FLARUM_POST_XML_END; must be at column 0 with no leading whitespace.

<?php
ini_set('display_errors', 1); error_reporting(E_ALL);
$dsn = 'mysql:host=localhost;dbname=azcompu_flarum;charset=utf8mb4';
$pdo = new PDO($dsn, 'azcompu_flarum', 'Fl@rum2026!CGS', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

$user_id = 1; $tag_id = %%TAG_ID%%;
$title   = %%TITLE_PHP%%;
$slug    = '%%SLUG%%';
$now     = date('Y-m-d H:i:s');

$content = <<<'FLARUM_POST_XML_END'
%%XML_CONTENT%%
FLARUM_POST_XML_END;

// Insert discussion first (post refs are nullable)
$stmt = $pdo->prepare("INSERT INTO discussions (title, comment_count, post_number_index, created_at, user_id, slug, is_private, is_approved) VALUES (?, 1, 1, ?, ?, ?, 0, 1)");
$stmt->execute([$title, $now, $user_id, $slug]);
$disc_id = $pdo->lastInsertId();
echo "Discussion ID: $disc_id\n";

// Insert post
$stmt = $pdo->prepare("INSERT INTO posts (discussion_id, number, created_at, user_id, type, content, is_private, is_approved) VALUES (?, 1, ?, ?, 'comment', ?, 0, 1)");
$stmt->execute([$disc_id, $now, $user_id, $content]);
$post_id = $pdo->lastInsertId();
echo "Post ID: $post_id\n";

// Link post back to discussion
$pdo->prepare("UPDATE discussions SET first_post_id=?, last_post_id=?, last_posted_at=?, last_posted_user_id=?, last_post_number=1 WHERE id=?")->execute([$post_id, $post_id, $now, $user_id, $disc_id]);

// Tag
$pdo->prepare("INSERT INTO discussion_tag (discussion_id, tag_id) VALUES (?, ?)")->execute([$disc_id, $tag_id]);

echo "Done! URL: https://community.azcomputerguru.com/d/$disc_id-$slug\n";

Template substitutions

Placeholder How to fill
%%TAG_ID%% Integer (e.g. 7)
%%TITLE_PHP%% PHP double-quoted string with escaped " — e.g. "The Title Here"
%%SLUG%% URL-safe slug string
%%XML_CONTENT%% The output of md_to_s9e(content_md)

Build the PHP script in Python using .replace() — never f-strings (curly braces in XML content will cause Python to try to expand them as template expressions).


Paramiko Execution Pattern

import paramiko, time

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('172.16.3.10', username='root', password=IX_PASS, timeout=10)

# Upload
remote_path = f'/tmp/flarum_post_{int(time.time())}.php'
sftp = client.open_sftp()
with sftp.open(remote_path, 'wb') as f:
    f.write(php_script.encode('utf-8'))
sftp.close()

# Execute
def run_chan(cmd):
    chan = client.get_transport().open_session()
    chan.exec_command(cmd)
    chan.shutdown_write()
    out = b''
    while not chan.exit_status_ready():
        if chan.recv_ready(): out += chan.recv(4096)
    while chan.recv_ready(): out += chan.recv(4096)
    return out.decode('utf-8', errors='replace'), chan.recv_exit_status()

out, rc = run_chan(f'php {remote_path} 2>&1')
run_chan(f'rm -f {remote_path}')
client.close()

Error Handling

rc Symptom Cause Fix
255, no output Script crashes silently Exception with display_errors off Script already includes ini_set('display_errors',1)
FK constraint error Cannot add child row Post inserted before discussion Use discussion-first insert order (template above does this)
Empty curl response localhost curl returns nothing HTTP→HTTPS redirect on vhost Use paramiko PHP approach, not curl
Cloudflare challenge API blocked externally Bot protection Always insert via paramiko, never via external HTTP

After Posting

Report:

[SUCCESS] Posted to community.azcomputerguru.com
Discussion: #<id>
URL: https://community.azcomputerguru.com/d/<id>-<slug>
Tag: How-Tos & Tips

Do NOT try to verify via curl or browser — Cloudflare blocks external API calls and localhost curl has a redirect issue. The DB output is the authoritative confirmation.