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
+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);
}
}