This commit is contained in:
Sven Steinert
2026-05-13 12:19:42 +02:00
parent f4511b9213
commit 3ff9146a63
13 changed files with 499 additions and 76 deletions

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Admin;
use KbMarkdownImporter\Repository\ProductRepository;
final class ProductsPage
{
public static function render(): void
{
if (! current_user_can('manage_kb_docs')) {
wp_die(esc_html__('You do not have permission to manage documentation products.', 'kb-markdown-importer'));
}
$repository = new ProductRepository();
self::handleActions($repository);
$products = $repository->allWithStats();
?>
<div class="wrap">
<h1><?php esc_html_e('Documentation Products', 'kb-markdown-importer'); ?></h1>
<p><?php esc_html_e('Manage imported products, repair their URL slugs, or remove a broken import from the frontend.', 'kb-markdown-importer'); ?></p>
<?php settings_errors('kb_markdown_products'); ?>
<table class="widefat striped">
<thead>
<tr>
<th><?php esc_html_e('Product', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Slug', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Versions', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Pages', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Actions', 'kb-markdown-importer'); ?></th>
</tr>
</thead>
<tbody>
<?php if (! $products) : ?>
<tr><td colspan="5"><?php esc_html_e('No products have been imported yet.', 'kb-markdown-importer'); ?></td></tr>
<?php endif; ?>
<?php foreach ($products as $item) : ?>
<?php
$term = $item['term'];
$formId = 'kb-markdown-product-' . (int) $term->term_id;
$versions = array_map(static fn (\WP_Term $version): string => $version->name, (array) $item['versions']);
?>
<tr>
<td>
<input form="<?php echo esc_attr($formId); ?>" class="regular-text" type="text" name="product_name" value="<?php echo esc_attr($term->name); ?>" aria-label="<?php esc_attr_e('Product name', 'kb-markdown-importer'); ?>">
</td>
<td>
<input form="<?php echo esc_attr($formId); ?>" class="regular-text" type="text" name="product_slug" value="<?php echo esc_attr($term->slug); ?>" aria-label="<?php esc_attr_e('Product slug', 'kb-markdown-importer'); ?>">
</td>
<td><?php echo esc_html($versions ? implode(', ', $versions) : __('No versions', 'kb-markdown-importer')); ?></td>
<td><?php echo esc_html((string) $item['page_count']); ?></td>
<td>
<form id="<?php echo esc_attr($formId); ?>" method="post" action="">
<?php wp_nonce_field('kb_markdown_update_product_' . $term->term_id); ?>
<input type="hidden" name="kb_markdown_product_action" value="update">
<input type="hidden" name="term_id" value="<?php echo esc_attr((string) $term->term_id); ?>">
<?php submit_button(__('Save', 'kb-markdown-importer'), 'secondary small', 'submit', false); ?>
</form>
<form method="post" action="" style="margin-top:8px;" onsubmit="return window.confirm('<?php echo esc_js(__('Move this product and its imported pages to the trash?', 'kb-markdown-importer')); ?>');">
<?php wp_nonce_field('kb_markdown_delete_product_' . $term->term_id); ?>
<input type="hidden" name="kb_markdown_product_action" value="delete">
<input type="hidden" name="term_id" value="<?php echo esc_attr((string) $term->term_id); ?>">
<label>
<input type="checkbox" name="trash_pages" value="1" checked>
<?php esc_html_e('Trash imported pages', 'kb-markdown-importer'); ?>
</label>
<?php submit_button(__('Delete product', 'kb-markdown-importer'), 'delete small', 'submit', false); ?>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
private static function handleActions(ProductRepository $repository): void
{
$action = sanitize_key(wp_unslash((string) ($_POST['kb_markdown_product_action'] ?? '')));
if (! $action) {
return;
}
$termId = absint($_POST['term_id'] ?? 0);
if (! $termId) {
add_settings_error('kb_markdown_products', 'missing_term', __('Missing product ID.', 'kb-markdown-importer'), 'error');
return;
}
if ('update' === $action) {
check_admin_referer('kb_markdown_update_product_' . $termId);
$result = $repository->update(
$termId,
sanitize_text_field(wp_unslash((string) ($_POST['product_name'] ?? ''))),
sanitize_title(wp_unslash((string) ($_POST['product_slug'] ?? '')))
);
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'update_failed', $result->get_error_message(), 'error');
return;
}
add_settings_error('kb_markdown_products', 'updated', __('Product saved.', 'kb-markdown-importer'), 'success');
return;
}
if ('delete' === $action) {
check_admin_referer('kb_markdown_delete_product_' . $termId);
$trashPages = ! empty($_POST['trash_pages']);
$result = $repository->deleteProduct($termId, $trashPages);
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'delete_failed', $result->get_error_message(), 'error');
return;
}
add_settings_error('kb_markdown_products', 'deleted', __('Product deleted.', 'kb-markdown-importer'), 'success');
}
}
}

