From 802f3f1b6c13f167133f17cf819bca37ff7760ec Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Wed, 11 Nov 2026 22:34:53 -0600 Subject: [PATCH 1/5] Fix enabling on a fresh PostgreSQL board version_1_0_0 inserts the first seven Knowledge Base permission options with explicit ids, which leaves PostgreSQL's sequence for kb_options at 1. version_2_0_30 then let the sequence pick the id for kb_m_change_author, got 1 again, and stopped with a duplicate key, so the extension could not be enabled on a new PostgreSQL board. It now gives the option the next id itself, as version_1_0_0 does. Adds a regression test that builds kb_options the way version_1_0_0 does and runs version_2_0_30 on SQLite, MySQL and PostgreSQL. Co-Authored-By: Claude Opus 5.5 --- migrations/version_2_0_30.php | 9 +- tests/kb_options_id_migration_test.php | 117 +++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/kb_options_id_migration_test.php diff --git a/migrations/version_2_0_30.php b/migrations/version_2_0_30.php index 1c75cb1..b3fa7fa 100644 --- a/migrations/version_2_0_30.php +++ b/migrations/version_2_0_30.php @@ -32,8 +32,15 @@ public function add_permission(): void $this->db->sql_freeresult($result); if (!$exists) { + // An explicit id, like the options version_1_0_0 inserted: those + // left PostgreSQL's sequence at 1, so letting it pick one fails + // with a duplicate key on a fresh PostgreSQL board + $result = $this->db->sql_query('SELECT MAX(auth_option_id) AS max_id FROM ' . $this->table_prefix . 'kb_options'); + $next_id = (int) $this->db->sql_fetchfield('max_id') + 1; + $this->db->sql_freeresult($result); + $this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_options ' . $this->db->sql_build_array('INSERT', [ - 'auth_option' => 'kb_m_change_author', 'is_global' => 0, 'is_local' => 1, + 'auth_option_id' => $next_id, 'auth_option' => 'kb_m_change_author', 'is_global' => 0, 'is_local' => 1, ])); } } diff --git a/tests/kb_options_id_migration_test.php b/tests/kb_options_id_migration_test.php new file mode 100644 index 0000000..7a8ab4b --- /dev/null +++ b/tests/kb_options_id_migration_test.php @@ -0,0 +1,117 @@ +tools instanceof \phpbb\db\tools\doctrine) $this->tools->get_connection()->close(); + if ($this->db) $this->db->sql_close(); + if ($this->sqlite_fixture) @unlink($this->sqlite_fixture); + } + + public function engines() + { + return [['sqlite'], ['mysql'], ['postgres']]; + } + + private function connect(string $engine): void + { + if ($engine === 'sqlite') + { + $this->db = new \phpbb\db\driver\sqlite3(); + if (class_exists(\phpbb\db\tools\doctrine::class)) + { + $this->sqlite_fixture = tempnam(sys_get_temp_dir(), 'kb-options-'); + $this->assertTrue($this->db->sql_connect($this->sqlite_fixture, '', '', '')); + $this->tools = new \phpbb\db\tools\doctrine(\phpbb\db\doctrine\connection_factory::get_connection_from_params('sqlite3', $this->sqlite_fixture)); + $this->tools->set_table_prefix('phpbb_'); + } + else + { + $this->assertTrue($this->db->sql_connect(':memory:', '', '', '')); + $this->tools = new \phpbb\db\tools\sqlite3($this->db); + } + return; + } + + $port = getenv('KB_TEST_' . strtoupper($engine) . '_PORT'); + if (!$port) $this->markTestSkipped('Set the fixture database port to run integration tests'); + $this->assertTrue(ctype_digit($port)); + $this->db = $engine === 'mysql' ? new \phpbb\db\driver\mysqli() : new \phpbb\db\driver\postgres(); + $this->assertIsObject($this->db->sql_connect('127.0.0.1', $engine === 'mysql' ? 'root' : 'postgres', '', 'kb_fixture', (int) $port)); + if (class_exists(\phpbb\db\tools\doctrine::class)) + { + $this->tools = new \phpbb\db\tools\doctrine(\phpbb\db\doctrine\connection_factory::get_connection_from_params( + $engine === 'mysql' ? 'mysqli' : 'postgres', '127.0.0.1', $engine === 'mysql' ? 'root' : 'postgres', '', 'kb_fixture', $port + )); + $this->tools->set_table_prefix('phpbb_'); + } + else + { + $this->tools = $engine === 'mysql' ? new \phpbb\db\tools\tools($this->db) : new \phpbb\db\tools\postgres($this->db); + } + } + + /** @dataProvider engines */ + public function test_the_author_permission_is_added_after_the_first_options_on_a_fresh_board($engine) + { + $this->connect($engine); + foreach (['phpbb_kb_options', 'phpbb_kb_users', 'phpbb_kb_groups'] as $table) + { + if ($this->tools->sql_table_exists($table)) $this->tools->sql_table_drop($table); + if ($this->db->get_sql_layer() === 'postgres') $this->db->sql_query('DROP SEQUENCE IF EXISTS ' . $table . '_seq'); + } + + // The options table and its first seven options, exactly as version_1_0_0 makes them + $config = new \phpbb\config\config([]); + $first = new version_1_0_0($config, $this->db, $this->tools, '', 'php', 'phpbb_', []); + $this->tools->sql_create_table('phpbb_kb_options', $first->update_schema()['add_tables']['phpbb_kb_options']); + $first->update_kb_options_table(); + + (new version_2_0_30($config, $this->db, $this->tools, '', 'php', 'phpbb_', []))->add_permission(); + + $result = $this->db->sql_query('SELECT auth_option_id, auth_option FROM phpbb_kb_options ORDER BY auth_option_id'); + $options = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $options[(int) $row['auth_option_id']] = $row['auth_option']; + } + $this->db->sql_freeresult($result); + + $this->assertSame('kb_m_change_author', $options[8] ?? null); + $this->assertCount(8, $options); + + // Running it again adds nothing + (new version_2_0_30($config, $this->db, $this->tools, '', 'php', 'phpbb_', []))->add_permission(); + $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM phpbb_kb_options'); + $this->assertSame(8, (int) $this->db->sql_fetchfield('total')); + $this->db->sql_freeresult($result); + + $this->tools->sql_table_drop('phpbb_kb_options'); + if ($this->db->get_sql_layer() === 'postgres') $this->db->sql_query('DROP SEQUENCE IF EXISTS phpbb_kb_options_seq'); + } +} From e3ad0779594062af18a3ef7177cb5c883124da5e Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Wed, 11 Nov 2026 22:38:59 -0600 Subject: [PATCH 2/5] Fix the search-match index rebuild on phpBB 3.3 with PostgreSQL On PostgreSQL, version_1_0_3 creates the search-match unique index as plain un_mtch. phpBB 3.3's database tools find an index by that name but drop it as {table}_un_mtch, so version_2_0_31 stopped with "index does not exist" and a fresh phpBB 3.3 PostgreSQL board could not enable the extension. version_2_0_31 now drops this table's un_mtch index under whichever name it really has before creating the four-column one. It leaves an index of that name on another table alone. The regression test runs the real migration chain for the table (version_1_0_0, 1_0_3 with its data step, 2_0_2, 2_0_15, then 2_0_31 twice) on SQLite, MySQL and PostgreSQL. Co-Authored-By: Claude Opus 5.5 --- migrations/version_2_0_31.php | 22 ++++++++++++ tests/kb_options_id_migration_test.php | 50 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/migrations/version_2_0_31.php b/migrations/version_2_0_31.php index 93d615f..8fea559 100644 --- a/migrations/version_2_0_31.php +++ b/migrations/version_2_0_31.php @@ -55,6 +55,28 @@ public function rebuild_match_index(): void return; } + // On PostgreSQL, version_1_0_3 created the index as plain un_mtch, but + // phpBB 3.3's tools find it by that name and then drop + // {table}_un_mtch, which does not exist. Drop whichever is there by + // its real name instead. + if ($this->db->get_sql_layer() === 'postgres' && !($this->db_tools instanceof \phpbb\db\tools\doctrine)) + { + // Only this table's: another table may own an index called un_mtch + $result = $this->db->sql_query("SELECT indexname + FROM pg_indexes + WHERE tablename = '" . $this->db->sql_escape($table) . "' + AND " . $this->db->sql_in_set('indexname', ['un_mtch', $table . '_un_mtch'])); + $indexes = array_column($this->db->sql_fetchrowset($result), 'indexname'); + $this->db->sql_freeresult($result); + + foreach ($indexes as $index) + { + $this->db->sql_query('DROP INDEX ' . $index); + } + $this->db_tools->sql_create_unique_index($table, 'un_mtch', self::MATCH_INDEX_COLUMNS); + return; + } + // phpBB 4.0 stores the index under the shortened table name, for example // kbsrcwrdmtch_un_mtch, and only finds it by that full name $stored_name = 'un_mtch'; diff --git a/tests/kb_options_id_migration_test.php b/tests/kb_options_id_migration_test.php index 7a8ab4b..9fd67ad 100644 --- a/tests/kb_options_id_migration_test.php +++ b/tests/kb_options_id_migration_test.php @@ -12,13 +12,23 @@ require_once __DIR__ . '/kb_extensions_test.php'; require_once dirname(__DIR__) . '/migrations/version_1_0_0.php'; +require_once dirname(__DIR__) . '/migrations/version_1_0_3.php'; +require_once dirname(__DIR__) . '/migrations/version_2_0_2.php'; +require_once dirname(__DIR__) . '/migrations/version_2_0_15.php'; require_once dirname(__DIR__) . '/migrations/version_2_0_30.php'; +require_once dirname(__DIR__) . '/migrations/version_2_0_31.php'; if (!defined('CONFIG_TABLE')) define('CONFIG_TABLE', 'phpbb_config'); /** + * Migrations that stopped a fresh PostgreSQL board from enabling the extension. + * * version_2_0_30 adds kb_m_change_author after the seven options * version_1_0_0 inserted with explicit ids. On PostgreSQL those inserts leave * the table's sequence at 1, so the new option must not take its id from it. + * + * version_2_0_31 rebuilds the search-match index that version_1_0_3 created + * as plain un_mtch on PostgreSQL, where phpBB 3.3's tools drop it as + * {table}_un_mtch. */ class kb_options_id_migration_test extends \PHPUnit\Framework\TestCase { @@ -114,4 +124,44 @@ public function test_the_author_permission_is_added_after_the_first_options_on_a $this->tools->sql_table_drop('phpbb_kb_options'); if ($this->db->get_sql_layer() === 'postgres') $this->db->sql_query('DROP SEQUENCE IF EXISTS phpbb_kb_options_seq'); } + + /** @dataProvider engines */ + public function test_the_search_match_index_is_rebuilt_after_version_1_0_3_made_it($engine) + { + $this->connect($engine); + $table = 'phpbb_kb_src_wrdmtch'; + foreach ([$table, 'phpbb_kb_articles'] as $name) + { + if ($this->tools->sql_table_exists($name)) $this->tools->sql_table_drop($name); + } + // version_1_0_3 also drops a kb_articles column + $this->tools->sql_create_table('phpbb_kb_articles', ['COLUMNS' => ['article_id' => ['UINT', 0], 'bbcode_bitfield' => ['VCHAR:32', '']]]); + + // The table as version_1_0_0, 1_0_3 (schema and data), 2_0_2 and 2_0_15 leave it + $config = new \phpbb\config\config([]); + $migration = fn($class) => new $class($config, $this->db, $this->tools, '', 'php', 'phpbb_', []); + $this->tools->sql_create_table($table, $migration(version_1_0_0::class)->update_schema()['add_tables'][$table]); + $this->tools->perform_schema_changes($migration(version_1_0_3::class)->update_schema()); + $migration(version_1_0_3::class)->add_key(); + $this->tools->perform_schema_changes($migration(version_2_0_2::class)->update_schema()); + $this->tools->perform_schema_changes($migration(version_2_0_15::class)->update_schema()); + $this->db->sql_query('INSERT INTO ' . $table . ' (article_id, word_id, title_match, descr_match) VALUES (1, 1, 0, 1)'); + + $migration(version_2_0_31::class)->rebuild_match_index(); + // Run again (an enable retried): still fine + $migration(version_2_0_31::class)->rebuild_match_index(); + + // The match survives, and the four-column unique key holds + $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM ' . $table); + $this->assertSame(1, (int) $this->db->sql_fetchfield('total')); + $this->db->sql_freeresult($result); + $this->db->sql_return_on_error(true); + $duplicate = $this->db->sql_query('INSERT INTO ' . $table . ' (article_id, word_id, title_match, descr_match) VALUES (1, 1, 0, 1)'); + $this->db->sql_return_on_error(false); + $this->assertFalse($duplicate, 'a duplicate match should be refused by the unique index'); + $this->assertNotFalse($this->db->sql_query('INSERT INTO ' . $table . ' (article_id, word_id, title_match, descr_match) VALUES (1, 1, 0, 0)')); + + $this->tools->sql_table_drop($table); + $this->tools->sql_table_drop('phpbb_kb_articles'); + } } From 2abacf2862a69293a1f7367f4e6507a6a29168f2 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Wed, 11 Nov 2026 23:54:55 -0600 Subject: [PATCH 3/5] Import a board's kinerity/knowledgebase data when enabled kinerity/knowledgebase uses the table names kb_articles and kb_categories with its own columns. New migration kinerity_tables_set_aside, which version_1_0_0 now depends on, copies kinerity's tables to kb_kinerity_* backup tables, counts the copies and drops the originals (and on PostgreSQL the sequence phpBB 4.0 leaves behind). version_2_0_32 then imports them in batches: categories with their tree, plain-text details and kinerity's board-wide permissions on every category; articles with their text, author, dates and views, the lowest category as primary and the others cross-listed, waiting articles queued and deleted or denied ones deactivated; then article counts, u_kb_view and a_manage_kb for kinerity's readers and admins, and the removal of kinerity's permissions, ACP modules, settings, notification types and extension entry. Each backup row records its new id and is claimed inside its batch's transaction, so an import that stops carries on where it stopped and two requests never convert a row twice. Enabling is refused while kinerity is enabled. docs/upgrading-from-kinerity.md describes the move, including the command line for large boards and retrying in the ACP. Tests: kb_kinerity_import_test.php against this extension's real schema on SQLite, MySQL and PostgreSQL; tests/board/kinerity_import_check.php compares a switched board with its backup tables row by row, and tests/browser/kb_kinerity_import.js checks the result in a browser. docs/testing.md records 50,000-article boards on phpBB 3.3 and 4.0. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + docs/testing.md | 76 ++ docs/upgrading-from-kinerity.md | 132 +++ ext.php | 12 + inc/kinerity_import.php | 1151 ++++++++++++++++++++++ language/en/install_failed.php | 1 + migrations/kinerity_tables_set_aside.php | 47 + migrations/version_1_0_0.php | 7 +- migrations/version_2_0_32.php | 118 +++ tests/board/kinerity_import_check.php | 380 +++++++ tests/browser/kb_kinerity_import.js | 173 ++++ tests/browser/kb_kinerity_import_ids.php | 78 ++ tests/kb_ext_enableable_test.php | 17 +- tests/kb_kinerity_import_test.php | 843 ++++++++++++++++ 14 files changed, 3036 insertions(+), 3 deletions(-) create mode 100644 docs/upgrading-from-kinerity.md create mode 100644 inc/kinerity_import.php create mode 100644 migrations/kinerity_tables_set_aside.php create mode 100644 migrations/version_2_0_32.php create mode 100644 tests/board/kinerity_import_check.php create mode 100644 tests/browser/kb_kinerity_import.js create mode 100644 tests/browser/kb_kinerity_import_ids.php create mode 100644 tests/kb_kinerity_import_test.php diff --git a/README.md b/README.md index 3eea023..1e07524 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Upgrading from the original `sheer/knowledgebase` extension? See [Upgrading from the original](docs/upgrading-from-original.md): your data carries over. +Moving from the `kinerity/knowledgebase` extension? See +[Moving from kinerity/knowledgebase](docs/upgrading-from-kinerity.md): enabling this extension +imports its categories, articles and permissions. + Attachments are stored under `store/phpbbmodders_knowledgebase/files/` in the board's own folder, with the rest of phpBB's data, so they stay when the extension's folder is replaced to upgrade it. The folder is made when the extension is enabled and has its own `.htaccess`. **That only works on diff --git a/docs/testing.md b/docs/testing.md index 83252c8..5b11c30 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1411,3 +1411,79 @@ phpBB 3.3.x and master. On master PHPUnit 10 also reports 53 deprecations (data are not static), which do not fail the run. MySQL can answer `mysqladmin ping` on its socket before it accepts TCP connections; tests started in that gap fail to connect, so wait a few seconds more after a fresh container reports ready. The CI health check pings over TCP for that reason. + +## Importing from kinerity/knowledgebase - 11/12/2026 + +New migrations `kinerity_tables_set_aside` (which `version_1_0_0` now depends on) and +`version_2_0_32`, with `inc/kinerity_import.php`, import a board's `kinerity/knowledgebase` data +when this extension is enabled; see [Moving from kinerity/knowledgebase](upgrading-from-kinerity.md). + +**PHPUnit.** `tests/kb_kinerity_import_test.php` builds this extension's real tables from every +migration's `update_schema()`, so a column the import writes that a later migration removed fails +the test (it caught `bbcode_bitfield`, dropped by `version_1_0_3`). It covers moving kinerity's +tables aside and retrying that, kinerity 1.0.x without `article_edit_name`, the category tree, +plain-text details (including an emoji phpBB stored as an entity, which MySQL's utf8 tables cannot +hold raw), each article state, the lowest category as primary and the rest cross-listed, articles +in no category or only a deleted one, counters, permissions per group and user on every category, +the board-wide read and admin permissions, kinerity's settings, notification types and extension +entry removed, an import stopped part way, two requests converting the same batch at once, a purge +followed by a second import, a board that never ran kinerity, and a board that already has +categories. It runs on SQLite, MySQL and PostgreSQL, and on PostgreSQL also with a sequence made the +phpBB 3.3 way, which phpBB 4.0's tools leave behind when the table is dropped (without dropping it +too, this extension's table of the same name cannot be created). The full suite, 558 tests, passes on +phpBB 3.3.x and master with the MySQL 8.0 and PostgreSQL 16 fixtures, none skipped. + +**Boards.** Claude built boards with seed-forum's `config/kinerity-33.yml`, `kinerity-40.yml` and +`kinerity-33-mssql.yml` and their `-switch` configs, and `bin/seed-kinerity-fixtures.php`: 2,000 +categories up to five levels deep and 50,000 articles (about 40,000 approved, 4,000 waiting, 3,000 +deleted and 3,000 denied; 14,000 in two to four categories; about 1,000 in no category or only a +deleted one; 70,000 category links), plus kinerity's own roles, a group and two members with +permissions of their own, and a kinerity notification. kinerity was enabled in the ACP (it cannot be +enabled from phpBB's command line; see below), seeded, disabled, and this extension enabled. +`tests/board/kinerity_import_check.php` then compared every backup row with what was imported. + +| phpBB | Database | Enabled through | Articles | Time | Result | +| --- | --- | --- | --- | --- | --- | +| 3.3 | MySQL 8.4 | command line | 50,000 | 262 s | all 35 checks pass | +| 3.3 | PostgreSQL 16 | ACP | 50,000 | one page load | all 35 checks pass | +| 3.3 | SQLite | command line | 50,000 | 132 s | all 35 checks pass | +| 4.0 | MySQL 8.4 | command line | 50,000 | 246 s | all 35 checks pass | +| 4.0 | PostgreSQL 16 | ACP | 50,000 | 278 s, 2 attempts | all 35 checks pass | + +`tests/browser/kb_kinerity_import.js` (14 checks, ids from `kb_kinerity_import_ids.php`) passed on a +phpBB 3.3 PostgreSQL board: guests refused as kinerity refused them, a member sees the imported +articles and sections, BBCode formatted, special characters shown once, a cross-listed article in its +second category, a deactivated article hidden, waiting articles in the queue as New, the ACP pages, +no kinerity module left, and search after building the index. + +**Found and fixed on the way.** +- phpBB's ACP enables an extension in one page load (`meta_refresh()` only sets a template value; + the loop carries on), so a large import outlasts nginx's 60 seconds and ends on a 504 while PHP + carries on. On these boards PHP finished it. An admin who enables again meanwhile starts a second + import of the same batch; that created 12,000 duplicate articles on the 4.0 PostgreSQL board. Each + backup row is now claimed inside the batch's transaction before it is converted (`kb_new_id` is set + to -1 where it is still 0, and only the request that changed the row goes on), and the fallback + category is reused. The rebuilt board, with the same two overlapping attempts, passed. Retrying in + the ACP also needs the cache purged first, or phpBB stops on a duplicate key in its extension list; + the guide says so. +- Two existing bugs stopped this extension from being enabled on a new PostgreSQL board at all + (`version_2_0_30`'s option id, `version_2_0_31`'s index name on phpBB 3.3); fixed separately with + `tests/kb_options_id_migration_test.php`. + +**kinerity itself**, as found by the fixture (not changed here): its first migration reads the +current user's name, so it cannot be enabled from phpBB's command line; on PostgreSQL it inserts its +sample category and article with id 1 without moving the sequences on, so the next insert fails; its +article list loads every article at once and runs out of memory with tens of thousands; and it stops +with an error on an article with no category. `SEED_KINERITY_LEFTOVERS=0` leaves those articles out +for boards meant to be viewed in kinerity. + +**MSSQL.** kinerity cannot be installed on MSSQL: its first migration inserts its sample category +with a fixed id, which MSSQL refuses for an identity column. So no board can bring kinerity data from +MSSQL, and there is no MSSQL row above. Trying it showed that this extension cannot be enabled on a +new MSSQL board either: `version_1_0_0` stops on its own fixed-id permission options in the same way, +and, with that worked around, on a later step that compares an `ntext` column with a number. That +is a separate piece of work, not part of this change. + +**Not covered.** Boards upgraded from phpBB 3.3 to 4.0 with kinerity data (the sequence case is +covered by PHPUnit only); a custom style; kinerity versions before 1.2.1 on a board (1.0.x's missing +column is covered by PHPUnit only); MariaDB. diff --git a/docs/upgrading-from-kinerity.md b/docs/upgrading-from-kinerity.md new file mode 100644 index 0000000..c1c32d8 --- /dev/null +++ b/docs/upgrading-from-kinerity.md @@ -0,0 +1,132 @@ +# Moving from the kinerity/knowledgebase extension + +This extension can import a board's knowledge base from the +`kinerity/knowledgebase` extension (also published as Crizz0's +`knowledgebase` fork; version 1.2.1 is the one tested, and 1.0.x and 1.1.x +boards are handled too). Your categories, articles and permissions come +across when you enable this extension. Nothing has to be converted by hand. + +Both extensions use database tables called `kb_articles` and `kb_categories`, +with different columns. When this extension is enabled, it first copies +kinerity's tables to backup tables, `kb_kinerity_articles`, +`kb_kinerity_categories` and `kb_kinerity_article_category`, and removes the +originals. Then it creates its own tables and imports into them. The backup +tables are kept, unchanged. + +## Steps + +1. Back up the database. +2. In the Administration Control Panel go to **Customise → Manage extensions** + and **Disable** `Knowledge Base` (kinerity). Do not use *Delete data*: that + removes kinerity's tables, and with them everything there is to import. This + extension refuses to enable while kinerity is still enabled and says so. +3. Copy this extension to `ext/phpbbmodders/knowledgebase`. +4. **Enable** it. The import runs while it is being enabled. + - On a large knowledge base, use the command line. It has no page time + limit and shows each step as it runs: + + ```bash + php bin/phpbbcli.php extension:enable phpbbmodders/knowledgebase + ``` + + - In the Administration Control Panel, phpBB enables an extension in a + single page load, so a large import can run past the server's time limit + and end on a time-out or error page. Nothing is lost: the categories and + articles are imported in batches, and each finished batch is kept. Go to + **General → Purge the cache**, then enable the extension again; it carries + on where it stopped. Repeat until phpBB says the extension was enabled. + (Purging the cache matters: without it phpBB can stop with a "duplicate + key" error on its list of extensions.) + + Either way, an import that stops part way carries on where it stopped the + next time the extension is enabled, and imports nothing twice. +5. Go to **Extensions → Knowledge Base → Search** and create the search index, + so the imported articles can be found by the Knowledge Base search. +6. Check your categories, articles and permissions under + **Extensions → Knowledge Base**. +7. Delete the `ext/kinerity/knowledgebase` folder. Don't enable kinerity again + on this board: its pages would read this extension's tables. + +## What is imported + +- **Categories**, with their order and nesting. A category's description + becomes plain text, because this extension's category details have no + BBCode, and is shortened to 255 characters if it is longer. +- **Articles**, with their title, text (BBCode, smilies and links as they + were), author, dates and view count. Descriptions longer than 255 characters + are shortened, ending in "…". + - Approved articles are published. + - Articles waiting for approval go to the approval queue. + - Articles kinerity marked as deleted or denied are kept, deactivated, so a + moderator can reactivate or delete them. + - An article that was in several categories goes in the one kinerity created + first (the lowest category id) and is cross-listed in the others. + - Articles kinerity filed in no category, or only in a category that no + longer exists, go in a new top-level category called + "Imported articles without a category". +- **Permissions.** kinerity's permissions applied to the whole knowledge base; + this extension's apply per category. Each group's and user's kinerity + setting (Yes or Never, set directly or through a role) is given to them on + every imported category: + + | kinerity permission | This extension's permission | + | --- | --- | + | `u_kb_read` | Can view Knowledge Base (`u_kb_view`, board-wide) | + | `u_kb_post` | Can add articles | + | `u_kb_noapprove` | Can add / modify articles without prior approval | + | `u_kb_edit` | Can edit own articles | + | `u_kb_delete` | Can delete own articles | + | `m_kb_edit` | Can edit articles | + | `m_kb_delete` | Can delete articles | + | `m_kb_approve` | Can approve articles | + | `m_kb_chgposter` | Can change the primary author of submitted articles | + | `a_kb_manage` | Can manage Knowledge Base (`a_manage_kb`, board-wide) | + + A group or user that already has a setting for `u_kb_view` or `a_manage_kb` + keeps it. + +## What is removed + +After the import, kinerity's permissions (with their settings in roles, +groups and users), its ACP modules, its three settings (the on/off switch, the +header link and its icon, which this extension has no equivalent of), its +notification types with their notifications and users' settings for them, +and its entry in the extensions list are removed. + +kinerity's migration records are kept on purpose. Without them, enabling +kinerity again would run its first migration, which fails on this extension's +tables, and phpBB would then drop those tables while undoing it. + +## Not imported + +- Who last edited an article (kinerity's "edited by" name). The edit date is + kept. +- A category's article count above 65,535, the most that column holds; the + articles themselves are all imported. +- kinerity's per-article BBCode, smilies and links switches: the stored text + already shows them the way kinerity did. +- Old links. Articles and categories get new ids, so links to kinerity's pages + (`/kb/viewarticle?a=…` and the like) in posts or search engines don't lead to + the imported articles. The backup tables record each article's old and new + id (`article_id` and `kb_new_id`). + +## The backup tables + +The `kb_kinerity_*` tables hold kinerity's data as it was, plus the id each +row was given here. Purging this extension leaves them in place, and enabling +it again imports them again. Once you are happy with the result you can drop +them with your database tool, for example: + +```sql +DROP TABLE phpbb_kb_kinerity_articles; +DROP TABLE phpbb_kb_kinerity_categories; +DROP TABLE phpbb_kb_kinerity_article_category; +``` + +(Use your board's table prefix in place of `phpbb_`.) + +## If something goes wrong + +Restore the database backup from step 1. Enabling stops and reports an error +rather than carrying on. kinerity's tables are only removed once every row has +been copied to the backup tables and the copies counted. diff --git a/ext.php b/ext.php index e13b514..e04cd8d 100644 --- a/ext.php +++ b/ext.php @@ -25,6 +25,9 @@ class ext extends \phpbb\extension\base /** The original package this extension can take over from; it must be disabled first. */ public const ORIGINAL_EXTENSION = 'sheer/knowledgebase'; + /** Another knowledge base this extension can import from; it must be disabled first. */ + public const KINERITY_EXTENSION = 'kinerity/knowledgebase'; + /** * Check whether the extension can be enabled. * The current phpBB version should meet or exceed @@ -53,6 +56,15 @@ public function is_enableable() $messages[] = $language->lang('INSTALL_FAILED_ORIGINAL_ENABLED', self::ORIGINAL_EXTENSION); } + // kinerity/knowledgebase uses the same table names and /kb pages. + // Once it is disabled, enabling this one imports its data (see + // migrations kinerity_tables_set_aside and version_2_0_32). + if ($this->container->get('ext.manager')->is_enabled(self::KINERITY_EXTENSION)) + { + $language->add_lang('install_failed', 'phpbbmodders/knowledgebase'); + $messages[] = $language->lang('INSTALL_FAILED_KINERITY_ENABLED', self::KINERITY_EXTENSION); + } + return $messages ?: true; } diff --git a/inc/kinerity_import.php b/inc/kinerity_import.php new file mode 100644 index 0000000..ff4049d --- /dev/null +++ b/inc/kinerity_import.php @@ -0,0 +1,1151 @@ + 'kb_u_add', + 'u_kb_noapprove' => 'kb_u_add_noapprove', + 'u_kb_edit' => 'kb_u_edit', + 'u_kb_delete' => 'kb_u_delete', + 'm_kb_edit' => 'kb_m_edit', + 'm_kb_delete' => 'kb_m_delete', + 'm_kb_approve' => 'kb_m_approve', + 'm_kb_chgposter' => 'kb_m_change_author', + ]; + + /** kinerity permissions that become this extension's board-wide permissions. */ + public const GLOBAL_PERMISSIONS = [ + 'u_kb_read' => 'u_kb_view', + 'a_kb_manage' => 'a_manage_kb', + ]; + + /** Every permission kinerity added, removed once the import is done. */ + public const KINERITY_PERMISSIONS = [ + 'u_kb_read', 'u_kb_post', 'u_kb_edit', 'u_kb_delete', 'u_kb_noapprove', + 'm_kb_edit', 'm_kb_delete', 'm_kb_approve', 'm_kb_chgposter', + 'a_kb_manage', + ]; + + /** Board settings kinerity added. None has an equivalent here. */ + public const KINERITY_CONFIG = ['knowledgebase_enable', 'knowledgebase_header_link', 'knowledgebase_font_icon']; + + /** Notification types kinerity registered. */ + public const KINERITY_NOTIFICATION_TYPES = [ + 'kinerity.knowledgebase.notification.type.approve_article', + 'kinerity.knowledgebase.notification.type.article_in_queue', + 'kinerity.knowledgebase.notification.type.delete_article', + 'kinerity.knowledgebase.notification.type.deny_article', + 'kinerity.knowledgebase.notification.type.disapprove_article', + ]; + + /** + * kb_new_id of a backup row a request has claimed but not converted yet, + * never a real id. Only ever seen inside that request's transaction. + */ + protected const CLAIMED = -1; + + /** phpBB's ACL_YES and ACL_NEVER, without needing phpBB's constants loaded. */ + protected const ACL_YES = 1; + protected const ACL_NEVER = 0; + + protected driver_interface $db; + protected tools_interface $db_tools; + protected string $table_prefix; + + /** + * @param driver_interface $db + * @param tools_interface $db_tools + * @param string $table_prefix The board's table prefix, such as phpbb_ + */ + public function __construct(driver_interface $db, tools_interface $db_tools, string $table_prefix) + { + $this->db = $db; + $this->db_tools = $db_tools; + $this->table_prefix = $table_prefix; + } + + /** + * Table names: kinerity's own, and the backup copy each is moved to. + * + * @return array + */ + public function tables(): array + { + return [ + 'articles' => ['original' => $this->table_prefix . 'kb_articles', 'backup' => $this->table_prefix . 'kb_kinerity_articles'], + 'categories' => ['original' => $this->table_prefix . 'kb_categories', 'backup' => $this->table_prefix . 'kb_kinerity_categories'], + 'article_category' => ['original' => $this->table_prefix . 'kb_article_category', 'backup' => $this->table_prefix . 'kb_kinerity_article_category'], + ]; + } + + /** + * Whether a kinerity table, with kinerity's layout, is on the board. + * kb_articles and kb_categories are recognised by a column only kinerity + * has, so this extension's own tables of the same name never match. + * + * @param string $key articles, categories or article_category + * @return bool + */ + public function original_table_present(string $key): bool + { + $table = $this->tables()[$key]['original']; + + if (!$this->db_tools->sql_table_exists($table)) + { + return false; + } + + switch ($key) + { + case 'articles': + return $this->db_tools->sql_column_exists($table, 'article_poster_id'); + + case 'categories': + return $this->db_tools->sql_column_exists($table, 'category_description'); + + default: + return true; + } + } + + /** + * Whether this board has kinerity data waiting in, or already converted + * from, the backup tables. + * + * @return bool + */ + public function has_backup(): bool + { + return $this->db_tools->sql_table_exists($this->tables()['articles']['backup']) + || $this->db_tools->sql_table_exists($this->tables()['categories']['backup']); + } + + /** + * Copy each kinerity table to its backup table, check every row arrived, + * and only then drop the original, so this extension can create its own + * tables of the same name. A table already moved is left alone; on a board + * that never ran kinerity nothing happens. + * + * @throws migration_exception When a copy does not hold every row; the original is kept + */ + public function set_aside(): void + { + foreach ($this->tables() as $key => $names) + { + if (!$this->original_table_present($key)) + { + continue; + } + + $columns = $this->backup_columns($key); + if (!$this->db_tools->sql_table_exists($names['backup'])) + { + $this->db_tools->sql_create_table($names['backup'], $this->backup_schema($key)); + } + + // Leftovers of an earlier attempt that stopped before the drop + $this->db->sql_query('DELETE FROM ' . $names['backup']); + + // kinerity 1.0.x has no article_edit_name column yet + $select = []; + foreach ($columns as $column) + { + $select[] = $this->db_tools->sql_column_exists($names['original'], $column) ? $column : "''"; + } + + $this->db->sql_query('INSERT INTO ' . $names['backup'] . ' (' . implode(', ', $columns) . ') + SELECT ' . implode(', ', $select) . ' + FROM ' . $names['original']); + + $original_rows = $this->count_rows($names['original']); + $backup_rows = $this->count_rows($names['backup']); + if ($original_rows !== $backup_rows) + { + throw new migration_exception('The Knowledge Base copied ' . $backup_rows . ' of the ' . $original_rows . ' rows of ' . $names['original'] . ' into ' . $names['backup'] . '. Nothing was removed; enable the extension again to retry.'); + } + + $this->db_tools->sql_table_drop($names['original']); + $this->drop_leftover_sequence($names['original']); + } + } + + /** + * On PostgreSQL, phpBB 3.3 gives an auto-increment column a sequence named + * after its table that dropping the table does not always remove (phpBB + * 4.0's database tools leave it). This extension's table of the same name + * would then fail to create its own, so the dropped table's goes too. + * + * @param string $table A table that has just been dropped + * @return void + */ + protected function drop_leftover_sequence(string $table): void + { + if ($this->db->get_sql_layer() === 'postgres') + { + $this->db->sql_query('DROP SEQUENCE IF EXISTS ' . $table . '_seq'); + } + } + + /** + * Convert the next batch of kinerity categories, parents before children, + * with the permissions kinerity granted board-wide. + * + * @param array|mixed $state What the previous call returned (anything else on the first call) + * @return array|true State for the next call, or true when every category is converted + */ + public function import_categories($state) + { + $names = $this->tables()['categories']; + if (!$this->db_tools->sql_table_exists($names['backup'])) + { + return true; + } + + $state = is_array($state) ? $state : []; + if (!isset($state['offset'])) + { + // This extension's tables are new when the import runs, so this + // is 0; a board that already has categories keeps them first. + $state['offset'] = $this->max_value($this->table_prefix . 'kb_categories', 'right_id'); + } + + // The whole tree (ids and bounds only) to find each category's parent + $sql = 'SELECT category_id, left_id, right_id, kb_new_id + FROM ' . $names['backup'] . ' + ORDER BY left_id ASC, category_id ASC'; + $result = $this->db->sql_query($sql); + $tree = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + $parents = $new_ids = $pending = []; + $stack = []; + foreach ($tree as $row) + { + $id = (int) $row['category_id']; + while ($stack && (int) end($stack)['right_id'] < (int) $row['left_id']) + { + array_pop($stack); + } + $parents[$id] = $stack ? (int) end($stack)['category_id'] : 0; + $stack[] = $row; + + $new_ids[$id] = (int) $row['kb_new_id']; + if (!$new_ids[$id] && count($pending) < static::CATEGORY_BATCH) + { + $pending[] = $id; + } + } + + if (!$pending) + { + return true; + } + + $sql = 'SELECT * + FROM ' . $names['backup'] . ' + WHERE ' . $this->db->sql_in_set('category_id', $pending); + $result = $this->db->sql_query($sql); + $rows = []; + while ($row = $this->db->sql_fetchrow($result)) + { + $rows[(int) $row['category_id']] = $row; + } + $this->db->sql_freeresult($result); + + $grants = $this->category_grants(); + + $this->db->sql_transaction('begin'); + foreach ($pending as $old_id) + { + // Another request converting the same batch (an enable retried while + // the first was still running) has it already + if (!$this->claim($names['backup'], 'category_id', $old_id)) + { + continue; + } + + $row = $rows[$old_id]; + $parent_old_id = $parents[$old_id]; + + $sql_ary = [ + 'parent_id' => $parent_old_id ? (int) ($new_ids[$parent_old_id] ?? 0) : 0, + 'left_id' => (int) $row['left_id'] + $state['offset'], + 'right_id' => (int) $row['right_id'] + $state['offset'], + 'category_parents' => '', + 'category_name' => self::fit_escaped((string) $row['category_name'], self::TEXT_COLUMN_LENGTH), + 'category_details' => self::category_details((string) $row['category_description']), + 'number_articles' => 0, + ]; + $this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_categories ' . $this->db->sql_build_array('INSERT', $sql_ary)); + $new_id = (int) $this->db->sql_nextid(); + $new_ids[$old_id] = $new_id; + + $this->insert_category_permissions($new_id, $grants); + + $this->db->sql_query('UPDATE ' . $names['backup'] . ' + SET kb_new_id = ' . $new_id . ' + WHERE category_id = ' . $old_id); + } + $this->db->sql_transaction('commit'); + + return $state; + } + + /** + * Convert the next batch of kinerity articles. An article in several + * kinerity categories goes in the one with the lowest id and is + * cross-listed in the others. + * + * @param array|mixed $state What the previous call returned (anything else on the first call) + * @return array|true State for the next call, or true when every article is converted + */ + public function import_articles($state) + { + $names = $this->tables(); + if (!$this->db_tools->sql_table_exists($names['articles']['backup'])) + { + return true; + } + + $state = is_array($state) ? $state : []; + + $sql = 'SELECT * + FROM ' . $names['articles']['backup'] . ' + WHERE kb_new_id = 0 + ORDER BY article_id ASC'; + $result = $this->db->sql_query_limit($sql, static::ARTICLE_BATCH); + $rows = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + if (!$rows) + { + return true; + } + + $category_map = $this->category_map(); + $article_ids = array_map('intval', array_column($rows, 'article_id')); + + $links = []; + if ($this->db_tools->sql_table_exists($names['article_category']['backup'])) + { + $sql = 'SELECT article_id, category_id + FROM ' . $names['article_category']['backup'] . ' + WHERE ' . $this->db->sql_in_set('article_id', $article_ids) . ' + ORDER BY category_id ASC'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $new_category = $category_map[(int) $row['category_id']] ?? 0; + // Links to a category kinerity no longer has are dropped + if ($new_category) + { + $links[(int) $row['article_id']][] = $new_category; + } + } + $this->db->sql_freeresult($result); + } + + $this->db->sql_transaction('begin'); + foreach ($rows as $row) + { + $old_id = (int) $row['article_id']; + + // Another request converting the same batch (an enable retried while + // the first was still running) has it already + if (!$this->claim($names['articles']['backup'], 'article_id', $old_id)) + { + continue; + } + + $categories = array_values(array_unique($links[$old_id] ?? [])); + + if (!$categories) + { + if (empty($state['fallback_category'])) + { + $state['fallback_category'] = $this->fallback_category(); + } + $categories = [$state['fallback_category']]; + } + + [$approved, $status] = self::map_visibility((int) $row['article_visibility']); + + $sql_ary = [ + 'article_category_id' => $categories[0], + 'approved' => $approved, + 'status' => $status, + 'article_title' => self::fit_escaped((string) $row['article_title'], self::TEXT_COLUMN_LENGTH), + 'article_description' => self::fit_escaped((string) $row['article_description'], self::TEXT_COLUMN_LENGTH), + 'article_date' => (int) $row['article_time'], + 'edit_date' => (int) $row['article_edit_time'], + 'author_id' => (int) $row['article_poster_id'], + 'author' => (string) $row['article_poster_name'], + 'author_type' => functions_kb::AUTHOR_TYPE_USER, + // Parsed (XML) text needs no bitfield; this table has none since version_1_0_3 + 'bbcode_uid' => substr((string) $row['bbcode_uid'], 0, 10), + 'article_body' => (string) $row['article_text'], + 'views' => (int) $row['article_views'], + ]; + $this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_articles ' . $this->db->sql_build_array('INSERT', $sql_ary)); + $new_id = (int) $this->db->sql_nextid(); + + $cross_listed = []; + foreach (array_slice($categories, 1) as $category_id) + { + $cross_listed[] = ['article_id' => $new_id, 'category_id' => $category_id]; + } + if ($cross_listed) + { + $this->db->sql_multi_insert($this->table_prefix . 'kb_article_categories', $cross_listed); + } + + $this->db->sql_query('UPDATE ' . $names['articles']['backup'] . ' + SET kb_new_id = ' . $new_id . ' + WHERE article_id = ' . $old_id); + } + $this->db->sql_transaction('commit'); + + return $state; + } + + /** + * Mark one backup row as being converted by this request. A request that + * reaches the same row later waits for this transaction and then finds + * it taken, so no row is converted twice. + * + * @param string $table Backup table + * @param string $column Its id column + * @param int $id + * @return bool Whether this request got the row + */ + protected function claim(string $table, string $column, int $id): bool + { + $this->db->sql_query('UPDATE ' . $table . ' + SET kb_new_id = ' . self::CLAIMED . ' + WHERE ' . $column . ' = ' . $id . ' + AND kb_new_id = 0'); + + return $this->db->sql_affectedrows() === 1; + } + + /** + * After the categories and articles: set each category's article count, + * carry kinerity's board-wide read and admin permissions over to this + * extension's, and remove kinerity's settings, notification types and + * extension entry. kinerity's permissions and ACP modules are removed by + * the migration with phpBB's own tools afterwards. + * + * kinerity's migration records are kept on purpose: without them, + * enabling kinerity again would re-run its first migration, which fails on + * this extension's tables and then drops them when phpBB reverts it. + * + * @param config $config + * @return void + */ + public function finish(config $config): void + { + if (!$this->has_backup()) + { + return; + } + + $this->update_article_counts(); + $this->copy_global_permissions(); + + foreach (self::KINERITY_CONFIG as $name) + { + if (isset($config[$name])) + { + $config->delete($name); + } + } + + $this->remove_notification_types(); + + $this->db->sql_query('DELETE FROM ' . $this->table_prefix . "ext + WHERE ext_name = '" . $this->db->sql_escape(self::ORIGINAL_EXTENSION) . "'"); + } + + /** + * Forget which backup rows were converted, so purging this extension and + * enabling it again imports them again into its new tables. + * + * @return void + */ + public function reset_import_markers(): void + { + foreach (['articles', 'categories'] as $key) + { + $backup = $this->tables()[$key]['backup']; + if ($this->db_tools->sql_table_exists($backup)) + { + $this->db->sql_query('UPDATE ' . $backup . ' SET kb_new_id = 0'); + } + } + } + + /** + * kinerity's article_visibility as this extension's approved flag and status. + * Articles waiting for approval go to the queue; kinerity's deleted and + * denied articles are kept, deactivated, for moderators to deal with. + * + * @param int $visibility + * @return array{0: int, 1: int} approved, status + */ + public static function map_visibility(int $visibility): array + { + switch ($visibility) + { + case self::KINERITY_APPROVED: + return [1, functions_kb::STATUS_ACTIVE]; + + case self::KINERITY_DISAPPROVED: + return [0, functions_kb::STATUS_NEW]; + + default: + return [0, functions_kb::STATUS_INACTIVE]; + } + } + + /** + * Text phpBB stored with BBCode, as plain text on one line: the parsed + * (XML) form phpBB 3.2 and later stores, or the older BBCode-with-uid form. + * + * @param string $stored + * @return string Plain text, not HTML-escaped + */ + public static function plain_text(string $stored): string + { + if ($stored === '') + { + return ''; + } + + if (preg_match('#^<[rt][ />]#', $stored) && class_exists(\s9e\TextFormatter\Utils::class)) + { + $text = \s9e\TextFormatter\Utils::removeFormatting($stored); + } + else + { + // Older storage: [b:uid] tags, smiley and link comments, escaped text + $text = preg_replace('##', '', $stored); + $text = preg_replace('#]*alt="([^"]*)"[^>]*>#', '$1', $text); + $text = strip_tags($text); + $text = preg_replace('#\[/?[a-z0-9*+\-]+(?:=[^\]]*)?(?::[a-z])?:[a-z0-9]{5,8}\]#i', '', $text); + $text = htmlspecialchars_decode($text, ENT_QUOTES); + } + + return trim(preg_replace('#\s+#u', ' ', $text)); + } + + /** + * A kinerity category description (stored with BBCode) as this + * extension's category details: plain text, HTML-escaped like user input, + * at most 255 characters. + * + * @param string $stored + * @return string + */ + public static function category_details(string $stored): string + { + return self::fit_escaped(self::encode_supplementary(htmlspecialchars(self::plain_text($stored), ENT_COMPAT, 'UTF-8')), self::TEXT_COLUMN_LENGTH); + } + + /** + * Characters outside the Basic Multilingual Plane (emoji and the like) as + * numeric entities, the way phpBB stores them: phpBB 3.3's MySQL tables + * use a character set that cannot hold them. Turning stored text into + * plain text decodes those entities, so they are encoded again. + * + * @param string $text + * @return string + */ + public static function encode_supplementary(string $text): string + { + return preg_replace_callback('/[\x{10000}-\x{10FFFF}]/u', fn($match) => '&#' . mb_ord($match[0], 'UTF-8') . ';', $text); + } + + /** + * Shorten HTML-escaped text to fit a column, cutting the text itself + * rather than an entity, and ending a shortened text with an ellipsis. + * + * @param string $escaped Text as phpBB stores user input (HTML-escaped) + * @param int $length Column width in characters + * @return string + */ + public static function fit_escaped(string $escaped, int $length): string + { + if (mb_strlen($escaped, 'UTF-8') <= $length) + { + return $escaped; + } + + // Each character can grow to several when escaped (& is &), so + // shorten the plain text until its escaped form fits + $plain = htmlspecialchars_decode($escaped, ENT_COMPAT); + for ($keep = min(mb_strlen($plain, 'UTF-8'), $length) - 1; $keep > 0; $keep--) + { + // Never end on part of an entity such as 🚀 (characters + // phpBB stores as entities stay entities in $plain) + $cut = preg_replace('/&#?[0-9a-z]*$/i', '', mb_substr($plain, 0, $keep, 'UTF-8')); + $fitted = htmlspecialchars(rtrim($cut), ENT_COMPAT, 'UTF-8') . "\u{2026}"; + if (mb_strlen($fitted, 'UTF-8') <= $length) + { + return $fitted; + } + } + + return "\u{2026}"; + } + + /** + * Backup table columns copied from kinerity, in order. + * + * @param string $key + * @return string[] + */ + protected function backup_columns(string $key): array + { + return array_keys(array_diff_key($this->backup_schema($key)['COLUMNS'], ['kb_new_id' => true])); + } + + /** + * Backup table layout: kinerity's columns (1.2.1), without auto-increment + * so the original ids copy as they are, plus kb_new_id, signed so a + * claimed row can be marked -1 (see claim()). + * + * @param string $key + * @return array + */ + protected function backup_schema(string $key): array + { + switch ($key) + { + case 'articles': + return [ + 'COLUMNS' => [ + 'article_id' => ['UINT', 0], + 'article_poster_id' => ['UINT', 0], + 'article_poster_name' => ['VCHAR:255', ''], + 'article_poster_colour' => ['VCHAR:6', ''], + 'article_time' => ['TIMESTAMP', 0], + 'article_edit_time' => ['TIMESTAMP', 0], + 'enable_bbcode' => ['BOOL', 1], + 'enable_smilies' => ['BOOL', 1], + 'enable_magic_url' => ['BOOL', 1], + 'article_title' => ['VCHAR:255', ''], + 'article_description' => ['MTEXT_UNI', ''], + 'article_text' => ['MTEXT_UNI', ''], + 'bbcode_bitfield' => ['VCHAR:255', ''], + 'bbcode_uid' => ['VCHAR:8', ''], + 'article_visibility' => ['TINT:3', 0], + 'article_views' => ['UINT', 0], + 'article_edit_name' => ['VCHAR:255', ''], + 'kb_new_id' => ['BINT', 0], + ], + 'PRIMARY_KEY' => 'article_id', + 'KEYS' => [ + 'kb_new_id' => ['INDEX', 'kb_new_id'], + ], + ]; + + case 'categories': + return [ + 'COLUMNS' => [ + 'category_id' => ['UINT', 0], + 'left_id' => ['UINT', 0], + 'right_id' => ['UINT', 0], + 'category_name' => ['VCHAR:255', ''], + 'category_description' => ['TEXT_UNI', ''], + 'bbcode_bitfield' => ['VCHAR:255', ''], + 'bbcode_options' => ['TIMESTAMP', 7], + 'bbcode_uid' => ['VCHAR:8', ''], + 'kb_new_id' => ['BINT', 0], + ], + 'PRIMARY_KEY' => 'category_id', + ]; + + default: + return [ + 'COLUMNS' => [ + 'category_id' => ['UINT', 0], + 'article_id' => ['UINT', 0], + ], + 'KEYS' => [ + 'article_id' => ['INDEX', 'article_id'], + ], + ]; + } + } + + /** + * kinerity category id => this extension's category id, for every + * category converted so far. + * + * @return array + */ + protected function category_map(): array + { + $map = []; + $backup = $this->tables()['categories']['backup']; + if (!$this->db_tools->sql_table_exists($backup)) + { + return $map; + } + + $sql = 'SELECT category_id, kb_new_id + FROM ' . $backup . ' + WHERE kb_new_id <> 0'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $map[(int) $row['category_id']] = (int) $row['kb_new_id']; + } + $this->db->sql_freeresult($result); + + return $map; + } + + /** + * Make a top-level category, after every other, for kinerity articles + * that were in no category. + * + * @return int The new category's id + */ + protected function fallback_category(): int + { + // One already made by an earlier or a parallel request is used again + $result = $this->db->sql_query_limit('SELECT category_id + FROM ' . $this->table_prefix . "kb_categories + WHERE category_name = '" . $this->db->sql_escape(self::FALLBACK_CATEGORY_NAME) . "' + AND parent_id = 0 + ORDER BY category_id", 1); + $existing = (int) $this->db->sql_fetchfield('category_id'); + $this->db->sql_freeresult($result); + if ($existing) + { + return $existing; + } + + $right = $this->max_value($this->table_prefix . 'kb_categories', 'right_id'); + + $sql_ary = [ + 'parent_id' => 0, + 'left_id' => $right + 1, + 'right_id' => $right + 2, + 'category_parents' => '', + 'category_name' => self::FALLBACK_CATEGORY_NAME, + 'category_details' => '', + 'number_articles' => 0, + ]; + $this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_categories ' . $this->db->sql_build_array('INSERT', $sql_ary)); + $category_id = (int) $this->db->sql_nextid(); + + $this->insert_category_permissions($category_id, $this->category_grants()); + + return $category_id; + } + + /** + * What kinerity's board-wide permissions granted each group and user, + * directly or through a role, as this extension's per-category options. + * A Never anywhere wins over a Yes, as in phpBB. + * + * @return array{groups: array>, users: array>} + * auth_option_id => ACL_YES/ACL_NEVER for each group and user id + */ + protected function category_grants(): array + { + $options = $this->kb_option_ids(); + $settings = $this->kinerity_settings(array_keys(self::CATEGORY_PERMISSIONS)); + + $grants = ['groups' => [], 'users' => []]; + foreach ($settings as $type => $holders) + { + foreach ($holders as $holder_id => $by_option) + { + foreach ($by_option as $kinerity_option => $setting) + { + $option_id = $options[self::CATEGORY_PERMISSIONS[$kinerity_option]] ?? 0; + if ($option_id) + { + $grants[$type][$holder_id][$option_id] = $setting; + } + } + } + } + + return $grants; + } + + /** + * The Yes or Never each group and user holds board-wide for the given + * kinerity permissions, combining direct settings and roles. + * + * @param string[] $kinerity_options + * @return array{groups: array>, users: array>} + */ + protected function kinerity_settings(array $kinerity_options): array + { + $settings = ['groups' => [], 'users' => []]; + + $option_names = $this->acl_option_ids($kinerity_options); + if (!$option_names) + { + return $settings; + } + $option_names = array_flip($option_names); + + $role_settings = []; + $sql = 'SELECT role_id, auth_option_id, auth_setting + FROM ' . $this->table_prefix . 'acl_roles_data + WHERE ' . $this->db->sql_in_set('auth_option_id', array_keys($option_names)); + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $role_settings[(int) $row['role_id']][$option_names[(int) $row['auth_option_id']]] = (int) $row['auth_setting']; + } + $this->db->sql_freeresult($result); + + foreach (['groups' => 'group_id', 'users' => 'user_id'] as $type => $holder_column) + { + $sql = 'SELECT ' . $holder_column . ' AS holder_id, auth_option_id, auth_role_id, auth_setting + FROM ' . $this->table_prefix . 'acl_' . $type . ' + WHERE forum_id = 0 + AND (' . $this->db->sql_in_set('auth_option_id', array_keys($option_names)) . ' + OR auth_role_id <> 0)'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $holder_id = (int) $row['holder_id']; + $found = []; + if ((int) $row['auth_role_id']) + { + $found = $role_settings[(int) $row['auth_role_id']] ?? []; + } + else if (isset($option_names[(int) $row['auth_option_id']])) + { + $found = [$option_names[(int) $row['auth_option_id']] => (int) $row['auth_setting']]; + } + + foreach ($found as $option => $setting) + { + if ($setting === self::ACL_NEVER) + { + $settings[$type][$holder_id][$option] = self::ACL_NEVER; + } + else if ($setting === self::ACL_YES && !isset($settings[$type][$holder_id][$option])) + { + $settings[$type][$holder_id][$option] = self::ACL_YES; + } + } + } + $this->db->sql_freeresult($result); + } + + return $settings; + } + + /** + * Write a category's group and user permission rows. + * + * @param int $category_id + * @param array $grants From category_grants() + * @return void + */ + protected function insert_category_permissions(int $category_id, array $grants): void + { + foreach (['groups' => 'group_id', 'users' => 'user_id'] as $type => $holder_column) + { + $rows = []; + foreach ($grants[$type] as $holder_id => $options) + { + foreach ($options as $option_id => $setting) + { + $rows[] = [ + $holder_column => (int) $holder_id, + 'category_id' => $category_id, + 'auth_option_id' => (int) $option_id, + 'auth_setting' => (int) $setting, + ]; + } + } + + if ($rows) + { + $this->db->sql_multi_insert($this->table_prefix . 'kb_' . $type, $rows); + } + } + } + + /** + * Give everyone who had kinerity's read or admin permission (directly or + * in a role) the same setting on this extension's u_kb_view and + * a_manage_kb, where they have no setting for it yet. + * + * @return void + */ + protected function copy_global_permissions(): void + { + $ids = $this->acl_option_ids(array_merge(array_keys(self::GLOBAL_PERMISSIONS), array_values(self::GLOBAL_PERMISSIONS))); + $changed = false; + + foreach (self::GLOBAL_PERMISSIONS as $from => $to) + { + if (empty($ids[$from]) || empty($ids[$to])) + { + continue; + } + + foreach (['acl_groups' => 'group_id', 'acl_users' => 'user_id', 'acl_roles_data' => 'role_id'] as $table => $holder_column) + { + $is_role_table = $table === 'acl_roles_data'; + $scope = $is_role_table ? '' : ' AND forum_id = 0 AND auth_role_id = 0'; + + $existing = []; + $sql = 'SELECT ' . $holder_column . ' AS holder_id + FROM ' . $this->table_prefix . $table . ' + WHERE auth_option_id = ' . (int) $ids[$to] . $scope; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $existing[(int) $row['holder_id']] = true; + } + $this->db->sql_freeresult($result); + + $rows = []; + $sql = 'SELECT ' . $holder_column . ' AS holder_id, auth_setting + FROM ' . $this->table_prefix . $table . ' + WHERE auth_option_id = ' . (int) $ids[$from] . $scope; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $holder_id = (int) $row['holder_id']; + if (isset($existing[$holder_id])) + { + continue; + } + $existing[$holder_id] = true; + + $new_row = [$holder_column => $holder_id, 'auth_option_id' => (int) $ids[$to], 'auth_setting' => (int) $row['auth_setting']]; + if (!$is_role_table) + { + $new_row += ['forum_id' => 0, 'auth_role_id' => 0]; + } + $rows[] = $new_row; + } + $this->db->sql_freeresult($result); + + if ($rows) + { + $this->db->sql_multi_insert($this->table_prefix . $table, $rows); + $changed = true; + } + } + } + + if ($changed) + { + // Every user's cached permissions are rebuilt on their next page + $this->db->sql_query('UPDATE ' . $this->table_prefix . "users + SET user_permissions = '', + user_perm_from = 0"); + } + } + + /** + * Set every category's article count from its articles, as the ACP's + * resynchronise does. + * + * @return void + */ + protected function update_article_counts(): void + { + $counts = []; + $sql = 'SELECT article_category_id, COUNT(article_id) AS articles + FROM ' . $this->table_prefix . 'kb_articles + GROUP BY article_category_id'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $counts[(int) $row['article_category_id']] = (int) $row['articles']; + } + $this->db->sql_freeresult($result); + + $sql = 'SELECT category_id, number_articles + FROM ' . $this->table_prefix . 'kb_categories'; + $result = $this->db->sql_query($sql); + $categories = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + foreach ($categories as $row) + { + $count = min($counts[(int) $row['category_id']] ?? 0, self::MAX_ARTICLE_COUNT); + if ($count !== (int) $row['number_articles']) + { + $this->db->sql_query('UPDATE ' . $this->table_prefix . 'kb_categories + SET number_articles = ' . $count . ' + WHERE category_id = ' . (int) $row['category_id']); + } + } + } + + /** + * Remove kinerity's notification types with the notifications and user + * settings that use them; its classes are gone, so phpBB could not show them. + * + * @return void + */ + protected function remove_notification_types(): void + { + $type_ids = []; + $sql = 'SELECT notification_type_id + FROM ' . $this->table_prefix . 'notification_types + WHERE ' . $this->db->sql_in_set('notification_type_name', self::KINERITY_NOTIFICATION_TYPES); + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $type_ids[] = (int) $row['notification_type_id']; + } + $this->db->sql_freeresult($result); + + if ($type_ids) + { + $this->db->sql_query('DELETE FROM ' . $this->table_prefix . 'notifications + WHERE ' . $this->db->sql_in_set('notification_type_id', $type_ids)); + $this->db->sql_query('DELETE FROM ' . $this->table_prefix . 'notification_types + WHERE ' . $this->db->sql_in_set('notification_type_id', $type_ids)); + } + + $this->db->sql_query('DELETE FROM ' . $this->table_prefix . 'user_notifications + WHERE ' . $this->db->sql_in_set('item_type', self::KINERITY_NOTIFICATION_TYPES)); + } + + /** + * This extension's per-category option ids by name. + * + * @return array + */ + protected function kb_option_ids(): array + { + $ids = []; + $sql = 'SELECT auth_option_id, auth_option + FROM ' . $this->table_prefix . 'kb_options'; + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $ids[$row['auth_option']] = (int) $row['auth_option_id']; + } + $this->db->sql_freeresult($result); + + return $ids; + } + + /** + * phpBB permission option ids by name, for the names that exist. + * + * @param string[] $names + * @return array + */ + protected function acl_option_ids(array $names): array + { + $ids = []; + $sql = 'SELECT auth_option_id, auth_option + FROM ' . $this->table_prefix . 'acl_options + WHERE ' . $this->db->sql_in_set('auth_option', $names); + $result = $this->db->sql_query($sql); + while ($row = $this->db->sql_fetchrow($result)) + { + $ids[$row['auth_option']] = (int) $row['auth_option_id']; + } + $this->db->sql_freeresult($result); + + return $ids; + } + + /** + * @param string $table + * @return int + */ + protected function count_rows(string $table): int + { + $result = $this->db->sql_query('SELECT COUNT(*) AS total FROM ' . $table); + $total = (int) $this->db->sql_fetchfield('total'); + $this->db->sql_freeresult($result); + + return $total; + } + + /** + * @param string $table + * @param string $column + * @return int The column's largest value, 0 for an empty table + */ + protected function max_value(string $table, string $column): int + { + $result = $this->db->sql_query('SELECT MAX(' . $column . ') AS max_value FROM ' . $table); + $max = (int) $this->db->sql_fetchfield('max_value'); + $this->db->sql_freeresult($result); + + return $max; + } +} diff --git a/language/en/install_failed.php b/language/en/install_failed.php index 84a74e7..a1134df 100644 --- a/language/en/install_failed.php +++ b/language/en/install_failed.php @@ -23,4 +23,5 @@ $lang = array_merge($lang, [ 'INSTALL_FAILED_VERSION' => 'The Knowledge Base extension does not meet the minimum requirements

