diff --git a/content/museum/apple-ii.md b/content/museum/apple-ii.md index afec78bd..4d7dbbaa 100644 --- a/content/museum/apple-ii.md +++ b/content/museum/apple-ii.md @@ -67,7 +67,7 @@ params: - docs - videos - comments - forum_thread: 1 + forum_thread: 7 photos: - title: INSIDE AN APPLE II subtitle: '' diff --git a/content/museum/commodore-c64.md b/content/museum/commodore-c64.md index 96393898..389dd7f9 100644 --- a/content/museum/commodore-c64.md +++ b/content/museum/commodore-c64.md @@ -72,7 +72,7 @@ params: - docs - videos - comments - forum_thread: 2 + forum_thread: 8 photos: - title: COMMODORE 64 subtitle: '' diff --git a/content/museum/kaypro-pc-286i.md b/content/museum/kaypro-pc-286i.md index 414d6828..542e1d0b 100644 --- a/content/museum/kaypro-pc-286i.md +++ b/content/museum/kaypro-pc-286i.md @@ -67,7 +67,7 @@ params: tabs: - photos - comments - forum_thread: 5 + forum_thread: 11 photos: - title: COMPLETE SYSTEM subtitle: '' diff --git a/content/museum/magnavox-odyssey-100.md b/content/museum/magnavox-odyssey-100.md index 37f00095..8846337d 100644 --- a/content/museum/magnavox-odyssey-100.md +++ b/content/museum/magnavox-odyssey-100.md @@ -54,7 +54,7 @@ params: - links - docs - comments - forum_thread: 6 + forum_thread: 12 photos: - title: ODYSSEY 100 subtitle: '' diff --git a/content/museum/magnavox-odyssey.md b/content/museum/magnavox-odyssey.md index 29e6ab61..89a79e51 100644 --- a/content/museum/magnavox-odyssey.md +++ b/content/museum/magnavox-odyssey.md @@ -52,7 +52,7 @@ params: - links - videos - comments - forum_thread: 4 + forum_thread: 10 photos: - title: MAIN UNIT subtitle: '' diff --git a/content/museum/nec-pc-6601.md b/content/museum/nec-pc-6601.md index 0f287fce..90f30524 100644 --- a/content/museum/nec-pc-6601.md +++ b/content/museum/nec-pc-6601.md @@ -69,7 +69,7 @@ params: - adverts - links - comments - forum_thread: 3 + forum_thread: 9 photos: - title: PC-6601 subtitle: '' diff --git a/data/forum.json b/data/forum.json index 6f621501..3d2ccd28 100644 --- a/data/forum.json +++ b/data/forum.json @@ -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 } diff --git a/forum/README.md b/forum/README.md index 479a3857..e6753914 100644 --- a/forum/README.md +++ b/forum/README.md @@ -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`), diff --git a/forum/docker/entrypoint.sh b/forum/docker/entrypoint.sh index 4a524963..241f2ac2 100755 --- a/forum/docker/entrypoint.sh +++ b/forum/docker/entrypoint.sh @@ -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 "$@" diff --git a/forum/extension/extend.php b/forum/extension/extend.php index 6ab6ab9a..68929936 100644 --- a/forum/extension/extend.php +++ b/forum/extension/extend.php @@ -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'), ]; diff --git a/forum/extension/src/Console/ImportCommand.php b/forum/extension/src/Console/ImportCommand.php index 43ab153e..eba22109 100644 --- a/forum/extension/src/Console/ImportCommand.php +++ b/forum/extension/src/Console/ImportCommand.php @@ -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 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 diff --git a/forum/extension/src/DisplayName.php b/forum/extension/src/DisplayName.php new file mode 100644 index 00000000..2590db70 --- /dev/null +++ b/forum/extension/src/DisplayName.php @@ -0,0 +1,27 @@ +nickname && str_ends_with((string) $user->email, self::ARCHIVE_DOMAIN)) { + return $user->nickname; + } + + return (new NicknameDriver())->displayName($user); + } +}