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 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 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: 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 up** — `rm /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>`</s>code<e>`</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) ```python 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 <?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 ```python 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.