Author: Mike Swanson Machine: DESKTOP-0O8A1RL Timestamp: 2026-05-20 14:38:07
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:
-
Title — descriptive, search-friendly, sentence case. Include the technology/product. Good:
OneDrive KFM Fails After Folder Redirection Migration -- Here's What's Actually Going OnBad:OneDrive issue fix -
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.
-
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:
- Get IX SSH password from vault (see Infrastructure above)
- Connect via paramiko (
AutoAddPolicy, password auth) - Generate the s9e XML from the markdown (see Converter section below)
- Build the PHP insert script (see PHP Template below)
- SFTP upload the PHP script to
/tmp/flarum_post_<timestamp>.phpon IX - Run
php /tmp/flarum_post_<timestamp>.php 2>&1via SSH - Parse output — look for
Discussion ID: NandPost ID: N - Clean up —
rm /tmp/flarum_post_<timestamp>.php - 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 & → &, < → <, > → > 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('&', '&').replace('<', '<').replace('>', '>')
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.