Forum import: archived comments posted under their original author names

One account per original author (nickname = name as signed on the old
site, group 'Original site'), display-name driver 'museum' keeps the
brackets; --replace re-imports threads without real replies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDRjtmAiNpo5mK5N3d7QwZ
This commit is contained in:
2026-10-11 22:03:07 +02:00
co-authored by Claude Opus 5.5
parent e9fb8f2c6e
commit 696dfe5e77
12 changed files with 167 additions and 26 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ params:
- docs
- videos
- comments
forum_thread: 1
forum_thread: 7
photos:
- title: INSIDE AN APPLE II
subtitle: ''
+1 -1
View File
@@ -72,7 +72,7 @@ params:
- docs
- videos
- comments
forum_thread: 2
forum_thread: 8
photos:
- title: COMMODORE 64
subtitle: ''
+1 -1
View File
@@ -67,7 +67,7 @@ params:
tabs:
- photos
- comments
forum_thread: 5
forum_thread: 11
photos:
- title: COMPLETE SYSTEM
subtitle: ''
+1 -1
View File
@@ -54,7 +54,7 @@ params:
- links
- docs
- comments
forum_thread: 6
forum_thread: 12
photos:
- title: ODYSSEY 100
subtitle: ''
+1 -1
View File
@@ -52,7 +52,7 @@ params:
- links
- videos
- comments
forum_thread: 4
forum_thread: 10
photos:
- title: MAIN UNIT
subtitle: ''
+1 -1
View File
@@ -69,7 +69,7 @@ params:
- adverts
- links
- comments
forum_thread: 3
forum_thread: 9
photos:
- title: PC-6601
subtitle: ''
+6 -6
View File
@@ -1,8 +1,8 @@
{
"68": 1,
"98": 2,
"395": 3,
"883": 4,
"1044": 5,
"1268": 6
"68": 7,
"98": 8,
"395": 9,
"883": 10,
"1044": 11,
"1268": 12
}
+13 -6
View File
@@ -14,12 +14,19 @@ frame.
- `../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
- `php flarum oc:import` creates one discussion per machine: an opening post by the user
`Archive` linking to the museum page, then one post per archived comment, dated as the
original. Each comment is posted by an account standing for its original author, shown under
the name used on the old site ("darius (croatia)"); the same name on several machines is one
account. These accounts are in the group "Original site" (a badge on their posts), have a
random password and an undeliverable address, so nobody can log in as them. Discussions go
under three tags (Computers, Consoles, Pongs) that are hidden from "All discussions".
- Original names are stored as nicknames (Nicknames extension, which must be enabled) and shown
by the display-name driver `museum` of this extension, which the import selects: the stock
nickname driver would remove the brackets.
- 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
can be run again when new machines arrive. `--replace` deletes and re-imports the imported
threads that have no reply from a real member (their thread numbers change). 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
@@ -72,8 +79,8 @@ are never served. This layout is tested in the local image.
over SSH, as `http`, in the forum folder: `php flarum install --file=install.yml` (format in
`docker/entrypoint.sh`). Do not leave the folder online uninstalled: whoever opens the
installer first becomes its administrator.
5. Enable the extensions Embed and "Old-Computers museum" (admin panel, or
`php flarum extension:enable flarum-embed` and `oc-museum`). Tags is enabled by default.
5. Enable the extensions Nicknames, Embed and "Old-Computers museum" (admin panel, or
`php flarum extension:enable` with `flarum-nicknames`, `flarum-embed`, `oc-museum`). Tags is enabled by default.
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, from a shell on the server (SSH, or a one-off Task Scheduler script run as `http`),
+1 -1
View File
@@ -27,7 +27,7 @@ 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
for ext in flarum-tags flarum-markdown flarum-nicknames 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 "$@"
+4
View File
@@ -2,10 +2,14 @@
use Flarum\Extend;
use Oc\Museum\Console\ImportCommand;
use Oc\Museum\DisplayName;
return [
(new Extend\Console())->command(ImportCommand::class),
// selected by oc:import (setting display_name_driver = museum); needs the Nicknames extension
(new Extend\User())->displayNameDriver('museum', DisplayName::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'),
];
+110 -7
View File
@@ -5,10 +5,13 @@ namespace Oc\Museum\Console;
use Carbon\Carbon;
use Flarum\Console\AbstractCommand;
use Flarum\Discussion\Discussion;
use Flarum\Group\Group;
use Flarum\Post\CommentPost;
use Flarum\Settings\SettingsRepositoryInterface;
use Flarum\Tags\Tag;
use Flarum\User\User;
use Illuminate\Database\ConnectionInterface;
use Oc\Museum\DisplayName;
use Illuminate\Support\Str;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
@@ -17,18 +20,30 @@ 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.
* Each comment is posted by an account standing for its original author: the name as it
* appeared on the site ("darius (croatia)") is the account's nickname, shown instead of the
* generated user name. These accounts are in the group "Original site" and nobody can log in
* as them (random password, undeliverable address).
*
* Safe to run again: machines already imported (table oc_threads) are skipped. With
* --replace, imported threads that nobody has replied to yet are deleted and imported again.
*/
class ImportCommand extends AbstractCommand
{
private const ARCHIVE_USER = 'Archive';
private const GROUP = 'Original site';
/** @var array<string, User> authors of archived comments, by lower-cased original name */
private array $authors = [];
private Group $group;
private int $authorSeq = 0;
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)
public function __construct(private ConnectionInterface $db, private SettingsRepositoryInterface $settings)
{
parent::__construct();
}
@@ -39,6 +54,7 @@ class ImportCommand extends AbstractCommand
->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('replace', null, InputOption::VALUE_NONE, 'Delete and re-import the imported threads that have no reply from a real member')
->addOption('map', null, InputOption::VALUE_REQUIRED, 'Where to write the machine -> discussion map (forum.json)');
}
@@ -50,9 +66,22 @@ class ImportCommand extends AbstractCommand
return 1;
}
if (! $this->db->getSchemaBuilder()->hasColumn('users', 'nickname')) {
$this->error('Enable the Nicknames extension first: php flarum extension:enable flarum-nicknames');
return 1;
}
// original names ("darius (croatia)") are not valid user names: show them as nicknames
$this->settings->set('display_name_driver', 'museum');
$site = rtrim((string) $this->input->getOption('site-url'), '/');
$archive = $this->archiveUser();
$this->group = $this->group();
$this->loadAuthors();
$tags = $this->tags();
if ($this->input->getOption('replace')) {
$this->removeUntouchedThreads();
}
$done = $this->db->table('oc_threads')->pluck('discussion_id', 'oc_id');
$created = $posts = 0;
@@ -72,6 +101,7 @@ class ImportCommand extends AbstractCommand
}
$archive->refreshCommentCount()->refreshDiscussionCount()->save();
$this->refreshAuthorCounts();
foreach ($tags as $tag) {
$tag->discussion_count = $tag->discussions()->count();
$tag->refreshLastPostedDiscussion()->save();
@@ -99,9 +129,7 @@ class ImportCommand extends AbstractCommand
"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']);
$this->post($discussion, $this->author($comment['author']), Carbon::parse($comment['date']), $comment['text']);
}
$discussion->setFirstPost($first)->refreshCommentCount()->refreshLastPost()->refreshParticipantCount()->save();
@@ -124,9 +152,84 @@ class ImportCommand extends AbstractCommand
return $post;
}
private function plain(string $text): string
/** Account standing for the author of archived comments, created on first use. */
private function author(string $name): User
{
return preg_replace('/([\\\\*_`\[\]@])/', '\\\\$1', trim($text));
$name = Str::limit(trim(preg_replace('/\s+/', ' ', $name)) ?: 'Anonymous', 150, '');
$key = Str::lower($name);
if (! isset($this->authors[$key])) {
$user = new User();
$user->username = $this->username($name);
$user->nickname = $name;
$user->email = $user->username.DisplayName::ARCHIVE_DOMAIN;
$user->password = Str::random(40); // nobody logs in as an archived author
$user->is_email_confirmed = true;
$user->joined_at = Carbon::now();
$user->save();
$user->groups()->attach($this->group->id);
$this->authors[$key] = $user;
}
return $this->authors[$key];
}
/** Valid, unique user name: "darius (croatia)" -> "darius-croatia-a12". */
private function username(string $name): string
{
$base = trim(Str::limit(Str::slug($name), 20, ''), '-') ?: 'visitor';
return $base.'-a'.(++$this->authorSeq);
}
private function loadAuthors(): void
{
foreach ($this->group->users()->get() as $user) {
$this->authors[Str::lower((string) $user->nickname)] = $user;
$this->authorSeq = max($this->authorSeq, (int) Str::afterLast($user->username, '-a'));
}
}
private function refreshAuthorCounts(): void
{
$p = $this->db->getTablePrefix();
$this->db->update(
"update {$p}users set comment_count = (select count(*) from {$p}posts where {$p}posts.user_id = {$p}users.id and {$p}posts.type = 'comment')
where id in (select user_id from {$p}group_user where group_id = ?)",
[$this->group->id]
);
}
/** --replace: forget imported threads in which only archive accounts have posted. */
private function removeUntouchedThreads(): void
{
$archived = $this->group->users()->pluck('users.id')->push($this->archiveUser()->id)->all();
$removed = $kept = 0;
foreach ($this->db->table('oc_threads')->pluck('discussion_id', 'oc_id') as $ocId => $discussionId) {
$discussion = Discussion::find($discussionId);
if ($discussion && $discussion->posts()->whereNotIn('user_id', $archived)->exists()) {
$kept++;
continue;
}
$discussion?->delete();
$this->db->table('oc_threads')->where('oc_id', $ocId)->delete();
$removed++;
}
$this->info("$removed imported threads removed for re-import, $kept kept (they have replies).");
}
private function group(): Group
{
$group = Group::query()->where('name_singular', self::GROUP)->first();
if (! $group) {
$group = new Group();
$group->name_singular = self::GROUP;
$group->name_plural = self::GROUP;
$group->color = '#8a7f66';
$group->icon = 'fas fa-box-archive';
$group->save();
}
return $group;
}
private function archiveUser(): User
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Oc\Museum;
use Flarum\Nicknames\NicknameDriver;
use Flarum\User\DisplayName\DriverInterface;
use Flarum\User\User;
/**
* Display-name driver "museum": the authors of archived comments are shown exactly as they
* signed on the original site, e.g. "darius (croatia)". The stock nickname driver removes
* brackets from names (a protection for notification e-mails, which these accounts never
* trigger); every other account goes through it unchanged.
*/
class DisplayName implements DriverInterface
{
public const ARCHIVE_DOMAIN = '@archive.invalid';
public function displayName(User $user): string
{
if ($user->nickname && str_ends_with((string) $user->email, self::ARCHIVE_DOMAIN)) {
return $user->nickname;
}
return (new NicknameDriver())->displayName($user);
}
}