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,71 @@
|
||||
# Forum and comments (Flarum)
|
||||
|
||||
The forum is a separate PHP application ([Flarum](https://flarum.org/) 2, PHP 8.3+, MariaDB or
|
||||
MySQL). It is not part of the static build: the site only links to it and shows its threads in a
|
||||
frame.
|
||||
|
||||
| Path | Role |
|
||||
|---|---|
|
||||
| `extension/` | Flarum extension `oc/flarum-museum`: the `oc:import` command and the look of embedded threads |
|
||||
| `docker/`, `docker-compose.yml` | local test forum |
|
||||
| `import/` | not versioned: `machines.json` (export from the extraction repo) and `forum.json` (thread map) |
|
||||
|
||||
## How comments work
|
||||
|
||||
- `../oc/scripts/06_forum.py` writes `import/machines.json`: one entry per machine with its
|
||||
archived comments, oldest first.
|
||||
- `php flarum oc:import` creates one discussion per machine: an opening post linking to the
|
||||
museum page, then one post per archived comment, all by the user `Archive`, with the original
|
||||
author and date at the top of each post and the original date as the post date. Discussions go
|
||||
under three tags (Computers, Consoles, Pongs) that are hidden from "All discussions".
|
||||
- The command records what it imported (table `oc_threads`) and skips it on later runs, so it
|
||||
can be run again when new machines arrive. It writes the map machine → discussion to
|
||||
`forum.json`.
|
||||
- Copied to `../data/forum.json`, that map makes `make content` give every machine a Comments
|
||||
tab pointing at its thread (`forum_thread` in the page front matter); the old comments are
|
||||
then no longer written into the pages.
|
||||
- With `params.forum.url` set, the Comments tab shows `<forum>/embed/<id>` (Flarum's Embed
|
||||
extension) in a frame that follows the height of the thread.
|
||||
|
||||
## Local test
|
||||
|
||||
make forum-up # http://localhost:8081, admin / adminadmin (first start takes a minute)
|
||||
make forum-import # export + import; safe to repeat
|
||||
|
||||
To see it in the site, copy `forum/import/forum.json` to `data/forum.json`, run `make content`,
|
||||
and build with `HUGO_PARAMS_FORUM_URL=http://localhost:8081 hugo server`. Do not commit a
|
||||
`data/forum.json` made from the test forum: its thread numbers are not those of the real one.
|
||||
|
||||
## Server install (outline)
|
||||
|
||||
The forum must live **outside** the folder `make deploy` writes to (that folder is synchronised
|
||||
with deletion). On the test server: `web/oldcomputers-forum/`, next to `web/oldcomputers/`.
|
||||
|
||||
1. PHP 8.3 or newer with curl, dom, fileinfo, gd, json, mbstring, openssl, pdo_mysql, tokenizer,
|
||||
zip; an empty database and a user for it.
|
||||
2. `composer create-project flarum/flarum oldcomputers-forum "^2.0"`, then in that folder
|
||||
`composer require flarum/embed "^2.0"`.
|
||||
3. Web server: requests that are not files go to `public/index.php`, and nothing outside `public/`
|
||||
is served. Flarum ships rules for Apache (`public/.htaccess`) and nginx (`.nginx.conf`); a
|
||||
sub-folder install is described in the Flarum documentation ("Customizing paths").
|
||||
4. Open the forum address and finish the installer (database, administrator account).
|
||||
5. Copy `extension/` to `packages/oc-museum/` in the forum folder, then:
|
||||
|
||||
composer config repositories.oc path "packages/*"
|
||||
composer require "oc/flarum-museum:*@dev"
|
||||
php flarum migrate
|
||||
php flarum extension:enable flarum-tags
|
||||
php flarum extension:enable flarum-embed
|
||||
php flarum extension:enable oc-museum
|
||||
|
||||
6. In the admin panel: e-mail settings (registration confirms addresses by e-mail), and a spam
|
||||
filter extension. Schedule a database dump.
|
||||
7. Import: copy `import/machines.json` to the server and run
|
||||
|
||||
php flarum oc:import machines.json --site-url=https://example.org/oldcomputers --map=forum.json
|
||||
|
||||
then bring `forum.json` back as `data/forum.json`, set `params.forum.url`, `make content`,
|
||||
`make deploy`.
|
||||
|
||||
The forum and the site should share one domain: logging in from the embedded thread relies on
|
||||
the forum's cookie, which browsers restrict across domains.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Local test forum on http://localhost:8081 (admin / adminadmin). Data in named volumes.
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11
|
||||
environment:
|
||||
MARIADB_DATABASE: flarum
|
||||
MARIADB_USER: flarum
|
||||
MARIADB_PASSWORD: flarum
|
||||
MARIADB_RANDOM_ROOT_PASSWORD: "1"
|
||||
volumes:
|
||||
- forum-db:/var/lib/mysql
|
||||
flarum:
|
||||
build: docker
|
||||
depends_on: [db]
|
||||
environment:
|
||||
FORUM_URL: http://localhost:8081
|
||||
ADMIN_PASSWORD: adminadmin
|
||||
ports:
|
||||
- "127.0.0.1:8081:80"
|
||||
volumes:
|
||||
- ./extension:/var/www/flarum/packages/oc-museum
|
||||
- ./import:/import
|
||||
volumes:
|
||||
forum-db:
|
||||
@@ -0,0 +1,19 @@
|
||||
# Local test forum: Flarum 2 on Apache. Not the production install (see forum/README.md).
|
||||
FROM php:8.3-apache
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libzip-dev libpng-dev libjpeg-dev libfreetype6-dev \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||
&& docker-php-ext-install pdo_mysql gd zip \
|
||||
&& a2enmod rewrite && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||
RUN composer create-project flarum/flarum /var/www/flarum "^2.0" --no-interaction --prefer-dist \
|
||||
&& cd /var/www/flarum && mkdir packages \
|
||||
&& composer require --no-interaction flarum/embed "^2.0" \
|
||||
&& composer config repositories.oc '{"type": "path", "url": "packages/*", "options": {"symlink": true}}' \
|
||||
&& chown -R www-data:www-data /var/www/flarum
|
||||
RUN sed -ri 's#/var/www/html#/var/www/flarum/public#g' /etc/apache2/sites-available/000-default.conf \
|
||||
&& printf '<Directory /var/www/flarum/public>\n AllowOverride All\n Require all granted\n</Directory>\n' > /etc/apache2/conf-enabled/flarum.conf
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
WORKDIR /var/www/flarum
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# First start: install Flarum against the db service, enable the extensions the site relies on.
|
||||
set -e
|
||||
cd /var/www/flarum
|
||||
if [ ! -f config.php ]; then
|
||||
until php -r 'new PDO("mysql:host=db;dbname=flarum", "flarum", "flarum");' >/dev/null 2>&1; do sleep 2; done
|
||||
cat > /tmp/install.yml <<Y
|
||||
debug: true
|
||||
baseUrl: ${FORUM_URL}
|
||||
databaseConfiguration:
|
||||
driver: mysql
|
||||
host: db
|
||||
port: 3306
|
||||
database: flarum
|
||||
username: flarum
|
||||
password: flarum
|
||||
prefix:
|
||||
adminUser:
|
||||
username: admin
|
||||
password: ${ADMIN_PASSWORD}
|
||||
email: admin@example.com
|
||||
settings:
|
||||
forum_title: Old-Computers.com forum
|
||||
Y
|
||||
php flarum install --file=/tmp/install.yml
|
||||
fi
|
||||
# the museum extension is mounted from the repository (forum/extension)
|
||||
composer require --no-interaction "oc/flarum-museum:*@dev" >/dev/null 2>&1 || composer require --no-interaction "oc/flarum-museum:*@dev"
|
||||
php flarum migrate
|
||||
for ext in flarum-tags flarum-markdown flarum-embed oc-museum; do php flarum extension:enable "$ext" || true; done
|
||||
php flarum cache:clear
|
||||
chown -R www-data:www-data storage public/assets
|
||||
exec docker-php-entrypoint "$@"
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "oc/flarum-museum",
|
||||
"description": "Old-Computers.com museum integration: imports the archived visitor comments as discussions and styles the embedded threads.",
|
||||
"type": "flarum-extension",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"flarum/core": "^2.0",
|
||||
"flarum/tags": "^2.0",
|
||||
"flarum/embed": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Oc\\Museum\\": "src/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"flarum-extension": {
|
||||
"title": "Old-Computers museum",
|
||||
"icon": {
|
||||
"name": "fas fa-landmark",
|
||||
"backgroundColor": "#9a3412",
|
||||
"color": "#fff"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Flarum\Extend;
|
||||
use Oc\Museum\Console\ImportCommand;
|
||||
|
||||
return [
|
||||
(new Extend\Console())->command(ImportCommand::class),
|
||||
|
||||
// threads shown inside the museum pages (flarum/embed) take the look of the static site
|
||||
(new Extend\Frontend('embed'))->css(__DIR__.'/less/embed.less'),
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
// Embedded thread, as shown in the Comments tab of the museum pages: full width, on the
|
||||
// light panel colour of the static site (--panel in its assets/css/main.css).
|
||||
@oc-accent: #9a3412;
|
||||
@oc-panel: #fffdf7;
|
||||
|
||||
body, .App { background: @oc-panel; }
|
||||
.Page-content { max-width: none; }
|
||||
.Post-body a, .PostUser-name a, .Button--link { color: @oc-accent; }
|
||||
.Button--primary { background: @oc-accent; }
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Flarum\Database\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
|
||||
// One discussion per museum machine; oc_id is the machine id of the original site.
|
||||
return Migration::createTable('oc_threads', function (Blueprint $table) {
|
||||
$table->unsignedInteger('oc_id')->primary();
|
||||
$table->unsignedInteger('discussion_id')->unique();
|
||||
});
|
||||
@@ -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