Forum integration: Flarum extension, local test stack, embedded comments
- forum/extension: oc:import command (archived comments -> one thread per machine) and embed styling - forum/docker: local Flarum 2 test stack - site: optional params.forum.url adds a Forum menu entry and shows the machine thread in the Comments tab Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDRjtmAiNpo5mK5N3d7QwZ
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Oc\Museum\Console;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Flarum\Console\AbstractCommand;
|
||||
use Flarum\Discussion\Discussion;
|
||||
use Flarum\Post\CommentPost;
|
||||
use Flarum\Tags\Tag;
|
||||
use Flarum\User\User;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
/**
|
||||
* Creates one discussion per museum machine from the file written by the extraction
|
||||
* pipeline (oc/scripts/06_forum.py), with the archived visitor comments as its posts.
|
||||
*
|
||||
* Safe to run again: machines already imported (table oc_threads) are skipped.
|
||||
*/
|
||||
class ImportCommand extends AbstractCommand
|
||||
{
|
||||
private const ARCHIVE_USER = 'Archive';
|
||||
private const TAGS = [
|
||||
'Computers' => ['#9a3412', 'fas fa-desktop'],
|
||||
'Consoles' => ['#1d4ed8', 'fas fa-gamepad'],
|
||||
'Pongs' => ['#15803d', 'fas fa-table-tennis-paddle-ball'],
|
||||
];
|
||||
|
||||
public function __construct(private ConnectionInterface $db)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('oc:import')
|
||||
->setDescription('Import the museum machines and their archived comments as discussions')
|
||||
->addArgument('file', InputArgument::REQUIRED, 'machines.json written by 06_forum.py')
|
||||
->addOption('site-url', null, InputOption::VALUE_REQUIRED, 'Address of the static site, e.g. https://example.org/oldcomputers', '')
|
||||
->addOption('map', null, InputOption::VALUE_REQUIRED, 'Where to write the machine -> discussion map (forum.json)');
|
||||
}
|
||||
|
||||
protected function fire(): int
|
||||
{
|
||||
$machines = json_decode((string) file_get_contents($this->input->getArgument('file')), true);
|
||||
if (! is_array($machines)) {
|
||||
$this->error('Cannot read the import file.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
$site = rtrim((string) $this->input->getOption('site-url'), '/');
|
||||
$archive = $this->archiveUser();
|
||||
$tags = $this->tags();
|
||||
$done = $this->db->table('oc_threads')->pluck('discussion_id', 'oc_id');
|
||||
$created = $posts = 0;
|
||||
|
||||
foreach ($machines as $machine) {
|
||||
if (isset($done[$machine['oc_id']])) {
|
||||
continue;
|
||||
}
|
||||
$this->db->transaction(function () use ($machine, $site, $archive, $tags, &$posts, &$done) {
|
||||
$id = $this->importMachine($machine, $site, $archive, $tags[$machine['section']] ?? $tags['Computers']);
|
||||
$this->db->table('oc_threads')->insert(['oc_id' => $machine['oc_id'], 'discussion_id' => $id]);
|
||||
$done[$machine['oc_id']] = $id;
|
||||
$posts += count($machine['comments']);
|
||||
});
|
||||
if (++$created % 50 === 0) {
|
||||
$this->info("$created discussions…");
|
||||
}
|
||||
}
|
||||
|
||||
$archive->refreshCommentCount()->refreshDiscussionCount()->save();
|
||||
foreach ($tags as $tag) {
|
||||
$tag->discussion_count = $tag->discussions()->count();
|
||||
$tag->refreshLastPostedDiscussion()->save();
|
||||
}
|
||||
if ($map = $this->input->getOption('map')) {
|
||||
file_put_contents($map, json_encode($done, JSON_PRETTY_PRINT)."\n");
|
||||
}
|
||||
$this->info("$created discussions created with $posts archived comments; ".count($done).' machines mapped.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function importMachine(array $machine, string $site, User $archive, Tag $tag): int
|
||||
{
|
||||
$comments = $machine['comments'];
|
||||
// the thread opens just before its first comment (or now, when there is none)
|
||||
$opened = $comments ? Carbon::parse($comments[0]['date'])->subMinute() : Carbon::now();
|
||||
$page = $site.$machine['path'];
|
||||
|
||||
$discussion = Discussion::start($machine['title'], $archive);
|
||||
$discussion->created_at = $opened;
|
||||
$discussion->save();
|
||||
|
||||
$first = $this->post($discussion, $archive, $opened,
|
||||
"Comments about the [{$machine['title']}]($page) page of the museum.\n\n"
|
||||
.($comments ? 'The first messages below were left by visitors of the original site.' : 'Be the first to comment!'));
|
||||
foreach ($comments as $comment) {
|
||||
$header = '**'.$this->plain($comment['author']).'**'
|
||||
.($comment['original_date'] ? ' — '.$this->plain($comment['original_date']) : '');
|
||||
$this->post($discussion, $archive, Carbon::parse($comment['date']), $header."\n\n".$comment['text']);
|
||||
}
|
||||
|
||||
$discussion->setFirstPost($first)->refreshCommentCount()->refreshLastPost()->refreshParticipantCount()->save();
|
||||
$discussion->tags()->sync([$tag->id]);
|
||||
|
||||
return $discussion->id;
|
||||
}
|
||||
|
||||
private function post(Discussion $discussion, User $user, Carbon $when, string $markdown): CommentPost
|
||||
{
|
||||
$post = new CommentPost();
|
||||
$post->discussion_id = $discussion->id;
|
||||
$post->user_id = $user->id;
|
||||
$post->created_at = $when;
|
||||
$post->type = CommentPost::$type;
|
||||
// the pipeline writes hard line breaks as "\" + newline; the forum keeps plain newlines
|
||||
$post->setContentAttribute(str_replace("\\\n", "\n", $markdown), $user);
|
||||
$post->save(); // events raised by the model are not dispatched: no notifications
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
private function plain(string $text): string
|
||||
{
|
||||
return preg_replace('/([\\\\*_`\[\]@])/', '\\\\$1', trim($text));
|
||||
}
|
||||
|
||||
private function archiveUser(): User
|
||||
{
|
||||
$user = User::query()->where('username', self::ARCHIVE_USER)->first();
|
||||
if (! $user) {
|
||||
$user = new User();
|
||||
$user->username = self::ARCHIVE_USER;
|
||||
$user->email = 'archive@invalid.invalid';
|
||||
$user->password = Str::random(40); // nobody logs in as the archive
|
||||
$user->is_email_confirmed = true;
|
||||
$user->joined_at = Carbon::now();
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** @return array<string, Tag> */
|
||||
private function tags(): array
|
||||
{
|
||||
$tags = [];
|
||||
$position = (int) Tag::query()->max('position');
|
||||
foreach (self::TAGS as $name => [$color, $icon]) {
|
||||
$tag = Tag::query()->where('slug', Str::slug($name))->first();
|
||||
if (! $tag) {
|
||||
// hidden from "All discussions": one thread per machine would bury the forum topics
|
||||
$tag = Tag::build($name, Str::slug($name), "Comments on the $name pages of the museum", $color, $icon, true);
|
||||
$tag->is_primary = true;
|
||||
$tag->position = ++$position;
|
||||
$tag->save();
|
||||
}
|
||||
$tags[$name] = $tag;
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user