phpBB minimum version %1s - you are using %2s
PHP minimum version %3s - you are using %4s', 'INSTALL_FAILED_ORIGINAL_ENABLED' => 'The original %1$s extension is still enabled

It uses the same database tables and pages as this Knowledge Base, so both cannot be enabled together. Disable %1$s first (Customise » Manage extensions), then enable this extension: it takes over the articles, categories, permissions and settings the original created.', + 'INSTALL_FAILED_KINERITY_ENABLED' => 'The %1$s extension is still enabled

It uses the same database table names and pages as this Knowledge Base, so both cannot be enabled together. Disable %1$s first (Customise » Manage extensions), without deleting its data, then enable this extension: it imports the categories, articles and permissions %1$s created.', ]); diff --git a/migrations/kinerity_tables_set_aside.php b/migrations/kinerity_tables_set_aside.php new file mode 100644 index 0000000..b106db5 --- /dev/null +++ b/migrations/kinerity_tables_set_aside.php @@ -0,0 +1,47 @@ +db, $this->db_tools, $this->table_prefix))->set_aside(); + } +} diff --git a/migrations/version_1_0_0.php b/migrations/version_1_0_0.php index 7822803..3c77fdb 100644 --- a/migrations/version_1_0_0.php +++ b/migrations/version_1_0_0.php @@ -16,7 +16,12 @@ class version_1_0_0 extends \phpbb\db\migration\migration { public static function depends_on() { - return ['\phpbb\db\migration\data\v310\dev']; + // kinerity_tables_set_aside moves the kinerity/knowledgebase tables + // that share this extension's table names out of the way first + return [ + '\phpbb\db\migration\data\v310\dev', + '\phpbbmodders\knowledgebase\migrations\kinerity_tables_set_aside', + ]; } /** diff --git a/migrations/version_2_0_32.php b/migrations/version_2_0_32.php new file mode 100644 index 0000000..44baf5f --- /dev/null +++ b/migrations/version_2_0_32.php @@ -0,0 +1,118 @@ +importer()->import_categories($state); + } + + /** + * @param mixed $state + * @return array|true + */ + public function import_articles($state) + { + return $this->importer()->import_articles($state); + } + + public function finish_import() + { + $this->importer()->finish($this->config); + } + + /** + * @return bool Whether this board ran kinerity/knowledgebase + */ + public function has_kinerity_data() + { + return $this->importer()->has_backup(); + } + + public function reset_import_markers() + { + $this->importer()->reset_import_markers(); + } + + /** + * @return kinerity_import + */ + protected function importer() + { + return new kinerity_import($this->db, $this->db_tools, $this->table_prefix); + } +} diff --git a/tests/board/kinerity_import_check.php b/tests/board/kinerity_import_check.php new file mode 100644 index 0000000..1e1a250 --- /dev/null +++ b/tests/board/kinerity_import_check.php @@ -0,0 +1,380 @@ + [], 'group' => []]; +for ($i = 2; $i < count($argv); $i += 2) +{ + if (!in_array($argv[$i], ['--expect-member', '--expect-group'], true) || !isset($argv[$i + 1]) || substr_count($argv[$i + 1], ':') !== 2) + { + fwrite(STDERR, "Unknown or incomplete argument: {$argv[$i]}\n"); + exit(2); + } + [$name, $option, $setting] = explode(':', $argv[$i + 1]); + $expectations[$argv[$i] === '--expect-member' ? 'member' : 'group'][] = [$name, $option, (int) $setting]; +} + +$phpEx = 'php'; +define('IN_PHPBB', true); +chdir($phpbb_root_path); +require($phpbb_root_path . 'includes/startup.php'); +require($phpbb_root_path . 'phpbb/class_loader.php'); +(new \phpbb\class_loader('phpbb\\', "{$phpbb_root_path}phpbb/", $phpEx))->register(); +$phpbb_config_php_file = new \phpbb\config_php_file($phpbb_root_path, $phpEx); +extract($phpbb_config_php_file->get_all()); +if (!defined('PHPBB_ENVIRONMENT')) +{ + define('PHPBB_ENVIRONMENT', 'production'); +} +require($phpbb_root_path . 'includes/constants.php'); +require($phpbb_root_path . 'includes/functions.php'); +require($phpbb_root_path . 'includes/functions_content.php'); +require($phpbb_root_path . 'includes/utf/utf_tools.php'); +(new \phpbb\class_loader('\\', "{$phpbb_root_path}ext/", $phpEx))->register(); +$phpbb_container = (new \phpbb\di\container_builder($phpbb_root_path, $phpEx))->with_config($phpbb_config_php_file)->get_container(); + +use phpbbmodders\knowledgebase\inc\functions_kb; +use phpbbmodders\knowledgebase\inc\kinerity_import; + +/** @var \phpbb\db\driver\driver_interface $db */ +$db = $phpbb_container->get('dbal.conn'); +$tools = $phpbb_container->get('dbal.tools'); +$p = $table_prefix; + +$failures = 0; +/** + * Print one check's result. + */ +function check(string $name, bool $passed, string $detail = ''): void +{ + global $failures; + if (!$passed) + { + $failures++; + } + echo ($passed ? 'PASS ' : 'FAIL ') . $name . ($detail !== '' ? ' - ' . $detail : '') . "\n"; +} + +/** + * All rows of a query, streamed into an array keyed by $key when given. + */ +function rows(string $sql, ?string $key = null): array +{ + global $db; + $result = $db->sql_query($sql); + $rows = []; + while ($row = $db->sql_fetchrow($result)) + { + if ($key === null) + { + $rows[] = $row; + } + else + { + $rows[(int) $row[$key]] = $row; + } + } + $db->sql_freeresult($result); + + return $rows; +} + +// Tables +check('kinerity tables moved aside', !$tools->sql_table_exists($p . 'kb_article_category') + && !$tools->sql_column_exists($p . 'kb_articles', 'article_poster_id') + && !$tools->sql_column_exists($p . 'kb_categories', 'category_description')); +check('backup tables kept', $tools->sql_table_exists($p . 'kb_kinerity_articles') && $tools->sql_table_exists($p . 'kb_kinerity_categories') && $tools->sql_table_exists($p . 'kb_kinerity_article_category')); +if (!$tools->sql_table_exists($p . 'kb_kinerity_articles')) +{ + echo "No backup tables; nothing more to compare.\n"; + exit(1); +} + +$unfinished = rows('SELECT migration_name FROM ' . MIGRATIONS_TABLE . " + WHERE migration_name LIKE '%phpbbmodders%knowledgebase%' AND (migration_schema_done = 0 OR migration_data_done = 0)"); +check('every Knowledge Base migration finished', !$unfinished, $unfinished ? implode(', ', array_column($unfinished, 'migration_name')) : ''); + +// Categories +$backup_categories = rows('SELECT * FROM ' . $p . 'kb_kinerity_categories', 'category_id'); +$categories = rows('SELECT * FROM ' . $p . 'kb_categories', 'category_id'); +$map = []; +foreach ($backup_categories as $old_id => $row) +{ + $map[$old_id] = (int) $row['kb_new_id']; +} +check('every kinerity category converted once', !in_array(0, $map, true) && count(array_unique($map)) === count($map) && !array_diff($map, array_keys($categories)), + count($backup_categories) . ' kinerity, ' . count($categories) . ' now'); + +$fallback = array_keys(array_filter($categories, fn($row) => $row['category_name'] === kinerity_import::FALLBACK_CATEGORY_NAME && !in_array((int) $row['category_id'], $map, true))); +$extra = array_diff(array_keys($categories), $map, $fallback); +check('no unexpected categories', !$extra, $extra ? 'ids ' . implode(', ', array_slice($extra, 0, 10)) : (count($fallback) . ' fallback category')); + +// Parents from the kinerity tree, as the import works them out +$sorted = $backup_categories; +uasort($sorted, fn($a, $b) => [(int) $a['left_id'], (int) $a['category_id']] <=> [(int) $b['left_id'], (int) $b['category_id']]); +$stack = []; +$tree_errors = $text_errors = []; +foreach ($sorted as $old_id => $row) +{ + while ($stack && (int) end($stack)['right_id'] < (int) $row['left_id']) + { + array_pop($stack); + } + $expected_parent = $stack ? $map[(int) end($stack)['category_id']] : 0; + $stack[] = $row; + + $new = $categories[$map[$old_id]]; + if ((int) $new['parent_id'] !== $expected_parent || (int) $new['left_id'] !== (int) $row['left_id'] || (int) $new['right_id'] !== (int) $row['right_id']) + { + $tree_errors[] = $old_id; + } + $expected_details = kinerity_import::category_details((string) $row['category_description']); + if ($new['category_name'] !== kinerity_import::fit_escaped((string) $row['category_name'], 255) || $new['category_details'] !== $expected_details) + { + $text_errors[] = $old_id; + } +} +check('category tree (parent, left, right) as in kinerity', !$tree_errors, $tree_errors ? 'kinerity ids ' . implode(', ', array_slice($tree_errors, 0, 10)) : ''); +check('category names and plain-text details', !$text_errors, $text_errors ? 'kinerity ids ' . implode(', ', array_slice($text_errors, 0, 10)) : ''); + +$nested_errors = []; +foreach ($categories as $id => $row) +{ + if ((int) $row['left_id'] >= (int) $row['right_id']) + { + $nested_errors[] = $id; + continue; + } + if ((int) $row['parent_id']) + { + $parent = $categories[(int) $row['parent_id']] ?? null; + if (!$parent || (int) $row['left_id'] <= (int) $parent['left_id'] || (int) $row['right_id'] >= (int) $parent['right_id']) + { + $nested_errors[] = $id; + } + } +} +check('nested set consistent', !$nested_errors, $nested_errors ? 'ids ' . implode(', ', array_slice($nested_errors, 0, 10)) : ''); +$long = array_filter($categories, fn($row) => mb_strlen($row['category_details']) > 255 || mb_strlen($row['category_name']) > 255); +check('category text fits its columns', !$long); + +// Articles +$links = []; +$result = $db->sql_query('SELECT article_id, category_id FROM ' . $p . 'kb_kinerity_article_category'); +while ($row = $db->sql_fetchrow($result)) +{ + if (!empty($map[(int) $row['category_id']])) + { + $links[(int) $row['article_id']][] = (int) $row['category_id']; + } +} +$db->sql_freeresult($result); + +$cross = []; +$result = $db->sql_query('SELECT article_id, category_id FROM ' . $p . 'kb_article_categories'); +while ($row = $db->sql_fetchrow($result)) +{ + $cross[(int) $row['article_id']][] = (int) $row['category_id']; +} +$db->sql_freeresult($result); + +$backup_count = (int) rows('SELECT COUNT(*) AS total FROM ' . $p . 'kb_kinerity_articles')[0]['total']; +$article_count = (int) rows('SELECT COUNT(*) AS total FROM ' . $p . 'kb_articles')[0]['total']; +check('article count matches', $backup_count === $article_count, $backup_count . ' kinerity, ' . $article_count . ' now'); + +$errors = ['unconverted' => [], 'duplicate' => [], 'category' => [], 'cross-listing' => [], 'state' => [], 'content' => []]; +$seen = []; +$primary_counts = []; +$sql = 'SELECT k.*, a.article_id AS new_id, a.article_category_id, a.approved, a.status, a.article_title AS new_title, + a.article_description AS new_description, a.article_body, a.bbcode_uid AS new_uid, a.article_date, a.edit_date, + a.author_id, a.author, a.author_type, a.views + FROM ' . $p . 'kb_kinerity_articles k + LEFT JOIN ' . $p . 'kb_articles a ON (a.article_id = k.kb_new_id) + ORDER BY k.article_id'; +$result = $db->sql_query($sql); +while ($row = $db->sql_fetchrow($result)) +{ + $old_id = (int) $row['article_id']; + if (!(int) $row['kb_new_id'] || $row['new_id'] === null) + { + $errors['unconverted'][] = $old_id; + continue; + } + $new_id = (int) $row['new_id']; + if (isset($seen[$new_id])) + { + $errors['duplicate'][] = $old_id; + } + $seen[$new_id] = true; + + $old_categories = array_values(array_unique(array_map(fn($id) => $map[$id], $links[$old_id] ?? []))); + sort($old_categories); + // The lowest kinerity category id, which is not always the lowest new id + $by_old = $links[$old_id] ?? []; + sort($by_old); + $expected_primary = $by_old ? $map[$by_old[0]] : ($fallback[0] ?? -1); + if ((int) $row['article_category_id'] !== $expected_primary) + { + $errors['category'][] = $old_id; + } + $expected_cross = array_values(array_diff($old_categories, [$expected_primary])); + $actual_cross = $cross[$new_id] ?? []; + sort($actual_cross); + if ($expected_cross !== $actual_cross) + { + $errors['cross-listing'][] = $old_id; + } + + [$approved, $status] = kinerity_import::map_visibility((int) $row['article_visibility']); + if ((int) $row['approved'] !== $approved || (int) $row['status'] !== $status) + { + $errors['state'][] = $old_id; + } + + if ($row['new_title'] !== kinerity_import::fit_escaped((string) $row['article_title'], 255) + || $row['new_description'] !== kinerity_import::fit_escaped((string) $row['article_description'], 255) + || $row['article_body'] !== $row['article_text'] + || $row['new_uid'] !== (string) $row['bbcode_uid'] + || (int) $row['article_date'] !== (int) $row['article_time'] + || (int) $row['edit_date'] !== (int) $row['article_edit_time'] + || (int) $row['author_id'] !== (int) $row['article_poster_id'] + || $row['author'] !== $row['article_poster_name'] + || (int) $row['author_type'] !== functions_kb::AUTHOR_TYPE_USER + || (int) $row['views'] !== (int) $row['article_views']) + { + $errors['content'][] = $old_id; + } + + $primary_counts[(int) $row['article_category_id']] = ($primary_counts[(int) $row['article_category_id']] ?? 0) + 1; +} +$db->sql_freeresult($result); + +foreach ($errors as $label => $ids) +{ + check('articles: ' . $label, !$ids, $ids ? count($ids) . ', kinerity ids ' . implode(', ', array_slice($ids, 0, 10)) : ''); +} +$stray = array_diff_key($cross, $seen); +check('no cross-listings for articles that were not imported', !$stray); + +$count_errors = []; +foreach ($categories as $id => $row) +{ + if ((int) $row['number_articles'] !== min($primary_counts[$id] ?? 0, kinerity_import::MAX_ARTICLE_COUNT)) + { + $count_errors[] = $id . ' (' . $row['number_articles'] . ' vs ' . ($primary_counts[$id] ?? 0) . ')'; + } +} +check('article count per category', !$count_errors, $count_errors ? implode(', ', array_slice($count_errors, 0, 10)) : ''); + +// Permissions: every category gets the same rows +foreach (['groups' => 'group_id', 'users' => 'user_id'] as $type => $column) +{ + $sets = []; + $result = $db->sql_query('SELECT ' . $column . ' AS holder, category_id, auth_option_id, auth_setting FROM ' . $p . 'kb_' . $type); + while ($row = $db->sql_fetchrow($result)) + { + $sets[(int) $row['category_id']][] = $row['holder'] . ':' . $row['auth_option_id'] . ':' . $row['auth_setting']; + } + $db->sql_freeresult($result); + $signatures = []; + foreach (array_keys($categories) as $id) + { + $set = $sets[$id] ?? []; + sort($set); + $signatures[implode(',', $set)] = true; + } + check("$type permissions identical on every category", count($signatures) === 1, count($signatures) . ' distinct set(s), ' . count($sets[array_key_first($categories)] ?? []) . ' rows each'); +} + +$options = []; +foreach (rows('SELECT auth_option_id, auth_option FROM ' . $p . 'kb_options') as $row) +{ + $options[$row['auth_option']] = (int) $row['auth_option_id']; +} +foreach (['member' => ['kb_users', 'user_id', USERS_TABLE, 'username_clean', 'user_id'], 'group' => ['kb_groups', 'group_id', GROUPS_TABLE, 'group_name', 'group_id']] as $kind => [$table, $column, $holder_table, $name_column, $id_column]) +{ + foreach ($expectations[$kind] as [$name, $option, $setting]) + { + $holder = rows('SELECT ' . $id_column . ' AS id FROM ' . $holder_table . " WHERE " . $name_column . " = '" . $db->sql_escape($kind === 'member' ? utf8_clean_string($name) : $name) . "'"); + $count = 0; + if ($holder && isset($options[$option])) + { + $count = (int) rows('SELECT COUNT(*) AS total FROM ' . $p . $table . ' WHERE ' . $column . ' = ' . (int) $holder[0]['id'] . ' + AND auth_option_id = ' . $options[$option] . ' AND auth_setting = ' . $setting)[0]['total']; + } + check("$kind $name has $option = $setting on every category", $count === count($categories), $count . ' of ' . count($categories)); + } +} + +// kinerity unregistered +$kinerity_options = rows('SELECT auth_option FROM ' . ACL_OPTIONS_TABLE . ' WHERE ' . $db->sql_in_set('auth_option', kinerity_import::KINERITY_PERMISSIONS)); +check('kinerity permissions removed', !$kinerity_options, implode(', ', array_column($kinerity_options, 'auth_option'))); +$orphans = 0; +foreach ([ACL_GROUPS_TABLE, ACL_USERS_TABLE, ACL_ROLES_DATA_TABLE] as $table) +{ + $orphans += (int) rows('SELECT COUNT(*) AS total FROM ' . $table . ' t LEFT JOIN ' . ACL_OPTIONS_TABLE . ' o ON (o.auth_option_id = t.auth_option_id) + WHERE t.auth_option_id <> 0 AND o.auth_option_id IS NULL')[0]['total']; +} +check('no permission settings left for removed options', $orphans === 0, (string) $orphans); +foreach (['u_kb_view', 'a_manage_kb'] as $option) +{ + $id = rows('SELECT auth_option_id FROM ' . ACL_OPTIONS_TABLE . " WHERE auth_option = '" . $option . "'"); + $granted = $id ? (int) rows('SELECT COUNT(*) AS total FROM ' . ACL_ROLES_DATA_TABLE . ' WHERE auth_setting = 1 AND auth_option_id = ' . (int) $id[0]['auth_option_id'])[0]['total'] + + (int) rows('SELECT COUNT(*) AS total FROM ' . ACL_GROUPS_TABLE . ' WHERE auth_setting = 1 AND auth_option_id = ' . (int) $id[0]['auth_option_id'])[0]['total'] : 0; + check("$option granted somewhere", $granted > 0, $granted . ' role/group setting(s)'); +} + +$modules = rows('SELECT module_langname, module_basename FROM ' . MODULES_TABLE . " + WHERE module_langname IN ('ACP_KNOWLEDGEBASE', 'ACP_KNOWLEDGEBASE_SETTINGS', 'ACP_KNOWLEDGEBASE_MANAGE') OR module_basename LIKE '%kinerity%'"); +check('kinerity ACP modules removed', !$modules, implode(', ', array_column($modules, 'module_langname'))); +$ours = (int) rows('SELECT COUNT(*) AS total FROM ' . MODULES_TABLE . " WHERE module_basename LIKE '%phpbbmodders%knowledgebase%'")[0]['total']; +check('Knowledge Base ACP modules present', $ours > 0, $ours . ' modules'); + +check('kinerity extension entry removed', !rows('SELECT ext_name FROM ' . EXT_TABLE . " WHERE ext_name = 'kinerity/knowledgebase'")); +check('kinerity migration records kept', (bool) rows('SELECT migration_name FROM ' . MIGRATIONS_TABLE . " WHERE migration_name LIKE '%kinerity%'")); +$settings = rows('SELECT config_name FROM ' . CONFIG_TABLE . ' WHERE ' . $db->sql_in_set('config_name', kinerity_import::KINERITY_CONFIG)); +check('kinerity settings removed', !$settings, implode(', ', array_column($settings, 'config_name'))); +$types = rows('SELECT notification_type_name FROM ' . NOTIFICATION_TYPES_TABLE . " WHERE notification_type_name LIKE 'kinerity.%'"); +$user_settings = rows('SELECT item_type FROM ' . USER_NOTIFICATIONS_TABLE . " WHERE item_type LIKE 'kinerity.%'"); +check('kinerity notification types and settings removed', !$types && !$user_settings); + +echo $failures ? "{$failures} check(s) failed.\n" : "All checks passed.\n"; +exit($failures ? 1 : 0); diff --git a/tests/browser/kb_kinerity_import.js b/tests/browser/kb_kinerity_import.js new file mode 100644 index 0000000..8cfbe4a --- /dev/null +++ b/tests/browser/kb_kinerity_import.js @@ -0,0 +1,173 @@ +// Browser checks on a board just moved from kinerity/knowledgebase to this +// extension (seed-forum's kinerity-33/-40 boards, after the switch and +// tests/board/kinerity_import_check.php). Looks at what a member and the admin +// see: guests refused as kinerity refused them, the imported tree and articles, +// BBCode and special characters, a cross-listed article in both categories, +// deactivated articles hidden from members, the approval queue, the ACP pages, +// and search after the index is built. +// +// Environment: KB_TEST_URL (board URL), KB_ARTIFACT_DIR (screenshots and +// results), KB_ADMIN_USER / KB_ADMIN_PASSWORD and KB_MEMBER_USER (seed-forum's +// public fixture admin and forum_alex by default), KB_IDS (JSON from the board: +// tests/browser/kb_kinerity_import_ids.php prints it). +// KB_IDS holds new article ids picked from the backup tables: +// {"approved": N, "cross": N, "cross_second_category": N, "waiting": N, +// "deleted": N, "special": N, "special_title": "..."} +// Run: node /run.js tests/browser/kb_kinerity_import.js +const { chromium } = require('playwright'); +const { expect } = require('playwright/test'); +const fs = require('node:fs'); +const path = require('node:path'); + +const base = (process.env.KB_TEST_URL || 'http://localhost:8161').replace(/\/$/, ''); +const output = process.env.KB_ARTIFACT_DIR || '/tmp/kb-kinerity-import'; +const adminUser = process.env.KB_ADMIN_USER || 'admin'; +const adminPassword = process.env.KB_ADMIN_PASSWORD || 'KbTest1234!'; +const memberUser = process.env.KB_MEMBER_USER || 'forum_alex'; +const ids = JSON.parse(process.env.KB_IDS || '{}'); +const results = []; +fs.mkdirSync(output, { recursive: true }); + +const article = id => base + '/app.php/knowledgebase/article?k=' + id; +const category = id => base + '/app.php/knowledgebase/category?id=' + id; + +async function check(name, page, action) { + try { + await action(); + results.push({ name, status: 'PASS' }); + console.log('PASS', name); + } catch (error) { + results.push({ name, status: 'FAIL', error: error.message.split('\n')[0] }); + console.error('FAIL', name, error.message.split('\n')[0]); + process.exitCode = 1; + } + await page.screenshot({ path: path.join(output, String(results.length).padStart(2, '0') + '.png'), fullPage: false }); + fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify(results, null, 2)); +} + +// phpBB refuses a form submitted within a second of its creation_time +async function ready(page) { + await page.waitForFunction(() => { + const input = document.querySelector('input[name="creation_time"]'); + return !input || Math.floor(Date.now() / 1000) > Number(input.value) + 1; + }); +} + +async function login(page, user = adminUser) { + await page.goto(base + '/ucp.php?mode=login'); + await page.fill('#username', user); + await page.fill('#password', adminPassword); + await ready(page); + await page.click('input[name="login"]'); + await expect(page.locator('a[href*="mode=logout"]').first()).toBeAttached(); +} + +// The real ACP link, then the re-authentication phpBB asks for +async function openAcp(page) { + await page.goto(base + '/index.php'); + await page.locator('a[href*="adm/index.php"]').first().click(); + if (await page.locator('input[name^="password_"]').count()) { + await page.locator('input[name^="password_"]').fill(adminPassword); + await ready(page); + await page.click('input[name="login"]'); + } + await expect(page.locator('#acp')).toBeVisible(); +} + +(async () => { + const browser = await chromium.launch(); + const guest = await (await browser.newContext()).newPage(); + const member = await (await browser.newContext()).newPage(); + const admin = await (await browser.newContext()).newPage(); + const errors = []; + for (const page of [guest, member, admin]) { + page.on('pageerror', error => errors.push(error.message)); + } + + // kinerity gave guests no read permission, so neither does the import + await check('guests are refused, as they were in kinerity', guest, async () => { + await guest.goto(base + '/app.php/knowledgebase'); + await expect(guest.locator('body')).not.toContainText('Article ' + ids.approved_number); + }); + + await login(member, memberUser); + + await check('a member sees the imported articles and sections', member, async () => { + await member.goto(base + '/app.php/knowledgebase'); + await expect(member.locator('body')).toContainText('Article'); + await expect(member.locator('select, body').first()).toContainText('Section 1'); + await expect(member.locator('body')).not.toContainText('General Error'); + }); + + await check('an approved article shows its BBCode as formatting', member, async () => { + await member.goto(article(ids.approved)); + await expect(member.locator('.content').first()).toBeVisible(); + expect(await member.locator('.content').first().innerHTML()).toMatch(/<(strong|b|ol|li|blockquote|code|a|span)[\s>]/); + await expect(member.locator('.content').first()).not.toContainText('[b]'); + }); + + await check('special characters show once, not double-escaped', member, async () => { + await member.goto(article(ids.special)); + await expect(member.locator('body')).toContainText(ids.special_title); + await expect(member.locator('body')).not.toContainText('&'); + await expect(member.locator('body')).not.toContainText('"'); + }); + + await check('a cross-listed article is listed in its other category', member, async () => { + await member.goto(category(ids.cross_second_category)); + await expect(member.locator('a[href*="k=' + ids.cross + '"]').first()).toBeAttached(); + }); + + await check('members cannot open a deactivated (kinerity deleted) article', member, async () => { + await member.goto(article(ids.deleted)); + await expect(member.locator('.content').filter({ hasText: 'Body' })).toHaveCount(0); + }); + + await login(admin); + + await check('articles waiting in kinerity are in the approval queue as New', admin, async () => { + await admin.goto(base + '/app.php/knowledgebase/queue'); + await expect(admin.locator('body')).toContainText('New'); + await admin.goto(article(ids.waiting)); + await expect(admin.locator('body')).toContainText('New'); + }); + + await openAcp(admin); + const acpLinks = ['ACP_LIBRARY_MANAGE', 'ACP_LIBRARY_ARTICLES', 'ACP_LIBRARY_PERMISSIONS', 'ACP_LIBRARY_SEARCH']; + const sid = new URL(admin.url()).searchParams.get('sid'); + for (const mode of [['manage_module', 'manage'], ['articles_module', 'articles'], ['permissions_module', 'permissions'], ['search_module', 'index']]) { + await check('ACP page loads: ' + mode[1], admin, async () => { + await admin.goto(base + '/adm/index.php?i=-phpbbmodders-knowledgebase-acp-' + mode[0] + '&mode=' + mode[1] + '&sid=' + sid); + await expect(admin.locator('#main')).toBeVisible(); + await expect(admin.locator('body')).not.toContainText('General Error'); + await expect(admin.locator('body')).not.toContainText('SQL ERROR'); + }); + } + void acpLinks; + + await check('no kinerity module left in the ACP menu', admin, async () => { + await admin.goto(base + '/adm/index.php?i=acp_extensions&mode=main&sid=' + sid); + await expect(admin.locator('#menu')).not.toContainText('ACP_KNOWLEDGEBASE'); + await expect(admin.locator('body')).not.toContainText('kinerity'); + }); + + await check('creating the search index, then searching, finds an imported article', admin, async () => { + await admin.goto(base + '/adm/index.php?i=-phpbbmodders-knowledgebase-acp-search_module&mode=index&sid=' + sid); + const create = admin.locator('input[name="action[create]"], input[value*="Create index"]').first(); + if (await create.count()) { + await ready(admin); + await create.click(); + // Indexing runs over several page loads; wait for it to finish + await admin.waitForFunction(() => !document.querySelector('meta[http-equiv="refresh"]'), null, { timeout: 600000, polling: 2000 }); + } + await admin.goto(base + '/app.php/knowledgebase/search?keywords=' + encodeURIComponent('Article ' + ids.approved_number)); + await expect(admin.locator('a[href*="k="]').first()).toBeAttached(); + }); + + await check('no JavaScript errors', admin, async () => { + expect(errors).toEqual([]); + }); + + await browser.close(); + console.log(results.filter(r => r.status === 'PASS').length + '/' + results.length + ' checks passed'); +})(); diff --git a/tests/browser/kb_kinerity_import_ids.php b/tests/browser/kb_kinerity_import_ids.php new file mode 100644 index 0000000..3e9e509 --- /dev/null +++ b/tests/browser/kb_kinerity_import_ids.php @@ -0,0 +1,78 @@ +register(); +$phpbb_config_php_file = new \phpbb\config_php_file($phpbb_root_path, $phpEx); +extract($phpbb_config_php_file->get_all()); +if (!defined('PHPBB_ENVIRONMENT')) +{ + define('PHPBB_ENVIRONMENT', 'production'); +} +require($phpbb_root_path . 'includes/constants.php'); +require($phpbb_root_path . 'includes/functions.php'); +$db = (new \phpbb\di\container_builder($phpbb_root_path, $phpEx))->with_config($phpbb_config_php_file)->get_container()->get('dbal.conn'); +$p = $table_prefix; + +/** + * The first row of a query (limited to one). + */ +function first(string $sql): array +{ + global $db; + $result = $db->sql_query_limit($sql, 1); + $row = $db->sql_fetchrow($result) ?: []; + $db->sql_freeresult($result); + + return $row; +} + +// An approved article whose text has BBCode (parsed text with markup starts with ) +$approved = first('SELECT k.kb_new_id, k.article_title FROM ' . $p . "kb_kinerity_articles k + WHERE k.article_visibility = 1 AND k.article_id > 1 AND k.article_text LIKE '%' ORDER BY k.article_id"); +$cross = first('SELECT article_id, category_id FROM ' . $p . 'kb_article_categories ORDER BY article_id'); +$waiting = first('SELECT kb_new_id FROM ' . $p . 'kb_kinerity_articles WHERE article_visibility = 0 ORDER BY article_id'); +$deleted = first('SELECT kb_new_id FROM ' . $p . 'kb_kinerity_articles WHERE article_visibility = 2 ORDER BY article_id'); +$special = first('SELECT a.article_id, a.article_title FROM ' . $p . 'kb_kinerity_articles k JOIN ' . $p . "kb_articles a ON (a.article_id = k.kb_new_id) + WHERE k.article_visibility = 1 AND k.article_title LIKE '%&%' ORDER BY k.article_id"); + +echo json_encode([ + 'approved' => (int) ($approved['kb_new_id'] ?? 0), + 'approved_number' => (int) preg_replace('/\D/', '', $approved['article_title'] ?? ''), + 'cross' => (int) ($cross['article_id'] ?? 0), + 'cross_second_category' => (int) ($cross['category_id'] ?? 0), + 'waiting' => (int) ($waiting['kb_new_id'] ?? 0), + 'deleted' => (int) ($deleted['kb_new_id'] ?? 0), + 'special' => (int) ($special['article_id'] ?? 0), + 'special_title' => html_entity_decode($special['article_title'] ?? '', ENT_QUOTES, 'UTF-8'), +]) . "\n"; diff --git a/tests/kb_ext_enableable_test.php b/tests/kb_ext_enableable_test.php index 07516ad..6cb1b1f 100644 --- a/tests/kb_ext_enableable_test.php +++ b/tests/kb_ext_enableable_test.php @@ -18,12 +18,12 @@ class kb_ext_enableable_test extends \PHPUnit\Framework\TestCase { - private function ext(bool $original_enabled): ext + private function ext(bool $original_enabled, bool $kinerity_enabled = false): ext { $language = $this->getMockBuilder(\phpbb\language\language::class)->disableOriginalConstructor()->getMock(); $language->method('lang')->willReturnCallback(fn() => implode('|', func_get_args())); $manager = $this->getMockBuilder(\phpbb\extension\manager::class)->disableOriginalConstructor()->getMock(); - $manager->method('is_enabled')->willReturnCallback(fn($name) => $original_enabled && $name === 'sheer/knowledgebase'); + $manager->method('is_enabled')->willReturnCallback(fn($name) => ($original_enabled && $name === 'sheer/knowledgebase') || ($kinerity_enabled && $name === 'kinerity/knowledgebase')); $container = $this->createMock(\Symfony\Component\DependencyInjection\ContainerInterface::class); $container->method('get')->willReturnCallback(fn($id) => ['language' => $language, 'ext.manager' => $manager][$id]); @@ -51,4 +51,17 @@ public function test_it_refuses_while_the_original_package_is_enabled_and_says_w { $this->assertSame(['INSTALL_FAILED_ORIGINAL_ENABLED|sheer/knowledgebase'], $this->ext(true)->is_enableable()); } + + public function test_it_refuses_while_kinerity_knowledgebase_is_enabled_and_says_which() + { + $this->assertSame(['INSTALL_FAILED_KINERITY_ENABLED|kinerity/knowledgebase'], $this->ext(false, true)->is_enableable()); + } + + public function test_it_lists_both_refusals_when_both_packages_are_enabled() + { + $this->assertSame( + ['INSTALL_FAILED_ORIGINAL_ENABLED|sheer/knowledgebase', 'INSTALL_FAILED_KINERITY_ENABLED|kinerity/knowledgebase'], + $this->ext(true, true)->is_enableable() + ); + } } diff --git a/tests/kb_kinerity_import_test.php b/tests/kb_kinerity_import_test.php new file mode 100644 index 0000000..64a0ccf --- /dev/null +++ b/tests/kb_kinerity_import_test.php @@ -0,0 +1,843 @@ +raced) + { + $this->raced = true; + $other = new kinerity_import($this->db, $this->db_tools, $this->table_prefix); + while ($other->import_categories(0) !== true); + while ($other->import_articles(0) !== true); + } + + return parent::claim($table, $column, $id); + } +} + +/** + * Importing a board's kinerity/knowledgebase data: moving its tables aside, + * converting categories, articles and permissions into this extension's real + * schema (built from its own migrations), and cleaning up after kinerity. + */ +class kb_kinerity_import_test extends \PHPUnit\Framework\TestCase +{ + private $db; + private $tools; + private $sqlite_fixture; + + /** kinerity acl option ids used by the fixture */ + private const OPTIONS = [ + 'u_kb_read' => 101, 'u_kb_post' => 102, 'u_kb_edit' => 103, 'u_kb_delete' => 104, 'u_kb_noapprove' => 105, + 'm_kb_edit' => 106, 'm_kb_delete' => 107, 'm_kb_approve' => 108, 'm_kb_chgposter' => 109, 'a_kb_manage' => 110, + // This extension's board-wide permissions + 'u_kb_view' => 201, 'a_manage_kb' => 202, + ]; + + protected function tearDown(): void + { + if ($this->tools instanceof \phpbb\db\tools\doctrine) $this->tools->get_connection()->close(); + if ($this->db) $this->db->sql_close(); + if ($this->sqlite_fixture) @unlink($this->sqlite_fixture); + } + + public function engines() + { + return [['sqlite'], ['mysql'], ['postgres']]; + } + + private function connect(string $engine): void + { + if ($engine === 'sqlite') + { + $this->db = new \phpbb\db\driver\sqlite3(); + if (class_exists(\phpbb\db\tools\doctrine::class)) + { + $this->sqlite_fixture = tempnam(sys_get_temp_dir(), 'kb-kinerity-'); + $this->assertTrue($this->db->sql_connect($this->sqlite_fixture, '', '', '')); + $this->tools = new \phpbb\db\tools\doctrine(\phpbb\db\doctrine\connection_factory::get_connection_from_params('sqlite3', $this->sqlite_fixture)); + $this->tools->set_table_prefix('phpbb_'); + } + else + { + $this->assertTrue($this->db->sql_connect(':memory:', '', '', '')); + $this->tools = new \phpbb\db\tools\sqlite3($this->db); + } + } + else + { + $port = getenv('KB_TEST_' . strtoupper($engine) . '_PORT'); + if (!$port) $this->markTestSkipped('Set the fixture database port to run integration tests'); + $this->assertTrue(ctype_digit($port)); + $this->db = $engine === 'mysql' ? new \phpbb\db\driver\mysqli() : new \phpbb\db\driver\postgres(); + $this->assertIsObject($this->db->sql_connect('127.0.0.1', $engine === 'mysql' ? 'root' : 'postgres', '', 'kb_fixture', (int) $port)); + if (class_exists(\phpbb\db\tools\doctrine::class)) + { + $this->tools = new \phpbb\db\tools\doctrine(\phpbb\db\doctrine\connection_factory::get_connection_from_params( + $engine === 'mysql' ? 'mysqli' : 'postgres', '127.0.0.1', $engine === 'mysql' ? 'root' : 'postgres', '', 'kb_fixture', $port + )); + $this->tools->set_table_prefix('phpbb_'); + } + else + { + $this->tools = $engine === 'mysql' ? new \phpbb\db\tools\tools($this->db) : new \phpbb\db\tools\postgres($this->db); + } + } + + $this->drop_all_tables(); + $this->create_core_tables(); + } + + /** Every table the fixture or the import touches, from earlier runs too. */ + private function drop_all_tables(): void + { + $tables = [ + 'acl_options', 'acl_groups', 'acl_users', 'acl_roles_data', 'users', 'ext', + 'notification_types', 'notifications', 'user_notifications', + 'kb_kinerity_articles', 'kb_kinerity_categories', 'kb_kinerity_article_category', 'kb_article_category', + ]; + foreach ($this->our_schema() as $table => $unused) + { + $tables[] = substr($table, strlen('phpbb_')); + } + foreach (array_unique($tables) as $table) + { + if ($this->tools->sql_table_exists('phpbb_' . $table)) + { + $this->tools->sql_table_drop('phpbb_' . $table); + } + // phpBB 4.0's tools leave a PostgreSQL table's sequence behind + if ($this->db->get_sql_layer() === 'postgres') + { + $this->db->sql_query('DROP SEQUENCE IF EXISTS phpbb_' . $table . '_seq'); + } + } + } + + /** The phpBB core tables the import reads or writes, cut down to the columns it uses. */ + private function create_core_tables(): void + { + $tables = [ + 'phpbb_acl_options' => ['auth_option_id' => ['UINT', 0], 'auth_option' => ['VCHAR:50', ''], 'is_global' => ['BOOL', 0], 'is_local' => ['BOOL', 0]], + 'phpbb_acl_groups' => ['group_id' => ['UINT', 0], 'forum_id' => ['UINT', 0], 'auth_option_id' => ['UINT', 0], 'auth_role_id' => ['UINT', 0], 'auth_setting' => ['TINT:2', 0]], + 'phpbb_acl_users' => ['user_id' => ['UINT', 0], 'forum_id' => ['UINT', 0], 'auth_option_id' => ['UINT', 0], 'auth_role_id' => ['UINT', 0], 'auth_setting' => ['TINT:2', 0]], + 'phpbb_acl_roles_data' => ['role_id' => ['UINT', 0], 'auth_option_id' => ['UINT', 0], 'auth_setting' => ['TINT:2', 0]], + 'phpbb_users' => ['user_id' => ['UINT', 0], 'user_permissions' => ['MTEXT', ''], 'user_perm_from' => ['UINT', 0]], + 'phpbb_ext' => ['ext_name' => ['VCHAR', ''], 'ext_active' => ['BOOL', 0]], + 'phpbb_notification_types' => ['notification_type_id' => ['USINT', 0], 'notification_type_name' => ['VCHAR', ''], 'notification_type_enabled' => ['BOOL', 1]], + 'phpbb_notifications' => ['notification_id' => ['UINT', 0], 'notification_type_id' => ['USINT', 0]], + 'phpbb_user_notifications' => ['item_type' => ['VCHAR', ''], 'user_id' => ['UINT', 0]], + ]; + foreach ($tables as $table => $columns) + { + $this->tools->sql_create_table($table, ['COLUMNS' => $columns]); + } + } + + /** + * This extension's tables as its migrations leave them: every migration's + * update_schema(), in dependency order, merged into final table layouts. + * + * @return array table => sql_create_table() layout + */ + private function our_schema(): array + { + static $schema = null; + if ($schema !== null) + { + return $schema; + } + + $classes = []; + foreach (glob(dirname(__DIR__) . '/migrations/*.php') as $file) + { + $classes[] = '\\phpbbmodders\\knowledgebase\\migrations\\' . basename($file, '.php'); + } + $ordered = []; + $visit = function ($class) use (&$visit, &$ordered, $classes) { + $class = '\\' . ltrim($class, '\\'); + if (isset($ordered[$class]) || !in_array($class, $classes, true)) return; + foreach ($class::depends_on() as $dependency) $visit($dependency); + $ordered[$class] = true; + }; + foreach ($classes as $class) $visit($class); + + $db = $this->createMock(\phpbb\db\driver\driver_interface::class); + $tools = $this->createMock(\phpbb\db\tools\tools_interface::class); + $schema = []; + foreach (array_keys($ordered) as $class) + { + $changes = (new $class(new \phpbb\config\config([]), $db, $tools, '', 'php', 'phpbb_', []))->update_schema(); + foreach ($changes['add_tables'] ?? [] as $table => $layout) + { + $schema[$table] = $layout; + } + foreach ($changes['add_columns'] ?? [] as $table => $columns) + { + $schema[$table]['COLUMNS'] = array_merge($schema[$table]['COLUMNS'], $columns); + } + foreach ($changes['drop_columns'] ?? [] as $table => $columns) + { + $schema[$table]['COLUMNS'] = array_diff_key($schema[$table]['COLUMNS'], array_flip($columns)); + } + foreach ($changes['change_columns'] ?? [] as $table => $columns) + { + $schema[$table]['COLUMNS'] = array_merge($schema[$table]['COLUMNS'], $columns); + } + foreach ($changes['drop_tables'] ?? [] as $table) + { + unset($schema[$table]); + } + } + + return $schema; + } + + /** Create this extension's tables (what enabling it does after the set-aside) with its options. */ + private function install_our_tables(): void + { + foreach ($this->our_schema() as $table => $layout) + { + $this->tools->sql_create_table($table, $layout); + } + + $options = ['kb_u_add', 'kb_u_edit', 'kb_u_delete', 'kb_u_add_noapprove', 'kb_m_edit', 'kb_m_delete', 'kb_m_approve', 'kb_m_change_author']; + foreach ($options as $option) + { + $this->insert('phpbb_kb_options', ['auth_option' => $option, 'is_global' => 0, 'is_local' => 1]); + } + } + + /** kinerity 1.2.1's tables, from its migrations release_0_0_1 and release_1_1_0. */ + private function install_kinerity_tables(bool $with_edit_name = true): void + { + $articles = [ + 'article_id' => ['UINT', null, 'auto_increment'], 'article_poster_id' => ['UINT', 0], 'article_poster_name' => ['VCHAR:255', ''], + 'article_poster_colour' => ['VCHAR:6', ''], 'article_time' => ['TIMESTAMP', 0], 'article_edit_time' => ['TIMESTAMP', 0], + 'enable_bbcode' => ['BOOL', 1], 'enable_smilies' => ['BOOL', 1], 'enable_magic_url' => ['BOOL', 1], + 'article_title' => ['VCHAR:255', ''], 'article_description' => ['MTEXT_UNI', ''], 'article_text' => ['MTEXT_UNI', ''], + 'bbcode_bitfield' => ['VCHAR:255', ''], 'bbcode_uid' => ['VCHAR:8', ''], 'article_visibility' => ['TINT:3', 0], 'article_views' => ['UINT', 0], + ]; + if ($with_edit_name) + { + $articles['article_edit_name'] = ['VCHAR:255', '']; + } + $this->tools->sql_create_table('phpbb_kb_articles', ['COLUMNS' => $articles, 'PRIMARY_KEY' => 'article_id']); + $this->tools->sql_create_table('phpbb_kb_article_category', ['COLUMNS' => ['category_id' => ['UINT', 0], 'article_id' => ['UINT', 0]]]); + $this->tools->sql_create_table('phpbb_kb_categories', ['COLUMNS' => [ + 'category_id' => ['UINT', null, 'auto_increment'], 'left_id' => ['UINT', 0], 'right_id' => ['UINT', 0], + 'category_name' => ['VCHAR:255', ''], 'category_description' => ['TEXT_UNI', ''], 'bbcode_bitfield' => ['VCHAR:255', ''], + 'bbcode_options' => ['TIMESTAMP', 7], 'bbcode_uid' => ['VCHAR:8', ''], + ], 'PRIMARY_KEY' => 'category_id']); + + // phpBB 3.3 made kinerity's sequences as separate objects that dropping + // the table does not remove; a board upgraded to 4.0 still has them + if ($this->db->get_sql_layer() === 'postgres') + { + foreach (['phpbb_kb_articles_seq', 'phpbb_kb_categories_seq'] as $sequence) + { + $this->db->sql_query('CREATE SEQUENCE IF NOT EXISTS ' . $sequence); + $this->db->sql_query('ALTER SEQUENCE ' . $sequence . ' OWNED BY NONE'); + } + } + } + + /** + * A small kinerity board: + * + * Guides (1) Reference (4) + * Install (2) + * Linux (3) + * + * Article 1 in Install and Reference (cross-listed), 2 in Linux, 3 pending in + * Guides, 4 deleted, 5 denied, 6 in no category, 7 linked only to a category + * that no longer exists, 8 with an over-long description. + */ + private function seed_kinerity_board(bool $with_edit_name = true): void + { + $this->install_kinerity_tables($with_edit_name); + + $categories = [ + [1, 1, 6, 'Guides', '[b]Step-by-step[/b] guides & how-tos'], + [2, 2, 5, 'Install', 'Installing'], + [3, 3, 4, 'Linux & BSD', 'Rocket 🚀 launch'], + [4, 7, 8, 'Reference', 'Line one +line two'], + ]; + foreach ($categories as [$id, $left, $right, $name, $description]) + { + $this->insert('phpbb_kb_categories', ['category_id' => $id, 'left_id' => $left, 'right_id' => $right, 'category_name' => $name, 'category_description' => $description, 'bbcode_uid' => 'abcd1234']); + } + + $articles = [ + [1, 1, 'Welcome', 'First "steps"'], + [2, 1, 'Linux install', ''], + [3, 0, 'Pending', ''], + [4, 2, 'Deleted', ''], + [5, 3, 'Denied', ''], + [6, 1, 'Homeless', ''], + [7, 1, 'Dangling', ''], + [8, 1, 'Long', str_repeat('a & b ', 40)], + ]; + foreach ($articles as [$id, $visibility, $title, $description]) + { + $row = [ + 'article_id' => $id, 'article_poster_id' => 2, 'article_poster_name' => 'Kailey', 'article_poster_colour' => 'AA0000', + 'article_time' => 1600000000 + $id, 'article_edit_time' => $id === 1 ? 1700000000 : 0, + 'article_title' => $title, 'article_description' => $description, + 'article_text' => '[b]Body ' . $id . '[/b]', 'bbcode_bitfield' => 'QQ==', 'bbcode_uid' => 'uid' . $id, + 'article_visibility' => $visibility, 'article_views' => $id * 10, + ]; + if ($with_edit_name) + { + $row['article_edit_name'] = ''; + } + $this->insert('phpbb_kb_articles', $row); + } + + foreach ([[4, 1], [2, 1], [3, 2], [1, 3], [2, 4], [2, 5], [99, 7], [1, 8]] as [$category, $article]) + { + $this->insert('phpbb_kb_article_category', ['category_id' => $category, 'article_id' => $article]); + } + + foreach (self::OPTIONS as $option => $id) + { + $this->insert('phpbb_acl_options', ['auth_option_id' => $id, 'auth_option' => $option, 'is_global' => 1, 'is_local' => 0]); + } + + // Registered users (group 2) by role 5: read, post, edit; Never delete directly + foreach (['u_kb_read' => 1, 'u_kb_post' => 1, 'u_kb_edit' => 1] as $option => $setting) + { + $this->insert('phpbb_acl_roles_data', ['role_id' => 5, 'auth_option_id' => self::OPTIONS[$option], 'auth_setting' => $setting]); + } + $this->insert('phpbb_acl_groups', ['group_id' => 2, 'forum_id' => 0, 'auth_option_id' => 0, 'auth_role_id' => 5, 'auth_setting' => 0]); + $this->insert('phpbb_acl_groups', ['group_id' => 2, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['u_kb_delete'], 'auth_role_id' => 0, 'auth_setting' => 0]); + // Global moderators (group 4): approve directly; a forum-only setting is not board-wide + $this->insert('phpbb_acl_groups', ['group_id' => 4, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['m_kb_approve'], 'auth_role_id' => 0, 'auth_setting' => 1]); + $this->insert('phpbb_acl_groups', ['group_id' => 4, 'forum_id' => 7, 'auth_option_id' => self::OPTIONS['m_kb_delete'], 'auth_role_id' => 0, 'auth_setting' => 1]); + // Administrators (group 5): kinerity admin + $this->insert('phpbb_acl_groups', ['group_id' => 5, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['a_kb_manage'], 'auth_role_id' => 0, 'auth_setting' => 1]); + // User 3: may change authors, never post + $this->insert('phpbb_acl_users', ['user_id' => 3, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['m_kb_chgposter'], 'auth_role_id' => 0, 'auth_setting' => 1]); + $this->insert('phpbb_acl_users', ['user_id' => 3, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['u_kb_post'], 'auth_role_id' => 0, 'auth_setting' => 0]); + + $this->insert('phpbb_users', ['user_id' => 2, 'user_permissions' => 'cached', 'user_perm_from' => 0]); + $this->insert('phpbb_ext', ['ext_name' => 'kinerity/knowledgebase', 'ext_active' => 0]); + $this->insert('phpbb_ext', ['ext_name' => 'phpbb/viglink', 'ext_active' => 1]); + $this->insert('phpbb_notification_types', ['notification_type_id' => 1, 'notification_type_name' => 'kinerity.knowledgebase.notification.type.approve_article', 'notification_type_enabled' => 0]); + $this->insert('phpbb_notification_types', ['notification_type_id' => 2, 'notification_type_name' => 'notification.type.post', 'notification_type_enabled' => 1]); + $this->insert('phpbb_notifications', ['notification_id' => 1, 'notification_type_id' => 1]); + $this->insert('phpbb_notifications', ['notification_id' => 2, 'notification_type_id' => 2]); + $this->insert('phpbb_user_notifications', ['item_type' => 'kinerity.knowledgebase.notification.type.approve_article', 'user_id' => 2]); + $this->insert('phpbb_user_notifications', ['item_type' => 'notification.type.post', 'user_id' => 2]); + } + + private function insert(string $table, array $row): void + { + $this->assertNotFalse($this->db->sql_query('INSERT INTO ' . $table . ' ' . $this->db->sql_build_array('INSERT', $row))); + } + + private function rows(string $sql): array + { + $result = $this->db->sql_query($sql); + $rows = $this->db->sql_fetchrowset($result); + $this->db->sql_freeresult($result); + + return $rows; + } + + private function importer(string $class = kinerity_import::class): kinerity_import + { + return new $class($this->db, $this->tools, 'phpbb_'); + } + + /** Run a batched import step until it reports it is done; returns how many calls it took. */ + private function run_until_done(kinerity_import $importer, string $method): int + { + $state = 0; + $calls = 0; + do + { + $state = $importer->$method($state); + $this->assertLessThan(1000, ++$calls, 'the import step never finished'); + } + while ($state !== true); + + return $calls; + } + + /** The whole enable: set aside, this extension's tables, import, finish. */ + private function full_import(string $class = kinerity_import::class, ?\phpbb\config\config $config = null): \phpbb\config\config + { + $config = $config ?? new \phpbb\config\config(['knowledgebase_enable' => '1', 'knowledgebase_header_link' => '1', 'knowledgebase_font_icon' => 'clone', 'kb_version' => '2.0.31']); + $this->importer($class)->set_aside(); + $this->install_our_tables(); + $this->run_until_done($this->importer($class), 'import_categories'); + $this->run_until_done($this->importer($class), 'import_articles'); + $this->importer($class)->finish($config); + + return $config; + } + + /** Our category id for a kinerity category id. */ + private function new_category_id(int $old_id): int + { + return (int) $this->rows('SELECT kb_new_id FROM phpbb_kb_kinerity_categories WHERE category_id = ' . $old_id)[0]['kb_new_id']; + } + + private function new_article(int $old_id): array + { + $new_id = (int) $this->rows('SELECT kb_new_id FROM phpbb_kb_kinerity_articles WHERE article_id = ' . $old_id)[0]['kb_new_id']; + $this->assertGreaterThan(0, $new_id); + + return $this->rows('SELECT * FROM phpbb_kb_articles WHERE article_id = ' . $new_id)[0]; + } + + /** @dataProvider engines */ + public function test_set_aside_moves_every_kinerity_table_to_a_backup_and_drops_it($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + + $this->importer()->set_aside(); + + foreach (['kb_articles', 'kb_categories', 'kb_article_category'] as $table) + { + $this->assertFalse($this->tools->sql_table_exists('phpbb_' . $table), $table . ' should be gone'); + } + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_kinerity_articles')); + $this->assertCount(4, $this->rows('SELECT category_id FROM phpbb_kb_kinerity_categories')); + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_kinerity_article_category')); + $this->assertSame('First "steps"', $this->rows('SELECT article_description FROM phpbb_kb_kinerity_articles WHERE article_id = 1')[0]['article_description']); + + // Running it again (an enable retried) changes nothing + $this->importer()->set_aside(); + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_kinerity_articles')); + + // This extension's tables can now be created under the shared names + $this->install_our_tables(); + $this->assertTrue($this->tools->sql_column_exists('phpbb_kb_articles', 'article_body')); + } + + /** @dataProvider engines */ + public function test_set_aside_handles_kinerity_1_0_without_the_edit_name_column($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(false); + + $this->full_import(); + + $this->assertSame('Welcome', $this->new_article(1)['article_title']); + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); + } + + /** @dataProvider engines */ + public function test_categories_keep_their_tree_names_and_plain_text_details($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->full_import(); + + $categories = []; + foreach ($this->rows('SELECT * FROM phpbb_kb_categories') as $row) + { + $categories[(int) $row['category_id']] = $row; + } + // 4 kinerity categories and one for the articles kinerity filed nowhere + $this->assertCount(5, $categories); + + $guides = $categories[$this->new_category_id(1)]; + $install = $categories[$this->new_category_id(2)]; + $linux = $categories[$this->new_category_id(3)]; + $reference = $categories[$this->new_category_id(4)]; + + $this->assertSame(0, (int) $guides['parent_id']); + $this->assertSame((int) $guides['category_id'], (int) $install['parent_id']); + $this->assertSame((int) $install['category_id'], (int) $linux['parent_id']); + $this->assertSame(0, (int) $reference['parent_id']); + $this->assertSame([1, 6, 2, 5, 3, 4, 7, 8], [(int) $guides['left_id'], (int) $guides['right_id'], (int) $install['left_id'], (int) $install['right_id'], (int) $linux['left_id'], (int) $linux['right_id'], (int) $reference['left_id'], (int) $reference['right_id']]); + + $this->assertSame('Linux & BSD', $linux['category_name']); + $this->assertSame('Step-by-step guides & how-tos', $guides['category_details']); + $this->assertSame('Line one line two', $reference['category_details']); + // An emoji phpBB stored as an entity stays one, for MySQL's utf8 tables + $this->assertSame('Rocket 🚀 launch', $linux['category_details']); + + $fallback = array_values(array_filter($categories, fn($row) => $row['category_name'] === kinerity_import::FALLBACK_CATEGORY_NAME)); + $this->assertCount(1, $fallback); + $this->assertSame([0, 9, 10], [(int) $fallback[0]['parent_id'], (int) $fallback[0]['left_id'], (int) $fallback[0]['right_id']]); + } + + /** @dataProvider engines */ + public function test_articles_keep_their_content_and_go_to_the_lowest_category_cross_listed_in_the_rest($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->full_import(); + + $welcome = $this->new_article(1); + $this->assertSame($this->new_category_id(2), (int) $welcome['article_category_id']); + $this->assertSame( + [$this->new_category_id(4)], + array_map('intval', array_column($this->rows('SELECT category_id FROM phpbb_kb_article_categories WHERE article_id = ' . (int) $welcome['article_id']), 'category_id')) + ); + $this->assertSame('Welcome', $welcome['article_title']); + $this->assertSame('First "steps"', $welcome['article_description']); + $this->assertSame('[b]Body 1[/b]', $welcome['article_body']); + $this->assertSame('uid1', $welcome['bbcode_uid']); + $this->assertSame([1600000001, 1700000000, 2, 10, 0], [(int) $welcome['article_date'], (int) $welcome['edit_date'], (int) $welcome['author_id'], (int) $welcome['views'], (int) $welcome['author_type']]); + $this->assertSame('Kailey', $welcome['author']); + $this->assertSame([1, functions_kb::STATUS_ACTIVE], [(int) $welcome['approved'], (int) $welcome['status']]); + + $this->assertSame($this->new_category_id(3), (int) $this->new_article(2)['article_category_id']); + + // Waiting for approval: in the queue. Deleted and denied: kept, deactivated. + $this->assertSame([0, functions_kb::STATUS_NEW], [(int) $this->new_article(3)['approved'], (int) $this->new_article(3)['status']]); + $this->assertSame([0, functions_kb::STATUS_INACTIVE], [(int) $this->new_article(4)['approved'], (int) $this->new_article(4)['status']]); + $this->assertSame([0, functions_kb::STATUS_INACTIVE], [(int) $this->new_article(5)['approved'], (int) $this->new_article(5)['status']]); + + // No category, or only a category kinerity no longer has: the fallback category + $fallback_id = (int) $this->rows("SELECT category_id FROM phpbb_kb_categories WHERE category_name = '" . kinerity_import::FALLBACK_CATEGORY_NAME . "'")[0]['category_id']; + $this->assertSame($fallback_id, (int) $this->new_article(6)['article_category_id']); + $this->assertSame($fallback_id, (int) $this->new_article(7)['article_category_id']); + + // Shortened to the column, without cutting an entity + $long = $this->new_article(8)['article_description']; + $this->assertLessThanOrEqual(255, mb_strlen($long)); + $this->assertStringEndsWith("\u{2026}", $long); + $this->assertDoesNotMatchRegularExpression('/&[a-z]*\x{2026}$/u', $long); + + $this->assertCount(1, $this->rows('SELECT article_id FROM phpbb_kb_article_categories')); + } + + /** @dataProvider engines */ + public function test_article_counts_are_set_per_primary_category($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->full_import(); + + $counts = []; + foreach ($this->rows('SELECT category_id, number_articles FROM phpbb_kb_categories') as $row) + { + $counts[(int) $row['category_id']] = (int) $row['number_articles']; + } + + $this->assertSame(2, $counts[$this->new_category_id(1)]); // articles 3, 8 + $this->assertSame(3, $counts[$this->new_category_id(2)]); // 1, 4, 5 + $this->assertSame(1, $counts[$this->new_category_id(3)]); // 2 + $this->assertSame(0, $counts[$this->new_category_id(4)]); // 1 is only cross-listed here + $this->assertSame(8, array_sum($counts)); // 6 and 7 in the fallback category + } + + /** @dataProvider engines */ + public function test_board_wide_permissions_become_permissions_on_every_category($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->full_import(); + + $option_names = []; + foreach ($this->rows('SELECT auth_option_id, auth_option FROM phpbb_kb_options') as $row) + { + $option_names[(int) $row['auth_option_id']] = $row['auth_option']; + } + + $groups = []; + foreach ($this->rows('SELECT group_id, category_id, auth_option_id, auth_setting FROM phpbb_kb_groups') as $row) + { + $groups[(int) $row['category_id']][(int) $row['group_id']][$option_names[(int) $row['auth_option_id']]] = (int) $row['auth_setting']; + } + $users = []; + foreach ($this->rows('SELECT user_id, category_id, auth_option_id, auth_setting FROM phpbb_kb_users') as $row) + { + $users[(int) $row['category_id']][(int) $row['user_id']][$option_names[(int) $row['auth_option_id']]] = (int) $row['auth_setting']; + } + + $all_categories = array_map('intval', array_column($this->rows('SELECT category_id FROM phpbb_kb_categories'), 'category_id')); + $this->assertCount(5, $all_categories); + foreach ($all_categories as $category_id) + { + $expected_groups = [ + 2 => ['kb_u_add' => 1, 'kb_u_edit' => 1, 'kb_u_delete' => 0], + 4 => ['kb_m_approve' => 1], + ]; + ksort($groups[$category_id]); + foreach ($groups[$category_id] as &$options) ksort($options); + unset($options); + foreach ($expected_groups as &$options) ksort($options); + unset($options); + $this->assertSame($expected_groups, $groups[$category_id], 'groups in category ' . $category_id); + + $expected_users = [3 => ['kb_m_change_author' => 1, 'kb_u_add' => 0]]; + ksort($users[$category_id][3]); + ksort($expected_users[3]); + $this->assertSame($expected_users, $users[$category_id], 'users in category ' . $category_id); + } + } + + /** @dataProvider engines */ + public function test_read_and_admin_permissions_carry_over_and_kinerity_is_unregistered($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + // An existing u_kb_view setting is not overwritten + $this->insert('phpbb_acl_groups', ['group_id' => 9, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['u_kb_view'], 'auth_role_id' => 0, 'auth_setting' => 0]); + $this->insert('phpbb_acl_groups', ['group_id' => 9, 'forum_id' => 0, 'auth_option_id' => self::OPTIONS['u_kb_read'], 'auth_role_id' => 0, 'auth_setting' => 1]); + + $config = $this->full_import(); + + $this->assertSame( + [['role_id' => 5, 'auth_setting' => 1]], + array_map(fn($row) => ['role_id' => (int) $row['role_id'], 'auth_setting' => (int) $row['auth_setting']], $this->rows('SELECT role_id, auth_setting FROM phpbb_acl_roles_data WHERE auth_option_id = ' . self::OPTIONS['u_kb_view'])) + ); + $this->assertSame( + [[5, 1]], + array_map(fn($row) => [(int) $row['group_id'], (int) $row['auth_setting']], $this->rows('SELECT group_id, auth_setting FROM phpbb_acl_groups WHERE auth_option_id = ' . self::OPTIONS['a_manage_kb'])) + ); + $this->assertSame( + [[9, 0]], + array_map(fn($row) => [(int) $row['group_id'], (int) $row['auth_setting']], $this->rows('SELECT group_id, auth_setting FROM phpbb_acl_groups WHERE auth_option_id = ' . self::OPTIONS['u_kb_view'])) + ); + $this->assertSame('', $this->rows('SELECT user_permissions FROM phpbb_users WHERE user_id = 2')[0]['user_permissions']); + + foreach (kinerity_import::KINERITY_CONFIG as $name) + { + $this->assertFalse(isset($config[$name]), $name); + } + $this->assertSame('2.0.31', $config['kb_version']); + $this->assertSame(['phpbb/viglink'], array_column($this->rows('SELECT ext_name FROM phpbb_ext'), 'ext_name')); + $this->assertSame(['notification.type.post'], array_column($this->rows('SELECT notification_type_name FROM phpbb_notification_types'), 'notification_type_name')); + $this->assertSame([2], array_map('intval', array_column($this->rows('SELECT notification_id FROM phpbb_notifications'), 'notification_id'))); + $this->assertSame(['notification.type.post'], array_column($this->rows('SELECT item_type FROM phpbb_user_notifications'), 'item_type')); + + // The backup stays as a backup + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_kinerity_articles')); + } + + /** @dataProvider engines */ + public function test_an_import_that_stops_part_way_carries_on_without_duplicates($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->importer()->set_aside(); + $this->install_our_tables(); + + $small = kinerity_import_small_batches::class; + $state = $this->importer($small)->import_categories(0); + $this->assertIsArray($state); + $this->assertCount(2, $this->rows('SELECT category_id FROM phpbb_kb_categories')); + + // A new request (fresh object, no state) picks up the rest + $this->assertSame(2, $this->run_until_done($this->importer($small), 'import_categories')); + $this->assertCount(4, $this->rows('SELECT category_id FROM phpbb_kb_categories')); + + $this->importer($small)->import_articles(0); + $this->assertCount(3, $this->rows('SELECT article_id FROM phpbb_kb_articles')); + $this->assertSame(3, $this->run_until_done($this->importer($small), 'import_articles')); + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); + $this->assertSame(true, $this->importer($small)->import_articles(0)); + + $this->importer()->finish(new \phpbb\config\config([])); + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); + $this->assertSame(1, (int) $this->rows("SELECT COUNT(*) AS total FROM phpbb_kb_categories WHERE category_name = '" . kinerity_import::FALLBACK_CATEGORY_NAME . "'")[0]['total']); + } + + /** @dataProvider engines */ + public function test_two_requests_converting_the_same_batch_import_each_row_once($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->importer()->set_aside(); + $this->install_our_tables(); + + // Categories: the second import converts all of them (and all articles, + // adding the fallback category) inside the first one's batch + $this->run_until_done($this->importer(kinerity_import_racing::class), 'import_categories'); + $this->assertCount(5, $this->rows('SELECT category_id FROM phpbb_kb_categories')); + + // Articles likewise, with the fallback category made only once + $this->run_until_done($this->importer(kinerity_import_racing::class), 'import_articles'); + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); + $this->assertCount(1, $this->rows('SELECT article_id FROM phpbb_kb_article_categories')); + $this->assertSame(1, (int) $this->rows("SELECT COUNT(*) AS total FROM phpbb_kb_categories WHERE category_name = '" . kinerity_import::FALLBACK_CATEGORY_NAME . "'")[0]['total']); + + // Every backup row points at an article that exists, and none twice + $new_ids = array_map('intval', array_column($this->rows('SELECT kb_new_id FROM phpbb_kb_kinerity_articles'), 'kb_new_id')); + $this->assertCount(8, array_unique($new_ids)); + $this->assertSame([], array_diff($new_ids, array_map('intval', array_column($this->rows('SELECT article_id FROM phpbb_kb_articles'), 'article_id')))); + } + + /** @dataProvider engines */ + public function test_after_a_purge_the_backup_is_imported_again($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->full_import(); + + // Purging this extension: version_2_0_32 reverts, then its tables go + $this->importer()->reset_import_markers(); + foreach (array_keys($this->our_schema()) as $table) + { + $this->tools->sql_table_drop($table); + if ($this->db->get_sql_layer() === 'postgres') + { + $this->db->sql_query('DROP SEQUENCE IF EXISTS ' . $table . '_seq'); + } + } + + $this->install_our_tables(); + $this->run_until_done($this->importer(), 'import_categories'); + $this->run_until_done($this->importer(), 'import_articles'); + + $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); + $this->assertSame('Welcome', $this->new_article(1)['article_title']); + } + + /** @dataProvider engines */ + public function test_a_board_that_never_ran_kinerity_is_left_alone($engine) + { + $this->connect($engine); + $this->install_our_tables(); + $this->insert('phpbb_kb_categories', ['category_name' => 'Ours', 'category_parents' => '', 'category_details' => '', 'left_id' => 1, 'right_id' => 2]); + $this->insert('phpbb_ext', ['ext_name' => 'phpbbmodders/knowledgebase', 'ext_active' => 1]); + $config = new \phpbb\config\config(['kb_version' => '2.0.31']); + + $this->importer()->set_aside(); + $this->assertFalse($this->importer()->has_backup()); + $this->assertSame(true, $this->importer()->import_categories(0)); + $this->assertSame(true, $this->importer()->import_articles(0)); + $this->importer()->finish($config); + $this->importer()->reset_import_markers(); + + $this->assertTrue($this->tools->sql_table_exists('phpbb_kb_articles')); + $this->assertFalse($this->tools->sql_table_exists('phpbb_kb_kinerity_articles')); + $this->assertSame(['Ours'], array_column($this->rows('SELECT category_name FROM phpbb_kb_categories'), 'category_name')); + $this->assertSame(['phpbbmodders/knowledgebase'], array_column($this->rows('SELECT ext_name FROM phpbb_ext'), 'ext_name')); + } + + /** @dataProvider engines */ + public function test_categories_already_on_the_board_are_kept_ahead_of_the_imported_tree($engine) + { + $this->connect($engine); + $this->seed_kinerity_board(); + $this->importer()->set_aside(); + $this->install_our_tables(); + $this->insert('phpbb_kb_categories', ['category_name' => 'Ours', 'category_parents' => '', 'category_details' => '', 'left_id' => 1, 'right_id' => 2]); + + $this->run_until_done($this->importer(), 'import_categories'); + + $guides = $this->rows('SELECT left_id, right_id FROM phpbb_kb_categories WHERE category_id = ' . $this->new_category_id(1))[0]; + $this->assertSame([3, 8], [(int) $guides['left_id'], (int) $guides['right_id']]); + } + + public function test_the_migrations_run_in_the_right_order() + { + $this->assertContains('\phpbbmodders\knowledgebase\migrations\kinerity_tables_set_aside', \phpbbmodders\knowledgebase\migrations\version_1_0_0::depends_on()); + $this->assertSame(['\phpbb\db\migration\data\v310\dev'], \phpbbmodders\knowledgebase\migrations\kinerity_tables_set_aside::depends_on()); + $this->assertSame(['\phpbbmodders\knowledgebase\migrations\version_2_0_31'], \phpbbmodders\knowledgebase\migrations\version_2_0_32::depends_on()); + } + + public function test_the_import_migration_removes_kinerity_permissions_and_modules_only_on_a_board_that_ran_it() + { + $migration = new \phpbbmodders\knowledgebase\migrations\version_2_0_32(new \phpbb\config\config([]), $this->createMock(\phpbb\db\driver\driver_interface::class), $this->createMock(\phpbb\db\tools\tools_interface::class), '', 'php', 'phpbb_', []); + $steps = $migration->update_data(); + + $guarded = array_filter($steps, fn($step) => $step[0] === 'if'); + $this->assertCount(count(kinerity_import::KINERITY_PERMISSIONS) + 3, $guarded); + foreach ($guarded as $step) + { + $this->assertSame(['custom', [[$migration, 'has_kinerity_data']]], $step[1][0]); + } + $removed = array_map(fn($step) => $step[1][1][1][0], array_filter($guarded, fn($step) => $step[1][1][0] === 'permission.remove')); + $this->assertSame(kinerity_import::KINERITY_PERMISSIONS, array_values($removed)); + // This extension's own permissions are never among them + $this->assertNotContains('u_kb_view', $removed); + $this->assertNotContains('a_manage_kb', $removed); + $this->assertSame(['config.update', ['kb_version', '2.0.32']], end($steps)); + } + + /** @dataProvider visibilities */ + public function test_map_visibility($visibility, $expected) + { + $this->assertSame($expected, kinerity_import::map_visibility($visibility)); + } + + public function visibilities() + { + return [ + 'approved' => [1, [1, functions_kb::STATUS_ACTIVE]], + 'waiting' => [0, [0, functions_kb::STATUS_NEW]], + 'deleted' => [2, [0, functions_kb::STATUS_INACTIVE]], + 'denied' => [3, [0, functions_kb::STATUS_INACTIVE]], + 'unknown' => [7, [0, functions_kb::STATUS_INACTIVE]], + ]; + } + + public function test_plain_text_from_parsed_and_older_bbcode() + { + $this->assertSame('Bold & "quoted" link', kinerity_import::plain_text('[b]Bold[/b] & "quoted" link')); + $this->assertSame('Plain text', kinerity_import::plain_text('Plain text')); + $this->assertSame('Bold & more :)', kinerity_import::plain_text('[b:1a2b3c4d]Bold[/b:1a2b3c4d] & more :)')); + $this->assertSame('', kinerity_import::plain_text('')); + } + + public function test_category_details_keep_supplementary_characters_as_entities() + { + $this->assertSame('A & B 🚀', kinerity_import::category_details('A & B 🚀')); + $this->assertSame('🚀 🎉', kinerity_import::encode_supplementary("\u{1F680} \u{1F389}")); + $this->assertSame('Ünïcödé 日本語', kinerity_import::encode_supplementary('Ünïcödé 日本語')); + } + + public function test_fit_escaped_shortens_without_cutting_an_entity() + { + $this->assertSame('short', kinerity_import::fit_escaped('short', 10)); + $this->assertSame(str_repeat('x', 10), kinerity_import::fit_escaped(str_repeat('x', 10), 10)); + + $fitted = kinerity_import::fit_escaped(str_repeat('&', 10), 12); + $this->assertSame('&&' . "\u{2026}", $fitted); + + // A character phpBB stored as an entity is not cut in half + $this->assertSame('ab' . "\u{2026}", kinerity_import::fit_escaped('ab🚀cdef', 6)); + + $fitted = kinerity_import::fit_escaped(str_repeat('é', 300), 255); + $this->assertSame(255, mb_strlen($fitted)); + $this->assertStringEndsWith("\u{2026}", $fitted); + } +} From 68287beb5be18aa361d3555f853439a371a78101 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Thu, 12 Nov 2026 03:00:45 -0600 Subject: [PATCH 4/5] Name the import's fallback category in the board's language The category made for kinerity articles that were in no category was named in English in PHP. Its name now comes from the new language/en/default_content.php, read by inc/default_content.php in the board's default language, falling back to English. The board checker finds that category as the one the import adds, not by its name. Co-Authored-By: Claude Opus 5.5 --- inc/default_content.php | 50 ++++++++++++++++++++++++ inc/kinerity_import.php | 22 ++++++++--- language/en/default_content.php | 28 +++++++++++++ migrations/kinerity_tables_set_aside.php | 2 +- migrations/version_2_0_32.php | 2 +- tests/board/kinerity_import_check.php | 6 +-- tests/kb_kinerity_import_test.php | 20 ++++++++-- 7 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 inc/default_content.php create mode 100644 language/en/default_content.php diff --git a/inc/default_content.php b/inc/default_content.php new file mode 100644 index 0000000..cd9639c --- /dev/null +++ b/inc/default_content.php @@ -0,0 +1,50 @@ +/default_content.php that migrations write into + * the board as content, such as a category name. Migrations have no language + * service, so the file is read directly, in the board's default language when + * it has one, otherwise in English. + */ +class default_content +{ + /** + * @param string $language A language folder name, such as the board's default_lang + * @param string $key A key in default_content.php + * @return string The text, or the key when no language file has it + */ + public static function lang(string $language, string $key): string + { + foreach (array_unique([preg_replace('/[^a-z0-9_\-]/i', '', $language), 'en']) as $folder) + { + $file = dirname(__DIR__) . '/language/' . $folder . '/default_content.php'; + if ($folder === '' || !is_file($file)) + { + continue; + } + + $lang = []; + if (!defined('IN_PHPBB')) + { + define('IN_PHPBB', true); + } + include $file; + if (isset($lang[$key])) + { + return $lang[$key]; + } + } + + return $key; + } +} diff --git a/inc/kinerity_import.php b/inc/kinerity_import.php index ff4049d..69765da 100644 --- a/inc/kinerity_import.php +++ b/inc/kinerity_import.php @@ -48,8 +48,6 @@ class kinerity_import /** Largest value the number_articles column (USINT) holds. */ public const MAX_ARTICLE_COUNT = 65535; - /** Name of the category made for articles kinerity filed in no category. */ - public const FALLBACK_CATEGORY_NAME = 'Imported articles without a category'; /** kinerity article_visibility values (kinerity\knowledgebase\constants). */ public const KINERITY_DISAPPROVED = 0; @@ -110,17 +108,31 @@ class kinerity_import protected driver_interface $db; protected tools_interface $db_tools; protected string $table_prefix; + protected string $language; /** * @param driver_interface $db * @param tools_interface $db_tools * @param string $table_prefix The board's table prefix, such as phpbb_ + * @param string $language The board's default language, for the text it writes */ - public function __construct(driver_interface $db, tools_interface $db_tools, string $table_prefix) + public function __construct(driver_interface $db, tools_interface $db_tools, string $table_prefix, string $language = 'en') { $this->db = $db; $this->db_tools = $db_tools; $this->table_prefix = $table_prefix; + $this->language = $language; + } + + /** + * Name of the category made for articles kinerity filed in no category, + * in the board's default language. + * + * @return string As stored: HTML-escaped like user input + */ + public function fallback_category_name(): string + { + return htmlspecialchars(default_content::lang($this->language, 'KB_IMPORTED_NO_CATEGORY'), ENT_COMPAT, 'UTF-8'); } /** @@ -776,7 +788,7 @@ protected function fallback_category(): int // One already made by an earlier or a parallel request is used again $result = $this->db->sql_query_limit('SELECT category_id FROM ' . $this->table_prefix . "kb_categories - WHERE category_name = '" . $this->db->sql_escape(self::FALLBACK_CATEGORY_NAME) . "' + WHERE category_name = '" . $this->db->sql_escape($this->fallback_category_name()) . "' AND parent_id = 0 ORDER BY category_id", 1); $existing = (int) $this->db->sql_fetchfield('category_id'); @@ -793,7 +805,7 @@ protected function fallback_category(): int 'left_id' => $right + 1, 'right_id' => $right + 2, 'category_parents' => '', - 'category_name' => self::FALLBACK_CATEGORY_NAME, + 'category_name' => $this->fallback_category_name(), 'category_details' => '', 'number_articles' => 0, ]; diff --git a/language/en/default_content.php b/language/en/default_content.php new file mode 100644 index 0000000..36a6146 --- /dev/null +++ b/language/en/default_content.php @@ -0,0 +1,28 @@ + 'Imported articles without a category', +]); diff --git a/migrations/kinerity_tables_set_aside.php b/migrations/kinerity_tables_set_aside.php index b106db5..4df80af 100644 --- a/migrations/kinerity_tables_set_aside.php +++ b/migrations/kinerity_tables_set_aside.php @@ -42,6 +42,6 @@ public function update_data() */ public function set_aside() { - (new kinerity_import($this->db, $this->db_tools, $this->table_prefix))->set_aside(); + (new kinerity_import($this->db, $this->db_tools, $this->table_prefix, (string) ($this->config['default_lang'] ?? 'en')))->set_aside(); } } diff --git a/migrations/version_2_0_32.php b/migrations/version_2_0_32.php index 44baf5f..0e93758 100644 --- a/migrations/version_2_0_32.php +++ b/migrations/version_2_0_32.php @@ -113,6 +113,6 @@ public function reset_import_markers() */ protected function importer() { - return new kinerity_import($this->db, $this->db_tools, $this->table_prefix); + return new kinerity_import($this->db, $this->db_tools, $this->table_prefix, (string) ($this->config['default_lang'] ?? 'en')); } } diff --git a/tests/board/kinerity_import_check.php b/tests/board/kinerity_import_check.php index 1e1a250..c865735 100644 --- a/tests/board/kinerity_import_check.php +++ b/tests/board/kinerity_import_check.php @@ -143,9 +143,9 @@ function rows(string $sql, ?string $key = null): array check('every kinerity category converted once', !in_array(0, $map, true) && count(array_unique($map)) === count($map) && !array_diff($map, array_keys($categories)), count($backup_categories) . ' kinerity, ' . count($categories) . ' now'); -$fallback = array_keys(array_filter($categories, fn($row) => $row['category_name'] === kinerity_import::FALLBACK_CATEGORY_NAME && !in_array((int) $row['category_id'], $map, true))); -$extra = array_diff(array_keys($categories), $map, $fallback); -check('no unexpected categories', !$extra, $extra ? 'ids ' . implode(', ', array_slice($extra, 0, 10)) : (count($fallback) . ' fallback category')); +// The only category the import adds beyond kinerity's: the one for articles in no category +$fallback = array_values(array_diff(array_keys($categories), $map)); +check('at most one category besides kinerity\'s (for articles in none)', count($fallback) <= 1, count($fallback) . ' extra'); // Parents from the kinerity tree, as the import works them out $sorted = $backup_categories; diff --git a/tests/kb_kinerity_import_test.php b/tests/kb_kinerity_import_test.php index 64a0ccf..f07c23d 100644 --- a/tests/kb_kinerity_import_test.php +++ b/tests/kb_kinerity_import_test.php @@ -15,6 +15,7 @@ if (!defined('CONFIG_TABLE')) define('CONFIG_TABLE', 'phpbb_config'); require_once (getenv('PHPBB_ROOT_PATH') ?: dirname(__DIR__, 4)) . '/includes/functions.php'; require_once dirname(__DIR__) . '/inc/functions_kb.php'; +require_once dirname(__DIR__) . '/inc/default_content.php'; require_once dirname(__DIR__) . '/inc/kinerity_import.php'; foreach (glob(dirname(__DIR__) . '/migrations/*.php') as $migration_file) { @@ -499,7 +500,7 @@ public function test_categories_keep_their_tree_names_and_plain_text_details($en // An emoji phpBB stored as an entity stays one, for MySQL's utf8 tables $this->assertSame('Rocket 🚀 launch', $linux['category_details']); - $fallback = array_values(array_filter($categories, fn($row) => $row['category_name'] === kinerity_import::FALLBACK_CATEGORY_NAME)); + $fallback = array_values(array_filter($categories, fn($row) => $row['category_name'] === 'Imported articles without a category')); $this->assertCount(1, $fallback); $this->assertSame([0, 9, 10], [(int) $fallback[0]['parent_id'], (int) $fallback[0]['left_id'], (int) $fallback[0]['right_id']]); } @@ -533,7 +534,7 @@ public function test_articles_keep_their_content_and_go_to_the_lowest_category_c $this->assertSame([0, functions_kb::STATUS_INACTIVE], [(int) $this->new_article(5)['approved'], (int) $this->new_article(5)['status']]); // No category, or only a category kinerity no longer has: the fallback category - $fallback_id = (int) $this->rows("SELECT category_id FROM phpbb_kb_categories WHERE category_name = '" . kinerity_import::FALLBACK_CATEGORY_NAME . "'")[0]['category_id']; + $fallback_id = (int) $this->rows("SELECT category_id FROM phpbb_kb_categories WHERE category_name = '" . 'Imported articles without a category' . "'")[0]['category_id']; $this->assertSame($fallback_id, (int) $this->new_article(6)['article_category_id']); $this->assertSame($fallback_id, (int) $this->new_article(7)['article_category_id']); @@ -676,7 +677,7 @@ public function test_an_import_that_stops_part_way_carries_on_without_duplicates $this->importer()->finish(new \phpbb\config\config([])); $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); - $this->assertSame(1, (int) $this->rows("SELECT COUNT(*) AS total FROM phpbb_kb_categories WHERE category_name = '" . kinerity_import::FALLBACK_CATEGORY_NAME . "'")[0]['total']); + $this->assertSame(1, (int) $this->rows("SELECT COUNT(*) AS total FROM phpbb_kb_categories WHERE category_name = '" . 'Imported articles without a category' . "'")[0]['total']); } /** @dataProvider engines */ @@ -696,7 +697,7 @@ public function test_two_requests_converting_the_same_batch_import_each_row_once $this->run_until_done($this->importer(kinerity_import_racing::class), 'import_articles'); $this->assertCount(8, $this->rows('SELECT article_id FROM phpbb_kb_articles')); $this->assertCount(1, $this->rows('SELECT article_id FROM phpbb_kb_article_categories')); - $this->assertSame(1, (int) $this->rows("SELECT COUNT(*) AS total FROM phpbb_kb_categories WHERE category_name = '" . kinerity_import::FALLBACK_CATEGORY_NAME . "'")[0]['total']); + $this->assertSame(1, (int) $this->rows("SELECT COUNT(*) AS total FROM phpbb_kb_categories WHERE category_name = '" . 'Imported articles without a category' . "'")[0]['total']); // Every backup row points at an article that exists, and none twice $new_ids = array_map('intval', array_column($this->rows('SELECT kb_new_id FROM phpbb_kb_kinerity_articles'), 'kb_new_id')); @@ -840,4 +841,15 @@ public function test_fit_escaped_shortens_without_cutting_an_entity() $this->assertSame(255, mb_strlen($fitted)); $this->assertStringEndsWith("\u{2026}", $fitted); } + + public function test_the_fallback_category_name_comes_from_the_language_file() + { + $db = $this->createMock(\phpbb\db\driver\driver_interface::class); + $tools = $this->createMock(\phpbb\db\tools\tools_interface::class); + $this->assertSame('Imported articles without a category', (new kinerity_import($db, $tools, 'phpbb_'))->fallback_category_name()); + // A board language without the file falls back to English + $this->assertSame('Imported articles without a category', (new kinerity_import($db, $tools, 'phpbb_', 'xx'))->fallback_category_name()); + $this->assertSame('KB_NO_SUCH_KEY', default_content::lang('en', 'KB_NO_SUCH_KEY')); + $this->assertSame('Imported articles without a category', default_content::lang('../../etc', 'KB_IMPORTED_NO_CATEGORY')); + } } From a2e234dddef1b4f504c36bd9ac048a94628412de Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Thu, 12 Nov 2026 03:09:35 -0600 Subject: [PATCH 5/5] Correct the test count in docs/testing.md Co-Authored-By: Claude Opus 5.5 --- docs/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testing.md b/docs/testing.md index 5b11c30..509a2c7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1430,7 +1430,7 @@ entry removed, an import stopped part way, two requests converting the same batc followed by a second import, a board that never ran kinerity, and a board that already has categories. It runs on SQLite, MySQL and PostgreSQL, and on PostgreSQL also with a sequence made the phpBB 3.3 way, which phpBB 4.0's tools leave behind when the table is dropped (without dropping it -too, this extension's table of the same name cannot be created). The full suite, 558 tests, passes on +too, this extension's table of the same name cannot be created). The full suite, 559 tests, passes on phpBB 3.3.x and master with the MySQL 8.0 and PostgreSQL 16 fixtures, none skipped. **Boards.** Claude built boards with seed-forum's `config/kinerity-33.yml`, `kinerity-40.yml` and