View File

@@ -143,11 +143,7 @@ final class Router
public static function shortcodeDocsIndex(): string
{
return (new TemplateLoader())->capture('documentation-index', [
'products' => self::productsWithVersions(),
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
return (new self())->captureRoute('index');
}
public static function shortcodeDocsApp(array $atts = []): string
@@ -188,12 +184,12 @@ final class Router
$atts = shortcode_atts(['product' => ''], $atts, 'kb_product_index');
$router = new self();
return $router->captureProduct((string) $atts['product']);
return $router->captureRoute('product', sanitize_title((string) $atts['product']));
}
private function renderIndex(): void
{
echo $this->captureIndex();
echo $this->captureRoute('index');
}
private function captureIndex(): string
@@ -207,7 +203,7 @@ final class Router
private function renderProduct(string $productSlug): void
{
echo $this->captureProduct($productSlug);
echo $this->captureRoute('product', $productSlug);
}
private function captureProduct(string $productSlug): string
@@ -229,7 +225,7 @@ final class Router
private function renderVersion(string $productSlug, string $versionSlug): void
{
echo $this->captureVersion($productSlug, $versionSlug);
echo $this->captureRoute('version', $productSlug, $versionSlug);
}
private function captureVersion(string $productSlug, string $versionSlug): string
@@ -257,7 +253,7 @@ final class Router
private function renderPage(string $productSlug, string $versionSlug, string $pageSlug): void
{
echo $this->capturePage($productSlug, $versionSlug, $pageSlug);
echo $this->captureRoute('page', $productSlug, $versionSlug, $pageSlug);
}
private function capturePage(string $productSlug, string $versionSlug, string $pageSlug): string
@@ -301,13 +297,35 @@ final class Router
private function captureRoute(string $route, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
return match ($route) {
$content = match ($route) {
'index' => $this->captureIndex(),
'product' => $productSlug ? $this->captureProduct($productSlug) : $this->captureIndex(),
'version' => ($productSlug && $versionSlug) ? $this->captureVersion($productSlug, $versionSlug) : $this->captureIndex(),
'page' => ($productSlug && $versionSlug) ? $this->capturePage($productSlug, $versionSlug, $pageSlug) : $this->captureIndex(),
default => $this->captureIndex(),
};
return $this->captureShell($content, $productSlug, $versionSlug, $pageSlug);
}
private function captureShell(string $content, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
$activePages = [];
if ($productSlug && $versionSlug) {
$activePages = $this->pagesForVersion($productSlug, $versionSlug);
}
return (new TemplateLoader())->capture('docs-app', [
'content' => $content,
'products' => self::productsWithVersions(),
'active_product_slug' => $productSlug,
'active_version_slug' => $versionSlug,
'active_page_slug' => $pageSlug,
'active_pages' => $activePages,
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
}
private function render404(): void

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace KbMarkdownImporter;
use KbMarkdownImporter\Admin\SettingsPage;
use KbMarkdownImporter\Admin\ProductsPage;
use KbMarkdownImporter\Admin\StatusPage;
use KbMarkdownImporter\Admin\SyncPage;
use KbMarkdownImporter\Frontend\Router;
@@ -119,6 +120,7 @@ final class Plugin
);
add_submenu_page('kb-markdown-importer', __('Overview', 'kb-markdown-importer'), __('Overview', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-importer', [StatusPage::class, 'render']);
add_submenu_page('kb-markdown-importer', __('Products', 'kb-markdown-importer'), __('Products', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-products', [ProductsPage::class, 'render']);
add_submenu_page('kb-markdown-importer', __('Synchronization', 'kb-markdown-importer'), __('Synchronization', 'kb-markdown-importer'), 'sync_kb_docs', 'kb-markdown-sync', [SyncPage::class, 'render']);
add_submenu_page('kb-markdown-importer', __('Settings', 'kb-markdown-importer'), __('Settings', 'kb-markdown-importer'), 'manage_kb_docs', 'kb-markdown-settings', [SettingsPage::class, 'render']);
}

View File

@@ -16,4 +16,117 @@ final class ProductRepository
return is_wp_error($term) ? 0 : (int) ($term['term_id'] ?? $term);
}
public function allWithStats(): array
{
$terms = get_terms([
'taxonomy' => 'kb_product',
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
]);
if (is_wp_error($terms)) {
return [];
}
$items = [];
foreach ($terms as $term) {
$pageIds = $this->pageIdsForProduct((int) $term->term_id);
$versions = [];
foreach ($pageIds as $pageId) {
$pageVersions = wp_get_object_terms($pageId, 'kb_version');
if (is_wp_error($pageVersions)) {
continue;
}
foreach ($pageVersions as $version) {
$versions[$version->slug] = $version;
}
}
uasort($versions, static fn ($a, $b): int => strnatcasecmp($b->name, $a->name));
$items[] = [
'term' => $term,
'page_count' => count($pageIds),
'versions' => array_values($versions),
];
}
return $items;
}
public function update(int $termId, string $name, string $slug): \WP_Term|\WP_Error
{
$name = trim($name);
$slug = sanitize_title($slug ?: $name);
if ('' === $name || '' === $slug) {
return new \WP_Error('kb_product_invalid', __('Product name and slug are required.', 'kb-markdown-importer'));
}
$updated = wp_update_term($termId, 'kb_product', [
'name' => $name,
'slug' => $slug,
]);
if (is_wp_error($updated)) {
return $updated;
}
foreach ($this->pageIdsForProduct($termId) as $pageId) {
update_post_meta($pageId, '_kb_product_slug', $slug);
}
$term = get_term((int) $updated['term_id'], 'kb_product');
return $term instanceof \WP_Term ? $term : new \WP_Error('kb_product_missing', __('Product could not be loaded after update.', 'kb-markdown-importer'));
}
public function trashProductPages(int $termId): int
{
$count = 0;
foreach ($this->pageIdsForProduct($termId, ['publish', 'draft', 'private', 'pending', 'future']) as $pageId) {
if (wp_trash_post($pageId)) {
++$count;
}
}
return $count;
}
public function deleteProduct(int $termId, bool $trashPages): true|\WP_Error
{
if ($trashPages) {
$this->trashProductPages($termId);
}
$deleted = wp_delete_term($termId, 'kb_product');
if (is_wp_error($deleted)) {
return $deleted;
}
return true;
}
private function pageIdsForProduct(int $termId, array $postStatus = ['publish']): array
{
$query = new \WP_Query([
'post_type' => 'kb_doc_page',
'post_status' => $postStatus,
'posts_per_page' => -1,
'fields' => 'ids',
'tax_query' => [
['taxonomy' => 'kb_product', 'field' => 'term_id', 'terms' => $termId],
],
]);
return array_map('intval', $query->posts);
}
}