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:
@@ -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'),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user