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:
@@ -67,7 +67,7 @@ params:
|
||||
- docs
|
||||
- videos
|
||||
- comments
|
||||
forum_thread: 1
|
||||
forum_thread: 7
|
||||
photos:
|
||||
- title: INSIDE AN APPLE II
|
||||
subtitle: ''
|
||||
|
||||
@@ -72,7 +72,7 @@ params:
|
||||
- docs
|
||||
- videos
|
||||
- comments
|
||||
forum_thread: 2
|
||||
forum_thread: 8
|
||||
photos:
|
||||
- title: COMMODORE 64
|
||||
subtitle: ''
|
||||
|
||||
@@ -67,7 +67,7 @@ params:
|
||||
tabs:
|
||||
- photos
|
||||
- comments
|
||||
forum_thread: 5
|
||||
forum_thread: 11
|
||||
photos:
|
||||
- title: COMPLETE SYSTEM
|
||||
subtitle: ''
|
||||
|
||||
@@ -54,7 +54,7 @@ params:
|
||||
- links
|
||||
- docs
|
||||
- comments
|
||||
forum_thread: 6
|
||||
forum_thread: 12
|
||||
photos:
|
||||
- title: ODYSSEY 100
|
||||
subtitle: ''
|
||||
|
||||
@@ -52,7 +52,7 @@ params:
|
||||
- links
|
||||
- videos
|
||||
- comments
|
||||
forum_thread: 4
|
||||
forum_thread: 10
|
||||
photos:
|
||||
- title: MAIN UNIT
|
||||
subtitle: ''
|
||||
|
||||
@@ -69,7 +69,7 @@ params:
|
||||
- adverts
|
||||
- links
|
||||
- comments
|
||||
forum_thread: 3
|
||||
forum_thread: 9
|
||||
photos:
|
||||
- title: PC-6601
|
||||
subtitle: ''
|
||||
|
||||
+6
-6
@@ -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
@@ -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`),
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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