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
|
- docs
|
||||||
- videos
|
- videos
|
||||||
- comments
|
- comments
|
||||||
forum_thread: 1
|
forum_thread: 7
|
||||||
photos:
|
photos:
|
||||||
- title: INSIDE AN APPLE II
|
- title: INSIDE AN APPLE II
|
||||||
subtitle: ''
|
subtitle: ''
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ params:
|
|||||||
- docs
|
- docs
|
||||||
- videos
|
- videos
|
||||||
- comments
|
- comments
|
||||||
forum_thread: 2
|
forum_thread: 8
|
||||||
photos:
|
photos:
|
||||||
- title: COMMODORE 64
|
- title: COMMODORE 64
|
||||||
subtitle: ''
|
subtitle: ''
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ params:
|
|||||||
tabs:
|
tabs:
|
||||||
- photos
|
- photos
|
||||||
- comments
|
- comments
|
||||||
forum_thread: 5
|
forum_thread: 11
|
||||||
photos:
|
photos:
|
||||||
- title: COMPLETE SYSTEM
|
- title: COMPLETE SYSTEM
|
||||||
subtitle: ''
|
subtitle: ''
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ params:
|
|||||||
- links
|
- links
|
||||||
- docs
|
- docs
|
||||||
- comments
|
- comments
|
||||||
forum_thread: 6
|
forum_thread: 12
|
||||||
photos:
|
photos:
|
||||||
- title: ODYSSEY 100
|
- title: ODYSSEY 100
|
||||||
subtitle: ''
|
subtitle: ''
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ params:
|
|||||||
- links
|
- links
|
||||||
- videos
|
- videos
|
||||||
- comments
|
- comments
|
||||||
forum_thread: 4
|
forum_thread: 10
|
||||||
photos:
|
photos:
|
||||||
- title: MAIN UNIT
|
- title: MAIN UNIT
|
||||||
subtitle: ''
|
subtitle: ''
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ params:
|
|||||||
- adverts
|
- adverts
|
||||||
- links
|
- links
|
||||||
- comments
|
- comments
|
||||||
forum_thread: 3
|
forum_thread: 9
|
||||||
photos:
|
photos:
|
||||||
- title: PC-6601
|
- title: PC-6601
|
||||||
subtitle: ''
|
subtitle: ''
|
||||||
|
|||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"68": 1,
|
"68": 7,
|
||||||
"98": 2,
|
"98": 8,
|
||||||
"395": 3,
|
"395": 9,
|
||||||
"883": 4,
|
"883": 10,
|
||||||
"1044": 5,
|
"1044": 11,
|
||||||
"1268": 6
|
"1268": 12
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -14,12 +14,19 @@ frame.
|
|||||||
|
|
||||||
- `../oc/scripts/06_forum.py` writes `import/machines.json`: one entry per machine with its
|
- `../oc/scripts/06_forum.py` writes `import/machines.json`: one entry per machine with its
|
||||||
archived comments, oldest first.
|
archived comments, oldest first.
|
||||||
- `php flarum oc:import` creates one discussion per machine: an opening post linking to the
|
- `php flarum oc:import` creates one discussion per machine: an opening post by the user
|
||||||
museum page, then one post per archived comment, all by the user `Archive`, with the original
|
`Archive` linking to the museum page, then one post per archived comment, dated as the
|
||||||
author and date at the top of each post and the original date as the post date. Discussions go
|
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".
|
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
|
- 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`.
|
`forum.json`.
|
||||||
- Copied to `../data/forum.json`, that map makes `make content` give every machine a Comments
|
- 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
|
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
|
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
|
`docker/entrypoint.sh`). Do not leave the folder online uninstalled: whoever opens the
|
||||||
installer first becomes its administrator.
|
installer first becomes its administrator.
|
||||||
5. Enable the extensions Embed and "Old-Computers museum" (admin panel, or
|
5. Enable the extensions Nicknames, Embed and "Old-Computers museum" (admin panel, or
|
||||||
`php flarum extension:enable flarum-embed` and `oc-museum`). Tags is enabled by default.
|
`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
|
6. In the admin panel: e-mail settings (registration confirms addresses by e-mail), and a spam
|
||||||
filter extension. Schedule a database dump.
|
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`),
|
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)
|
# 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"
|
composer require --no-interaction "oc/flarum-museum:*@dev" >/dev/null 2>&1 || composer require --no-interaction "oc/flarum-museum:*@dev"
|
||||||
php flarum migrate
|
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
|
php flarum cache:clear
|
||||||
chown -R www-data:www-data storage public/assets
|
chown -R www-data:www-data storage public/assets
|
||||||
exec docker-php-entrypoint "$@"
|
exec docker-php-entrypoint "$@"
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
use Flarum\Extend;
|
use Flarum\Extend;
|
||||||
use Oc\Museum\Console\ImportCommand;
|
use Oc\Museum\Console\ImportCommand;
|
||||||
|
use Oc\Museum\DisplayName;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
(new Extend\Console())->command(ImportCommand::class),
|
(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
|
// threads shown inside the museum pages (flarum/embed) take the look of the static site
|
||||||
(new Extend\Frontend('embed'))->css(__DIR__.'/less/embed.less'),
|
(new Extend\Frontend('embed'))->css(__DIR__.'/less/embed.less'),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ namespace Oc\Museum\Console;
|
|||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Flarum\Console\AbstractCommand;
|
use Flarum\Console\AbstractCommand;
|
||||||
use Flarum\Discussion\Discussion;
|
use Flarum\Discussion\Discussion;
|
||||||
|
use Flarum\Group\Group;
|
||||||
use Flarum\Post\CommentPost;
|
use Flarum\Post\CommentPost;
|
||||||
|
use Flarum\Settings\SettingsRepositoryInterface;
|
||||||
use Flarum\Tags\Tag;
|
use Flarum\Tags\Tag;
|
||||||
use Flarum\User\User;
|
use Flarum\User\User;
|
||||||
use Illuminate\Database\ConnectionInterface;
|
use Illuminate\Database\ConnectionInterface;
|
||||||
|
use Oc\Museum\DisplayName;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
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
|
* 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.
|
* 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
|
class ImportCommand extends AbstractCommand
|
||||||
{
|
{
|
||||||
private const ARCHIVE_USER = 'Archive';
|
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 = [
|
private const TAGS = [
|
||||||
'Computers' => ['#9a3412', 'fas fa-desktop'],
|
'Computers' => ['#9a3412', 'fas fa-desktop'],
|
||||||
'Consoles' => ['#1d4ed8', 'fas fa-gamepad'],
|
'Consoles' => ['#1d4ed8', 'fas fa-gamepad'],
|
||||||
'Pongs' => ['#15803d', 'fas fa-table-tennis-paddle-ball'],
|
'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();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -39,6 +54,7 @@ class ImportCommand extends AbstractCommand
|
|||||||
->setDescription('Import the museum machines and their archived comments as discussions')
|
->setDescription('Import the museum machines and their archived comments as discussions')
|
||||||
->addArgument('file', InputArgument::REQUIRED, 'machines.json written by 06_forum.py')
|
->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('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)');
|
->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;
|
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'), '/');
|
$site = rtrim((string) $this->input->getOption('site-url'), '/');
|
||||||
$archive = $this->archiveUser();
|
$archive = $this->archiveUser();
|
||||||
|
$this->group = $this->group();
|
||||||
|
$this->loadAuthors();
|
||||||
$tags = $this->tags();
|
$tags = $this->tags();
|
||||||
|
if ($this->input->getOption('replace')) {
|
||||||
|
$this->removeUntouchedThreads();
|
||||||
|
}
|
||||||
$done = $this->db->table('oc_threads')->pluck('discussion_id', 'oc_id');
|
$done = $this->db->table('oc_threads')->pluck('discussion_id', 'oc_id');
|
||||||
$created = $posts = 0;
|
$created = $posts = 0;
|
||||||
|
|
||||||
@@ -72,6 +101,7 @@ class ImportCommand extends AbstractCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
$archive->refreshCommentCount()->refreshDiscussionCount()->save();
|
$archive->refreshCommentCount()->refreshDiscussionCount()->save();
|
||||||
|
$this->refreshAuthorCounts();
|
||||||
foreach ($tags as $tag) {
|
foreach ($tags as $tag) {
|
||||||
$tag->discussion_count = $tag->discussions()->count();
|
$tag->discussion_count = $tag->discussions()->count();
|
||||||
$tag->refreshLastPostedDiscussion()->save();
|
$tag->refreshLastPostedDiscussion()->save();
|
||||||
@@ -99,9 +129,7 @@ class ImportCommand extends AbstractCommand
|
|||||||
"Comments about the [{$machine['title']}]($page) page of the museum.\n\n"
|
"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!'));
|
.($comments ? 'The first messages below were left by visitors of the original site.' : 'Be the first to comment!'));
|
||||||
foreach ($comments as $comment) {
|
foreach ($comments as $comment) {
|
||||||
$header = '**'.$this->plain($comment['author']).'**'
|
$this->post($discussion, $this->author($comment['author']), Carbon::parse($comment['date']), $comment['text']);
|
||||||
.($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->setFirstPost($first)->refreshCommentCount()->refreshLastPost()->refreshParticipantCount()->save();
|
||||||
@@ -124,9 +152,84 @@ class ImportCommand extends AbstractCommand
|
|||||||
return $post;
|
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
|
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