diff --git a/README.md b/README.md index 1b723fa..3eea023 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,27 @@ A knowledge base for phpBB: articles organised in categories, with attachments,
## Hierarchical category browsing
The category tree with its subcategories, article counts and the last article of each.
-
+
## An article
-The article with its status, details, a copyable link and BBCode, and the tab that reads "Reading Article #1".
+The article with its status, details, a copyable link and BBCode, and the tab that reads "Reading Article #1", with the private-draft indicator visible to the administrator.
-
+
## Manage your articles
A member's own articles and drafts.
-
+
## Recent changes
The **Recent changes** tab: the live articles, newest first, with a **New** or **Updated** badge, the category, the author and when the article last changed. Under the heading are the RSS and Atom feeds of the same list. A browser or feed reader also finds them from the page header, and the ACP says which formats are offered.
-
+
## Add an article
The form for a new article.
-
+
## The approval queue
The Administration tab: new articles and revisions waiting for review, with the actions available to a moderator.
-
+
## Search
Results of a search on the Knowledge Base's own search page.
-
+
## ACP configuration
**Extensions > Knowledge Base > Configuration**, the whole page: the announcement and changelog forums, hierarchical browsing, the alternative stylesheet, the **Recent changes feed** (RSS, Atom or both), the number of articles per page, the description length and the sorting order, then the attachment settings, the review teams and the contact group.
-
+
## Knowledge Base articles in the board's search
A search in phpBB's own search for `backup`. The **Knowledge Base (4)** block sits above the forum results and lists the best matches with their category, a snippet with the word marked and a link to all of them; the forum post that asks about backups is the first of the forum results below it. Switch the block off, or set how many articles it lists, in the ACP.
-
+
## Also search the Knowledge Base
The advanced search has an **Also search the Knowledge Base** option, ticked by default.
-
+
## ACP search settings
**Extensions > Knowledge Base > Search**: under **General search settings**, whether the board search shows the Knowledge Base block and how many articles it lists, beside the results per page for the Knowledge Base's own search; then the search backend and each backend's index.
-
+
+
+
+## Submit a redirect
+
+The redirect form, with its visible Knowledge Base » Submit a Redirect heading.
+
+
+
+## Change author
+
+Transfer the primary author to an active member or configured KB team while retaining the pending revision.
+
+
+
+## Private draft Notice
+
+A blocked transfer appears in the extension's Notice tab, with the board styling and navigation intact.
+
+
+
+## Rendered Markdown search
+
+Markdown headings, lists and emphasis render in a shortened search excerpt rather than appearing as source syntax.
+
diff --git a/docs/testing.md b/docs/testing.md
index 6395c09..97072be 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -1357,3 +1357,34 @@ a guest getting 403 until guests may read and then both feeds and the page, a me
`kb_feed_test.php` (the wiki's feed cases: formats offered, valid settings, both documents well formed with values escaped once, an empty feed) and `kb_recent_changes_test.php` (the migration; 403 without running a query, 404 for a format not offered, the feed built from given rows with its headers and New or Updated summaries, the query's live-only
conditions and ordering, and the routes, service, tab, header template, ACP and language strings being wired in) are new; the style layout test now expects the extra tab. The suite passes with 485 tests (17 skipped: database cases without the MySQL and PostgreSQL fixtures, which this change does not touch).
+
+
+## Primary author transfers
+
+The author-transfer tests cover permission checks, CSRF rejection, private
+article and edit drafts, stale ownership, member/team transfers, co-author
+handling, audit logging and rollback, and search-result cache invalidation.
+Run `kb_author_change_database_test.php` with the disposable database ports
+above to exercise the actual MySQL and PostgreSQL transfer SQL and permission
+migration/rollback.
+
+The browser check uses the standard seed-forum fixtures on a dedicated SQLite
+board whose directory is named `kb-author-change-*` under `seeded-board/`.
+It temporarily grants the `forum_moderator` account transfer permission for
+one category, prepares a pending revision and co-authors, and restores those
+fixtures after the checks. The audit entries remain as test evidence.
+
+```bash
+KB_TEST_URL=http://localhost:8100 \
+KB_TEST_BOARD_ROOT=/path/to/seeded-board/kb-author-change-33 \
+node /path/to/playwright-skill/run.js tests/browser/kb_author_change.js
+```
+
+`KB_ARTIFACT_DIR` optionally selects a directory for a full-page form screenshot.
+Use only disposable fixture boards: these checks change article ownership
+through real browser sessions before restoring it.
+
+The canonical seed-forum `bin/seed-kb-fixtures.php` supplies the author-transfer
+moderator, co-authors and pending revision. Build a fresh board with the updated
+script before running the browser suite. Its Python helper only snapshots and
+restores the fixture; it does not create articles or grant permissions.
diff --git a/docs/update/2026-10-18/01-change-author.png b/docs/update/2026-10-18/01-change-author.png
new file mode 100644
index 0000000..08115f7
Binary files /dev/null and b/docs/update/2026-10-18/01-change-author.png differ
diff --git a/docs/update/2026-10-18/02-queue-statuses.png b/docs/update/2026-10-18/02-queue-statuses.png
new file mode 100644
index 0000000..c611aea
Binary files /dev/null and b/docs/update/2026-10-18/02-queue-statuses.png differ
diff --git a/docs/update/2026-10-18/03-markdown-search.png b/docs/update/2026-10-18/03-markdown-search.png
new file mode 100644
index 0000000..84b150b
Binary files /dev/null and b/docs/update/2026-10-18/03-markdown-search.png differ
diff --git a/inc/author_change.php b/inc/author_change.php
new file mode 100644
index 0000000..28bf62a
--- /dev/null
+++ b/inc/author_change.php
@@ -0,0 +1,99 @@
+db->sql_transaction('begin');
+ try
+ {
+ $sql = 'UPDATE ' . $this->articles_table . ' SET ' . $this->db->sql_build_array('UPDATE', [
+ 'author_id' => $author_id, 'author_type' => $author_type, 'author' => $author_name,
+ ]) . '
+ WHERE article_id = ' . $article_id . '
+ AND author_id = ' . (int) $article['author_id'] . '
+ AND author_type = ' . (int) $article['author_type'] . '
+ AND status = ' . (int) $article['status'] . '
+ AND NOT EXISTS (SELECT article_id FROM ' . $this->draft_edits_table . '
+ WHERE article_id = ' . $article_id . ')';
+ $this->db->sql_query($sql);
+ if (!$this->db->sql_affectedrows())
+ {
+ throw new \RuntimeException('KB_CHANGE_AUTHOR_CONFLICT');
+ }
+
+ // The new primary author must not also be listed as a co-author.
+ $this->db->sql_query('DELETE FROM ' . $this->co_authors_table . '
+ WHERE article_id = ' . $article_id . '
+ AND author_type = ' . $author_type . '
+ AND author_ref_id = ' . $author_id);
+ $log_id = $this->log->add('admin', (int) $this->user->data['user_id'], $this->user->ip,
+ 'LOG_LIBRARY_CHANGED_AUTHOR', time(), [
+ $article['article_title'],
+ $article['author'] . ' (' . (int) $article['author_type'] . ':' . (int) $article['author_id'] . ')',
+ $author_name . ' (' . $author_type . ':' . $author_id . ')',
+ ]);
+ if (!$log_id)
+ {
+ throw new \RuntimeException('KB_CHANGE_AUTHOR_LOG_FAILED');
+ }
+ $this->db->sql_transaction('commit');
+ }
+ catch (\Throwable $error)
+ {
+ $this->db->sql_transaction('rollback');
+ throw $error;
+ }
+
+ // Author filters are cached separately from the full-text word index.
+ // Invalidate KB results only; their word matches need no rebuilding.
+ $result = $this->db->sql_query('SELECT search_key FROM ' . $this->search_results_table);
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $this->cache->destroy('_kb_search_results_' . $row['search_key']);
+ }
+ $this->db->sql_freeresult($result);
+ return true;
+ }
+}
diff --git a/inc/functions_kb.php b/inc/functions_kb.php
index 65d92d8..b700018 100644
--- a/inc/functions_kb.php
+++ b/inc/functions_kb.php
@@ -1699,10 +1699,27 @@ public function delete_article_revision(int $article_id): void
$this->db->sql_query($sql);
}
- /**
- * @param int $article_id
- * @return bool
- */
+ /** Return pending revision and private draft existence flags for a page of articles. */
+ public function get_article_edit_flags_bulk(array $article_ids): array
+ {
+ $flags = [];
+ $article_ids = array_values(array_unique(array_map('intval', $article_ids)));
+ if (!$article_ids)
+ {
+ return $flags;
+ }
+ foreach (['revision' => $this->revisions_table, 'draft' => $this->draft_edits_table] as $flag => $table)
+ {
+ $result = $this->db->sql_query('SELECT article_id FROM ' . $table . ' WHERE ' . $this->db->sql_in_set('article_id', $article_ids));
+ while ($row = $this->db->sql_fetchrow($result))
+ {
+ $flags[(int) $row['article_id']][$flag] = true;
+ }
+ $this->db->sql_freeresult($result);
+ }
+ return $flags;
+ }
+
public function article_has_draft_edit(int $article_id): bool
{
return (bool) $this->get_article_draft_edit($article_id);
diff --git a/language/en/acp_knowledgebase.php b/language/en/acp_knowledgebase.php
index 1dd2277..cc508bc 100644
--- a/language/en/acp_knowledgebase.php
+++ b/language/en/acp_knowledgebase.php
@@ -158,6 +158,7 @@
'kb_u_delete' => 'Can delete own articles',
'kb_u_add_noapprove' => 'Can add / modify articles without prior approval',
// Moderator Permissions
+'kb_m_change_author' => 'Can change the primary author of submitted articles',
'kb_m_edit' => 'Can edit articles',
'kb_m_delete' => 'Can delete articles',
'kb_m_approve' => 'Can approve articles',
diff --git a/language/en/knowledgebase_lng.php b/language/en/knowledgebase_lng.php
index deeb942..b35ac84 100644
--- a/language/en/knowledgebase_lng.php
+++ b/language/en/knowledgebase_lng.php
@@ -18,10 +18,21 @@
}
$lang = array_merge($lang, [
+ 'KB_CHANGE_AUTHOR' => 'Change author',
+ 'KB_CHANGE_AUTHOR_TYPE' => 'New author type',
+ 'KB_CHANGE_AUTHOR_SELECT_TEAM' => 'Select a team',
+ 'KB_CHANGE_AUTHOR_TEAM' => 'Knowledge Base team',
+ 'KB_CHANGE_AUTHOR_EXPLAIN' => 'Choose a member or a Knowledge Base team as the new primary author. Use the field for the selected author type. Co-authors, article content, and submitted revisions are retained. This replaces the primary author; co-author and team permissions still apply.',
+ 'KB_CHANGE_AUTHOR_LOG_FAILED' => 'The author could not be changed because the audit log could not be written.',
+ 'KB_CHANGE_AUTHOR_INVALID' => 'Choose an active member or a configured Knowledge Base team.',
+ 'KB_CHANGE_AUTHOR_PRIVATE_DRAFT' => 'Authorship cannot be transferred while a private draft or private draft edit exists. Ask its author to submit or discard it first.',
+ 'KB_CHANGE_AUTHOR_CONFLICT' => 'The author or article status changed, or a private draft edit was saved. Reload the form before trying again.',
+ 'LOG_LIBRARY_CHANGED_AUTHOR' => 'Changed the author of article “%1$s”{{ lang('LINK_TO_ARTICLE') }}{{ lang('COLON') }} {{ lang('COPY_TO_BUFFER') }}
@@ -63,6 +68,10 @@