Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@ A knowledge base for phpBB: articles organised in categories, with attachments,

<table>
<tr>
<td align="center"><a href="docs/images/screenshots/01-flat-index.png"><img src="docs/images/screenshots/01-flat-index.png" width="440" alt="The flat article index"></a><br>The flat article index (the default)</td>
<td align="center"><a href="docs/images/screenshots/08-category-tree.png"><img src="docs/images/screenshots/08-category-tree.png" width="440" alt="The category tree"></a><br>Hierarchical category browsing</td>
<td align="center"><a href="docs/images/screenshots/01-flat-index.png"><img src="docs/images/screenshots/01-flat-index.png" width="440" height="370" alt="The flat article index"></a><br>The flat article index (the default)</td>
<td align="center"><a href="docs/images/screenshots/08-category-tree.png"><img src="docs/images/screenshots/08-category-tree.png" width="440" height="330" alt="The category tree"></a><br>Hierarchical category browsing</td>
</tr>
<tr>
<td align="center"><a href="docs/images/screenshots/02-article.png"><img src="docs/images/screenshots/02-article.png" width="440" alt="An article"></a><br>An article</td>
<td align="center"><a href="docs/images/screenshots/09-board-search-kb-block.png"><img src="docs/images/screenshots/09-board-search-kb-block.png" width="440" alt="Knowledge Base articles beside the forum posts in a board search"></a><br>Knowledge Base block in the board search</td>
<td align="center"><a href="docs/images/screenshots/02-article.png"><img src="docs/images/screenshots/02-article.png" width="440" height="309" alt="An article"></a><br>An article</td>
<td align="center"><a href="docs/images/screenshots/09-board-search-kb-block.png"><img src="docs/images/screenshots/09-board-search-kb-block.png" width="440" height="382" alt="Knowledge Base articles beside the forum posts in a board search"></a><br>Knowledge Base block in the board search</td>
</tr>
</table>

More screenshots, including the article form, the approval queue and the ACP settings: [docs/images/screenshots](docs/images/screenshots/README.md).

Primary authorship of submitted articles can be transferred to another active
member or a configured KB team using **Change author** on the article page.
Grant moderators **Can change the primary author of submitted articles** in
Knowledge Base category permissions. KB administrators already have access.
Transfers are logged and retain co-authors and submitted revisions. Private
drafts and articles with private draft edits must be submitted or discarded
before authorship can change.

Article pages show status below the category. Category lists and review queues also show status, pending revisions, and “Has draft” for authors and authorized editors. Draft contents remain private. Markdown search excerpts retain their formatting.

## Installation

1. Copy the extension to: `/ext/phpbbmodders/knowledgebase`
Expand Down Expand Up @@ -86,6 +96,7 @@ Based on the original [knowledgebase](https://github.com/sutrus/knowledgebase) e
- Code review, bug fixes, and documentation assisted by [Claude](https://www.anthropic.com/claude).
- Code review, bug fixes, and documentation assisted by [Codex](https://openai.com/codex/).


## License

Licensed under the [GNU General Public License v2](license.txt)
4 changes: 4 additions & 0 deletions config/routing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ phpbbmodders_knowledgebase_article:
path: /knowledgebase/article
defaults: { _controller: phpbbmodders.knowledgebase.controller.article:show }

phpbbmodders_knowledgebase_change_author:
path: /knowledgebase/change_author
defaults: { _controller: phpbbmodders.knowledgebase.controller.change_author:main }

phpbbmodders_knowledgebase_posting:
path: /knowledgebase/posting
defaults: { _controller: phpbbmodders.knowledgebase.controller.posting:post_article }
Expand Down
26 changes: 26 additions & 0 deletions config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ services:
- '%tables.articles_table%'
- '%tables.kb_attachments_table%'

phpbbmodders.knowledgebase.author_change:
class: phpbbmodders\knowledgebase\inc\author_change
arguments:
- '@dbal.conn'
- '@cache.driver'
- '@log'
- '@user'
- '%tables.articles_table%'
- '%tables.kb_article_co_authors_table%'
- '%tables.kb_draft_edits_table%'
- '%tables.search_result_table%'

phpbbmodders.knowledgebase.controller.change_author:
class: phpbbmodders\knowledgebase\controller\change_author
arguments:
- '@dbal.conn'
- '@controller.helper'
- '@language'
- '@auth'
- '@request'
- '@template'
- '@user'
- '@phpbbmodders.knowledgebase.inc'
- '@phpbbmodders.knowledgebase.author_change'
- '%tables.articles_table%'

phpbbmodders.knowledgebase.controller.posting:
class: phpbbmodders\knowledgebase\controller\posting
arguments:
Expand Down
6 changes: 5 additions & 1 deletion controller/approve.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ public function queue(): \Symfony\Component\HttpFoundation\Response
$this->db->sql_freeresult($result);

$author_colours = $this->kb->get_author_colours_bulk($art_rows);
$edit_flags = $this->kb->get_article_edit_flags_bulk(array_column($art_rows, 'article_id'));
$is_own_article_map = $this->kb->is_article_author_bulk($art_rows);

// Same options for every unassigned row below - computed once
// rather than re-querying the team group list per row.
Expand All @@ -248,7 +250,7 @@ public function queue(): \Symfony\Component\HttpFoundation\Response
// A revision survives deactivation - a moderator pulling a live
// article offline over a contentious edit still needs to see
// and act on it, not just get "Reactivate".
$has_revision = in_array($status, [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && $this->kb->article_has_pending_revision($art_id);
$has_revision = in_array($status, [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && !empty($edit_flags[$art_id]['revision']);
$assigned_team_id = (int) $art_row['assigned_team_id'];
$effective_team_id = $assigned_team_id ?: (int) ($category_info['default_team_id'] ?? 0);

Expand All @@ -265,6 +267,8 @@ public function queue(): \Symfony\Component\HttpFoundation\Response
'S_STATUS_ACTIVE' => $status === functions_kb::STATUS_ACTIVE,
'S_STATUS_INACTIVE' => $status === functions_kb::STATUS_INACTIVE,
'S_HAS_REVISION' => $has_revision,
'STATUS' => $status,
'S_HAS_DRAFT_EDIT' => !empty($edit_flags[$art_id]['draft']) && (!empty($is_own_article_map[$art_id]) || $this->auth->acl_get('a_manage_kb') || $this->kb->acl_kb_get_any($this->kb->get_article_categories($art_id), 'kb_m_edit')),
// Whether an explicit per-article team is set - controls
// the "Assign" button, independent of the category's
// default team below.
Expand Down
9 changes: 4 additions & 5 deletions controller/article.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@ public function show(): \Symfony\Component\HttpFoundation\Response
'S_CAN_EDIT' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_edit') || $this->auth->acl_get('a_manage_kb')),
'S_CAN_DELETE' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_delete') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_delete') || $this->auth->acl_get('a_manage_kb')),
'S_CAN_APPROVE' => $can_moderate || $can_review,
'U_CHANGE_AUTHOR' => $status !== functions_kb::STATUS_DRAFT && $this->kb->acl_kb_get_any($article_categories, 'kb_m_change_author')
? $this->helper->route('phpbbmodders_knowledgebase_change_author', ['k' => $art_id]) : '',
'COUNT_COMMENTS' => ($comment_topic_id) ? '[' . $this->language->lang('LEAVE_COMMENTS') . ']' : '',
'U_FORUM' => generate_board_url() . '/',
'S_APPROVED' => $row['approved'],
Expand All @@ -316,11 +318,8 @@ public function show(): \Symfony\Component\HttpFoundation\Response
'S_STATUS_INACTIVE' => $status === functions_kb::STATUS_INACTIVE,
'S_STATUS_DRAFT' => $status === functions_kb::STATUS_DRAFT,
'S_HAS_REVISION' => in_array($status, [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && $this->kb->article_has_pending_revision($art_id),
// Only the author/co-authors ever reach this branch for a
// draft-of-an-edit to matter - anyone else viewing the page
// couldn't act on it anyway, and a draft article itself can
// never have one (see posting.php).
'S_HAS_DRAFT_EDIT' => $is_own_article && $status !== functions_kb::STATUS_DRAFT && $this->kb->article_has_draft_edit($art_id),
// Expose only the existence to editors; draft content remains private.
'S_HAS_DRAFT_EDIT' => ($is_own_article || $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || $this->auth->acl_get('a_manage_kb')) && $status !== functions_kb::STATUS_DRAFT && $this->kb->article_has_draft_edit($art_id),
]);

if ($mode != 'print' && $row['approved'])
Expand Down
3 changes: 3 additions & 0 deletions controller/category.php
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ public function cat(): \Symfony\Component\HttpFoundation\Response
}, $art_rows);
$is_own_article_map = $this->kb->is_article_author_bulk($art_rows);
$co_authors_display_map = $this->kb->get_co_authors_display_bulk($art_ids);
$edit_flags = $this->kb->get_article_edit_flags_bulk($art_ids);

foreach ($art_rows as $art_row)
{
Expand Down Expand Up @@ -400,6 +401,8 @@ public function cat(): \Symfony\Component\HttpFoundation\Response
'S_CAN_DELETE' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_delete') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_delete')),
'S_CAN_EDIT' => $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || ($is_own_article && $this->kb->acl_kb_get_any($article_categories, 'kb_u_edit')),
'S_APPROVED' => (bool) $art_row['approved'],
'S_HAS_REVISION' => in_array((int) $art_row['status'], [functions_kb::STATUS_ACTIVE, functions_kb::STATUS_INACTIVE], true) && !empty($edit_flags[(int) $art_id]['revision']),
'S_HAS_DRAFT_EDIT' => ($is_own_article || $this->kb->acl_kb_get_any($article_categories, 'kb_m_edit') || $this->auth->acl_get('a_manage_kb')) && !empty($edit_flags[(int) $art_id]['draft']),
'STATUS' => (int) $art_row['status'],
'S_CROSS_LISTED' => !$flat_all && (int) $art_row['article_category_id'] !== (int) $cat_id,
'S_IS_REDIRECT' => (bool) $art_row['is_redirect'],
Expand Down
142 changes: 142 additions & 0 deletions controller/change_author.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php
/**
*
* Knowledge Base extension for the phpBB Forum Software package
*
* @copyright (c) 2026, phpBB Modders, https://www.phpbbmodders.com/
* @license GNU General Public License, version 2 (GPL-2.0)
*
*/
/**
* Permission-checked primary-author transfer form for submitted KB articles.
*/
namespace phpbbmodders\knowledgebase\controller;

use phpbbmodders\knowledgebase\inc\functions_kb;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;

class change_author
{
public function __construct(
protected \phpbb\db\driver\driver_interface $db,
protected \phpbb\controller\helper $helper,
protected \phpbb\language\language $language,
protected \phpbb\auth\auth $auth,
protected \phpbb\request\request_interface $request,
protected \phpbb\template\template $template,
protected \phpbb\user $user,
protected functions_kb $kb,
protected \phpbbmodders\knowledgebase\inc\author_change $author_change,
protected string $articles_table
)
{
}

/** Render the form, or validate a POST and return to the article. */
public function main(): Response
{
if ((int) $this->user->data['user_id'] === ANONYMOUS ||
(!$this->auth->acl_get('u_kb_view') && !$this->auth->acl_get('a_manage_kb')))
{
return new Response($this->language->lang('NOT_AUTHORISED'), 403);
}
$id = $this->request->variable('k', 0);
$result = $this->db->sql_query('SELECT * FROM ' . $this->articles_table . ' WHERE article_id = ' . (int) $id);
$article = $this->db->sql_fetchrow($result);
$this->db->sql_freeresult($result);
if (!$article || (int) $article['status'] === functions_kb::STATUS_DRAFT)
{
return new Response($this->language->lang('ARTICLE_NO_EXISTS'), 404);
}
if (!$this->kb->acl_kb_get_any($this->kb->get_article_categories($id), 'kb_m_change_author'))
{
return new Response($this->language->lang('NOT_AUTHORISED'), 403);
}
if ($this->kb->article_has_draft_edit($id))
{
$this->template->assign_vars([
'NOTICE_MESSAGE' => $this->language->lang('KB_CHANGE_AUTHOR_PRIVATE_DRAFT'),
'S_KNOWLEDGEBASE' => true,
]);
$this->kb->assign_nav_tabs_vars('', 0, $this->language->lang('KB_TAB_NOTICE'));
return $this->helper->render('@phpbbmodders_knowledgebase/kb_notice_body.html', $this->language->lang('KB_TAB_NOTICE'), 409);
}

$errors = [];
$type = $this->request->variable('author_type', functions_kb::AUTHOR_TYPE_USER);
$username = $this->request->variable('username', '', true);
$group_id = $this->request->variable('group_id', 0);
$teams = $this->kb->get_kb_team_group_names();
if ($this->request->is_set_post('submit'))
{
if (!check_form_key('kb_change_author'))
{
$errors[] = $this->language->lang('FORM_INVALID');
}
if ($this->request->variable('old_author_id', -1) !== (int) $article['author_id'] ||
$this->request->variable('old_author_type', -1) !== (int) $article['author_type'])
{
$errors[] = $this->language->lang('KB_CHANGE_AUTHOR_CONFLICT');
}
$target = $this->resolve_author($type, $username, $group_id, $teams);
if (!$target)
{
$errors[] = $this->language->lang('KB_CHANGE_AUTHOR_INVALID');
}
if (!$errors)
{
try
{
$this->author_change->transfer($article, $target['id'], $type, $target['name']);
return new RedirectResponse($this->helper->route('phpbbmodders_knowledgebase_article', ['k' => $id]));
}
catch (\RuntimeException $error)
{
if (!in_array($error->getMessage(), ['KB_CHANGE_AUTHOR_PRIVATE_DRAFT', 'KB_CHANGE_AUTHOR_CONFLICT', 'KB_CHANGE_AUTHOR_LOG_FAILED'], true))
{
throw $error;
}
$errors[] = $this->language->lang($error->getMessage());
}
}
}
add_form_key('kb_change_author');
foreach ($teams as $team_id => $name)
{
$this->template->assign_block_vars('author_team', ['ID' => $team_id, 'NAME' => $name, 'S_SELECTED' => $team_id === $group_id]);
}
$this->kb->assign_nav_tabs_vars('', 0, $this->language->lang('KB_CHANGE_AUTHOR'));
$this->template->assign_vars([
'S_KNOWLEDGEBASE' => true,
'ARTICLE_TITLE' => $article['article_title'],
'ARTICLE_AUTHOR' => $this->kb->get_author_display($article),
'OLD_AUTHOR_ID' => $article['author_id'], 'OLD_AUTHOR_TYPE' => $article['author_type'],
'AUTHOR_TYPE' => $type, 'AUTHOR_USERNAME' => $username,
'ERROR' => implode('<br>', $errors),
'U_CHANGE_AUTHOR' => $this->helper->route('phpbbmodders_knowledgebase_change_author', ['k' => $id]),
'U_ARTICLE' => $this->helper->route('phpbbmodders_knowledgebase_article', ['k' => $id]),
]);
return $this->helper->render('@phpbbmodders_knowledgebase/kb_change_author_body.html', $this->language->lang('KB_CHANGE_AUTHOR'));
}

/** Resolve only active human accounts or an explicitly configured KB team. */
protected function resolve_author(int $type, string $username, int $group_id, array $teams): ?array
{
if ($type === functions_kb::AUTHOR_TYPE_GROUP)
{
return isset($teams[$group_id]) ? ['id' => $group_id, 'name' => $teams[$group_id]] : null;
}
if ($type !== functions_kb::AUTHOR_TYPE_USER || trim($username) === '')
{
return null;
}
$result = $this->db->sql_query('SELECT user_id, username FROM ' . USERS_TABLE . "
WHERE username_clean = '" . $this->db->sql_escape(utf8_clean_string($username)) . "'
AND user_type IN (" . USER_NORMAL . ', ' . USER_FOUNDER . ')
AND user_id <> ' . ANONYMOUS);
$row = $this->db->sql_fetchrow($result);
$this->db->sql_freeresult($result);
return $row ? ['id' => (int) $row['user_id'], 'name' => $row['username']] : null;
}
}
30 changes: 19 additions & 11 deletions controller/search.php
Original file line number Diff line number Diff line change
Expand Up @@ -339,20 +339,28 @@ public function main(): \Symfony\Component\HttpFoundation\Response

if ($show_results === 'posts')
{
$text_only_message = $message = $row['article_body'];
if ($row['bbcode_uid'])
if (!empty($row['article_markdown']))
{
$text_only_message = str_replace('[*:' . $row['bbcode_uid'] . ']', '&sdot;&nbsp;', $text_only_message);

// no BBCode in text only message
strip_bbcode($text_only_message, $row['bbcode_uid']);
$row['article_body'] = get_context($text_only_message, array_filter(explode('|', $hilit), 'strlen'), $return_chars);
$row['article_body'] = bbcode_nl2br($row['article_body']);

$row['article_body'] = $this->article_text->search_display($row['article_body'], $row['bbcode_uid'], $return_chars);
}
if ($return_chars == -1 || utf8_strlen($text_only_message) < ($return_chars + 3))
else
{
$row['article_body'] = $this->article_text->display($message, $row['bbcode_uid'], !empty($row['article_markdown']), false);
$text_only_message = $message = $row['article_body'];
if ($row['bbcode_uid'])
{
$text_only_message = str_replace('[*:' . $row['bbcode_uid'] . ']', '&sdot;&nbsp;', $text_only_message);

// no BBCode in text only message
strip_bbcode($text_only_message, $row['bbcode_uid']);
$row['article_body'] = get_context($text_only_message, array_filter(explode('|', $hilit), 'strlen'), $return_chars);
$row['article_body'] = bbcode_nl2br($row['article_body']);

}
if ($return_chars == -1 || utf8_strlen($text_only_message) < ($return_chars + 3))
{
$row['article_body'] = $this->article_text->display($message, $row['bbcode_uid'], !empty($row['article_markdown']), false);
}

}

if ($hilit)
Expand Down
Binary file modified docs/images/screenshots/01-flat-index.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/02-article.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/03-manage-your-articles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/04-add-article.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/05-approval-queue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/06-search-results.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/07-acp-configuration.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/08-category-tree.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/09-board-search-kb-block.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/10-advanced-search-option.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/11-acp-search-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/screenshots/12-recent-changes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/screenshots/13-submit-redirect.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/screenshots/14-change-author.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/screenshots/16-markdown-search.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading