new file: olm-login.php

This commit is contained in:
Sven Steinert
2026-05-27 14:17:22 +02:00
parent 1d4cf6e727
commit e99acdce47
25 changed files with 36226 additions and 630 deletions

View File

@@ -10,33 +10,40 @@
}
.kb-docs-wrap {
max-width: 1320px;
margin: 0 auto;
padding: 24px 20px 48px;
position: relative;
left: 50%;
width: calc(100vw - 16px);
max-width: none !important;
margin: 0 0 0 calc(-50vw + 8px);
padding: 20px clamp(14px, 2.8vw, 44px) 56px;
box-sizing: border-box;
background: color-mix(in srgb, var(--kb-surface-muted) 72%, #ffffff);
}
.kb-docs-app {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 32px;
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
gap: clamp(20px, 2.2vw, 34px);
align-items: start;
}
.kb-app-sidebar {
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
top: 16px;
max-height: calc(100vh - 32px);
overflow: auto;
border: 1px solid var(--kb-border);
border-radius: 8px;
padding: 16px;
padding: 14px;
background: var(--kb-surface);
scrollbar-gutter: stable;
}
.kb-app-sidebar__brand {
margin-bottom: 14px;
font-size: 18px;
font-weight: 700;
margin-bottom: 12px;
padding: 0 2px;
font-size: 17px;
font-weight: 800;
}
.kb-app-sidebar__brand a {
@@ -60,6 +67,27 @@
display: none;
}
.kb-app-active-nav {
position: sticky;
top: 0;
z-index: 2;
margin: 0 -4px 14px;
border: 1px solid color-mix(in srgb, var(--kb-accent) 28%, var(--kb-border));
border-left: 4px solid var(--kb-accent);
border-radius: 8px;
padding: 11px 10px 12px;
background: var(--kb-surface);
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.1);
}
.kb-app-active-nav__title {
display: block;
margin-bottom: 9px;
color: var(--kb-text);
font-weight: 800;
text-decoration: none;
}
.kb-app-nav,
.kb-app-nav ul {
margin: 0;
@@ -67,11 +95,32 @@
list-style: none;
}
.kb-app-product {
padding: 10px 0;
.kb-app-nav__title {
margin: 2px 0 8px;
padding: 0 2px;
color: var(--kb-text);
font-size: 14px;
font-weight: 800;
}
.kb-app-category {
padding: 9px 0;
border-top: 1px solid var(--kb-border);
}
.kb-app-category h3 {
margin: 0 0 6px;
color: var(--kb-muted);
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.kb-app-product {
padding: 2px 0;
}
.kb-app-product:first-child {
border-top: 0;
}
@@ -85,17 +134,26 @@
}
.kb-app-product__link {
padding: 8px 10px;
padding: 7px 9px;
font-weight: 700;
}
.kb-app-page-list a {
padding: 6px 10px 6px 22px;
padding: 6px 8px 6px 12px;
color: var(--kb-muted);
font-size: 14px;
line-height: 1.35;
}
.kb-app-page-list__part,
.kb-page-list__part {
display: block;
color: var(--kb-muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.kb-app-product__link:hover,
.kb-app-page-list a:hover,
.kb-app-product.is-active > .kb-app-product__link,
@@ -106,6 +164,7 @@
.kb-app-main {
min-width: 0;
width: 100%;
}
.kb-docs-home > h1,
@@ -114,10 +173,31 @@
margin-top: 0;
}
.kb-docs-home,
.kb-docs-product {
width: 100%;
min-width: 0;
}
.kb-docs-home > h1,
.kb-docs-product > h1 {
margin-bottom: 18px;
font-size: 38px;
line-height: 1.1;
}
.kb-docs-product {
border: 1px solid var(--kb-border);
border-radius: var(--kb-radius, 8px);
padding: clamp(22px, 2.7vw, 42px);
background: var(--kb-surface);
box-shadow: var(--kb-shadow, 0 1px 2px rgba(16, 24, 40, 0.04));
}
.kb-product-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.kb-home-card,
@@ -132,9 +212,9 @@
.kb-docs-home-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.8fr);
gap: 16px;
margin-bottom: 16px;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
margin-bottom: 18px;
}
.kb-home-card h2 {
@@ -153,6 +233,23 @@
list-style: none;
}
.kb-product-updates__month {
margin-top: 18px;
}
.kb-product-updates__month:first-of-type {
margin-top: 0;
}
.kb-docs-wrap .kb-product-updates__month h3 {
margin: 0 0 10px;
border-radius: 6px;
padding: 8px 10px;
background: var(--kb-accent);
color: #fff;
font-size: 16px;
}
.kb-product-updates__list li {
padding: 12px 0;
border-top: 1px solid var(--kb-border);
@@ -174,6 +271,15 @@
color: var(--kb-text);
}
.kb-product-updates__meta strong a {
color: inherit;
text-decoration: none;
}
.kb-product-updates__meta strong a:hover {
color: var(--kb-accent);
}
.kb-product-updates__meta span,
.kb-product-updates__meta time,
.kb-empty-state {
@@ -181,11 +287,65 @@
font-size: 14px;
}
.kb-product-card h2 {
.kb-product-updates__changes {
margin: 8px 0 0 18px;
padding: 0;
}
.kb-product-updates__changes li {
padding: 0;
border: 0;
}
.kb-product-updates__details {
display: grid;
gap: 4px;
margin: 10px 0 0;
font-size: 14px;
}
.kb-product-updates__details div {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 10px;
}
.kb-product-updates__details dt {
color: var(--kb-muted);
font-weight: 700;
}
.kb-product-updates__details dd {
margin: 0;
}
.kb-product-category {
margin-top: 26px;
}
.kb-product-category:first-child {
margin-top: 0;
}
.kb-product-category > h2 {
margin: 0 0 12px;
color: var(--kb-muted);
font-size: 15px;
text-transform: uppercase;
}
.kb-product-card h2,
.kb-product-card h3 {
margin-top: 0;
font-size: 20px;
}
.kb-product-card__parts,
.kb-product-parts {
color: var(--kb-muted);
font-size: 14px;
}
.kb-product-card ul,
.kb-version-list,
.kb-page-list {
@@ -200,10 +360,28 @@
margin: 7px 0;
}
.kb-version-list a,
.kb-page-list a {
display: block;
border: 1px solid var(--kb-border);
border-radius: 8px;
padding: 11px 12px;
background: color-mix(in srgb, var(--kb-surface) 92%, var(--kb-surface-muted));
color: var(--kb-text);
text-decoration: none;
}
.kb-version-list a:hover,
.kb-page-list a:hover {
border-color: color-mix(in srgb, var(--kb-accent) 38%, var(--kb-border));
background: var(--kb-accent-soft);
color: var(--kb-accent);
}
.kb-doc-layout {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 40px;
grid-template-columns: 280px minmax(0, 1fr);
gap: clamp(22px, 2.8vw, 42px);
align-items: start;
}
@@ -281,11 +459,12 @@
.kb-doc-content,
.kb-docs-version {
max-width: 860px;
width: 100%;
max-width: none;
min-width: 0;
border: 1px solid var(--kb-border);
border-radius: var(--kb-radius, 8px);
padding: 22px;
padding: clamp(22px, 2.7vw, 42px);
background: var(--kb-surface);
box-shadow: var(--kb-shadow, 0 1px 2px rgba(16, 24, 40, 0.04));
}
@@ -305,7 +484,7 @@
.kb-doc-header h1 {
margin-top: 0;
margin-bottom: 0;
font-size: clamp(28px, 4vw, 42px);
font-size: 38px;
line-height: 1.1;
}
@@ -377,6 +556,13 @@
}
@media (max-width: 780px) {
.kb-docs-wrap {
left: auto;
width: 100%;
margin: 0;
padding: 18px 14px 36px;
}
.kb-docs-app {
grid-template-columns: 1fr;
}
@@ -407,6 +593,12 @@
display: block;
}
.kb-doc-header h1,
.kb-docs-home > h1,
.kb-docs-product > h1 {
font-size: 30px;
}
.kb-version-switcher {
margin-top: 16px;
}

View File

@@ -36,8 +36,8 @@
--kb-border: rgba(52, 53, 55, 0.15);
--kb-accent: var(--kb-primary);
--kb-accent-soft: rgba(0, 167, 230, 0.1);
--kb-radius: 14px;
--kb-shadow: 0 18px 40px rgba(52, 53, 55, 0.11);
--kb-radius: 8px;
--kb-shadow: 0 10px 26px rgba(52, 53, 55, 0.09);
--kb-font-body: "Nunito", "Segoe UI", Arial, sans-serif;
--kb-font-heading: "Nunito", "Segoe UI", Arial, sans-serif;
--kb-font-strong: "URW Gothic L Demi", "Nunito", "Segoe UI", Arial, sans-serif;
@@ -79,7 +79,7 @@
}
.kb-app-sidebar {
background: rgba(255, 255, 255, 0.96);
background: rgba(255, 255, 255, 0.98);
}
.kb-app-sidebar__brand a {
@@ -89,7 +89,7 @@
.kb-app-product__link,
.kb-app-page-list a {
border-radius: 11px;
border-radius: 7px;
}
.kb-app-product__link:hover,
@@ -100,12 +100,14 @@
color: var(--kb-primary-shade);
}
.kb-product-card h2 a {
.kb-product-card h2 a,
.kb-product-card h3 a {
color: var(--kb-text);
text-decoration: none;
}
.kb-product-card h2 a:hover {
.kb-product-card h2 a:hover,
.kb-product-card h3 a:hover {
color: var(--kb-primary);
}
@@ -139,7 +141,7 @@
}
.kb-rendered-content h2 {
font-size: clamp(26px, 3vw, 32px);
font-size: 30px;
line-height: 1.12;
font-weight: 700;
}

View File

@@ -19,107 +19,153 @@ final class ProductsPage
?>
<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>
<p><?php esc_html_e('Manage imported products, group them into frontend products, and assign categories.', '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']);
?>
<form method="post" action="">
<?php wp_nonce_field('kb_markdown_save_all_products'); ?>
<table class="widefat striped">
<thead>
<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>
<th><?php esc_html_e('Imported product', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Frontend product', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Documentation part', 'kb-markdown-importer'); ?></th>
<th><?php esc_html_e('Category', '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('Delete', 'kb-markdown-importer'); ?></th>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</thead>
<tbody>
<?php if (! $products) : ?>
<tr><td colspan="7"><?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'];
$meta = (array) $item['meta'];
$versions = array_map(static fn (\WP_Term $version): string => $version->name, (array) $item['versions']);
?>
<tr>
<td>
<input type="hidden" name="products[<?php echo esc_attr((string) $term->term_id); ?>][term_id]" value="<?php echo esc_attr((string) $term->term_id); ?>">
<strong><?php echo esc_html($term->name); ?></strong>
<br>
<code><?php echo esc_html($term->slug); ?></code>
<input type="hidden" name="products[<?php echo esc_attr((string) $term->term_id); ?>][product_name]" value="<?php echo esc_attr($term->name); ?>">
<input type="hidden" name="products[<?php echo esc_attr((string) $term->term_id); ?>][product_slug]" value="<?php echo esc_attr($term->slug); ?>">
</td>
<td>
<label class="screen-reader-text" for="group_name_<?php echo esc_attr((string) $term->term_id); ?>"><?php esc_html_e('Frontend product name', 'kb-markdown-importer'); ?></label>
<input id="group_name_<?php echo esc_attr((string) $term->term_id); ?>" class="regular-text" type="text" name="products[<?php echo esc_attr((string) $term->term_id); ?>][group_name]" value="<?php echo esc_attr((string) $meta['group_name']); ?>" placeholder="<?php echo esc_attr($term->name); ?>">
<br>
<label class="screen-reader-text" for="group_slug_<?php echo esc_attr((string) $term->term_id); ?>"><?php esc_html_e('Frontend product slug', 'kb-markdown-importer'); ?></label>
<input id="group_slug_<?php echo esc_attr((string) $term->term_id); ?>" class="regular-text" type="text" name="products[<?php echo esc_attr((string) $term->term_id); ?>][group_slug]" value="<?php echo esc_attr((string) $meta['group_slug']); ?>" placeholder="<?php echo esc_attr($term->slug); ?>">
</td>
<td>
<input class="regular-text" type="text" name="products[<?php echo esc_attr((string) $term->term_id); ?>][part_label]" value="<?php echo esc_attr((string) $meta['part_label']); ?>" placeholder="<?php esc_attr_e('App, Modul, Exporter', 'kb-markdown-importer'); ?>">
</td>
<td>
<input class="regular-text" type="text" name="products[<?php echo esc_attr((string) $term->term_id); ?>][category]" value="<?php echo esc_attr((string) $meta['category']); ?>" placeholder="<?php esc_attr_e('CRM, Telefonie, Integration', '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>
<label>
<input type="checkbox" name="delete_terms[]" value="<?php echo esc_attr((string) $term->term_id); ?>">
<?php esc_html_e('Trash pages and remove product', 'kb-markdown-importer'); ?>
</label>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php submit_button(__('Save all products', 'kb-markdown-importer'), 'primary', 'kb_markdown_save_all_products', false); ?>
<?php submit_button(__('Delete selected', 'kb-markdown-importer'), 'delete', 'kb_markdown_delete_selected_products', false, [
'onclick' => "return window.confirm('" . esc_js(__('Move selected products and their imported pages to the trash?', 'kb-markdown-importer')) . "');",
]); ?>
</form>
</div>
<?php
}
private static function handleActions(ProductRepository $repository): void
{
$action = sanitize_key(wp_unslash((string) ($_POST['kb_markdown_product_action'] ?? '')));
$action = '';
if (isset($_POST['kb_markdown_save_all_products'])) {
$action = 'save_all';
} elseif (isset($_POST['kb_markdown_delete_selected_products'])) {
$action = 'delete_selected';
} else {
$action = sanitize_key(wp_unslash((string) ($_POST['kb_markdown_product_action'] ?? '')));
}
if (! $action) {
return;
}
$termId = absint($_POST['term_id'] ?? 0);
if ('save_all' === $action) {
check_admin_referer('kb_markdown_save_all_products');
$products = (array) ($_POST['products'] ?? []);
$saved = 0;
if (! $termId) {
add_settings_error('kb_markdown_products', 'missing_term', __('Missing product ID.', 'kb-markdown-importer'), 'error');
foreach ($products as $rawTermId => $rawProduct) {
$termId = absint($rawTermId);
$rawProduct = (array) $rawProduct;
if (! $termId) {
continue;
}
$result = $repository->update(
$termId,
sanitize_text_field(wp_unslash((string) ($rawProduct['product_name'] ?? ''))),
sanitize_title(wp_unslash((string) ($rawProduct['product_slug'] ?? ''))),
[
'group_name' => sanitize_text_field(wp_unslash((string) ($rawProduct['group_name'] ?? ''))),
'group_slug' => sanitize_title(wp_unslash((string) ($rawProduct['group_slug'] ?? ''))),
'part_label' => sanitize_text_field(wp_unslash((string) ($rawProduct['part_label'] ?? ''))),
'category' => sanitize_text_field(wp_unslash((string) ($rawProduct['category'] ?? ''))),
]
);
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'update_failed_' . $termId, $result->get_error_message(), 'error');
continue;
}
$saved++;
}
add_settings_error('kb_markdown_products', 'updated', sprintf(
/* translators: %d: number of saved products. */
__('Saved %d products.', 'kb-markdown-importer'),
$saved
), 'success');
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 ('delete_selected' === $action) {
check_admin_referer('kb_markdown_save_all_products');
$termIds = array_map('absint', (array) ($_POST['delete_terms'] ?? []));
$deleted = 0;
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'update_failed', $result->get_error_message(), 'error');
return;
foreach (array_filter($termIds) as $termId) {
$result = $repository->deleteProduct($termId, true);
if (is_wp_error($result)) {
add_settings_error('kb_markdown_products', 'delete_failed_' . $termId, $result->get_error_message(), 'error');
continue;
}
$deleted++;
}
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');
add_settings_error('kb_markdown_products', 'deleted', sprintf(
/* translators: %d: number of deleted products. */
__('Deleted %d products.', 'kb-markdown-importer'),
$deleted
), 'success');
}
}
}

View File

@@ -5,6 +5,7 @@ namespace KbMarkdownImporter\Admin;
use KbMarkdownImporter\GitLab\GitLabClient;
use KbMarkdownImporter\Import\ImportLogger;
use KbMarkdownImporter\Olm\ChangelogSync;
use KbMarkdownImporter\Plugin;
use KbMarkdownImporter\Settings;
@@ -40,20 +41,12 @@ final class SettingsPage
$settings['custom_theme_css_url'] = esc_url_raw((string) ($input['custom_theme_css_url'] ?? ''));
$settings['docs_home_intro_title'] = sanitize_text_field((string) ($input['docs_home_intro_title'] ?? $settings['docs_home_intro_title']));
$settings['docs_home_intro_content'] = wp_kses_post((string) ($input['docs_home_intro_content'] ?? $settings['docs_home_intro_content']));
$settings['product_updates_source'] = in_array(($input['product_updates_source'] ?? 'rss'), ['rss', 'rest'], true) ? (string) $input['product_updates_source'] : 'rss';
$settings['product_updates_feed_url'] = esc_url_raw((string) ($input['product_updates_feed_url'] ?? ''));
$settings['product_updates_feed_limit'] = (string) max(1, min(20, (int) ($input['product_updates_feed_limit'] ?? 5)));
$settings['product_updates_feed_item_path'] = self::sanitizeXmlPath((string) ($input['product_updates_feed_item_path'] ?? 'channel/item'), 'channel/item');
$settings['product_updates_feed_product_field'] = self::sanitizeXmlPath((string) ($input['product_updates_feed_product_field'] ?? 'title'), 'title');
$settings['product_updates_feed_version_field'] = self::sanitizeXmlPath((string) ($input['product_updates_feed_version_field'] ?? 'category'), 'category');
$settings['product_updates_feed_date_field'] = self::sanitizeXmlPath((string) ($input['product_updates_feed_date_field'] ?? 'pubDate'), 'pubDate');
$settings['product_updates_feed_changelog_field'] = self::sanitizeXmlPath((string) ($input['product_updates_feed_changelog_field'] ?? 'description'), 'description');
$settings['product_updates_rest_url'] = esc_url_raw((string) ($input['product_updates_rest_url'] ?? ''));
$settings['product_updates_rest_list_path'] = self::sanitizePathList((string) ($input['product_updates_rest_list_path'] ?? 'content,data,items'), 'content,data,items');
$settings['product_updates_rest_product_field'] = self::sanitizePathList((string) ($input['product_updates_rest_product_field'] ?? 'product.name,productName,name'), 'product.name,productName,name');
$settings['product_updates_rest_version_field'] = self::sanitizePathList((string) ($input['product_updates_rest_version_field'] ?? 'version,versionName,name'), 'version,versionName,name');
$settings['product_updates_rest_date_field'] = self::sanitizePathList((string) ($input['product_updates_rest_date_field'] ?? 'releaseDate,date,updatedAt,createdAt'), 'releaseDate,date,updatedAt,createdAt');
$settings['product_updates_rest_changelog_field'] = self::sanitizePathList((string) ($input['product_updates_rest_changelog_field'] ?? 'changelog,changeLog,description,changes'), 'changelog,changeLog,description,changes');
$settings['product_updates_source'] = 'olm_changelog';
$settings['product_updates_olm_months'] = (string) max(1, min(24, (int) ($input['product_updates_olm_months'] ?? 4)));
$settings['product_updates_olm_ignore_numbers'] = self::sanitizeOlmNumberList((string) ($input['product_updates_olm_ignore_numbers'] ?? 'olm-10109,olm-10110'));
$settings['olm_base_url'] = esc_url_raw(ChangelogSync::normalizeBaseUrl((string) ($input['olm_base_url'] ?? '')));
$settings['olm_username'] = sanitize_text_field((string) ($input['olm_username'] ?? ''));
$settings['olm_password'] = trim((string) ($input['olm_password'] ?? '')) ?: (string) $old['olm_password'];
Plugin::syncCronSchedule($settings);
if (($old['docs_base_slug'] ?? 'docs') !== $settings['docs_base_slug']) {
@@ -155,93 +148,33 @@ final class SettingsPage
</td>
</tr>
<tr>
<th scope="row"><label for="product_updates_source"><?php esc_html_e('Update-Quelle', 'kb-markdown-importer'); ?></label></th>
<th scope="row"><label for="product_updates_olm_months"><?php esc_html_e('OLM Zeitraum', 'kb-markdown-importer'); ?></label></th>
<td>
<select id="product_updates_source" name="kb_markdown_importer_settings[product_updates_source]">
<option value="rss" <?php selected($settings['product_updates_source'], 'rss'); ?>><?php esc_html_e('RSS/XML', 'kb-markdown-importer'); ?></option>
<option value="rest" <?php selected($settings['product_updates_source'], 'rest'); ?>><?php esc_html_e('REST/JSON', 'kb-markdown-importer'); ?></option>
</select>
<input id="product_updates_olm_months" name="kb_markdown_importer_settings[product_updates_olm_months]" type="number" min="1" max="24" value="<?php echo esc_attr($settings['product_updates_olm_months']); ?>"> <?php esc_html_e('Monate zurück ab Monatsanfang', 'kb-markdown-importer'); ?>
<p class="description"><?php esc_html_e('Entspricht dem Python-Script: aktueller Monat plus die angegebene Anzahl vorheriger Monate.', 'kb-markdown-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="product_updates_feed_url"><?php esc_html_e('RSS/XML-Feed URL', 'kb-markdown-importer'); ?></label></th>
<th scope="row"><label for="product_updates_olm_ignore_numbers"><?php esc_html_e('OLM Nummern ignorieren', 'kb-markdown-importer'); ?></label></th>
<td>
<input class="regular-text" id="product_updates_feed_url" name="kb_markdown_importer_settings[product_updates_feed_url]" type="url" value="<?php echo esc_attr($settings['product_updates_feed_url']); ?>" placeholder="https://example.com/updates.xml">
<p class="description"><?php esc_html_e('RSS- oder XML-Feed mit den neuesten Produktupdates. Wird nur genutzt, wenn RSS/XML als Quelle ausgewählt ist.', 'kb-markdown-importer'); ?></p>
<input class="regular-text" id="product_updates_olm_ignore_numbers" name="kb_markdown_importer_settings[product_updates_olm_ignore_numbers]" type="text" value="<?php echo esc_attr($settings['product_updates_olm_ignore_numbers']); ?>" placeholder="olm-10109,olm-10110">
<p class="description"><?php esc_html_e('Kommagetrennte productNo-Liste, die nicht im Changelog erscheinen soll.', 'kb-markdown-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="product_updates_rest_url"><?php esc_html_e('REST/JSON URL', 'kb-markdown-importer'); ?></label></th>
<th scope="row"><label for="olm_base_url"><?php esc_html_e('OLM Basis-URL', 'kb-markdown-importer'); ?></label></th>
<td>
<input class="regular-text" id="product_updates_rest_url" name="kb_markdown_importer_settings[product_updates_rest_url]" type="url" value="<?php echo esc_attr($settings['product_updates_rest_url']); ?>" placeholder="https://example.com/api/product-versions">
<p class="description"><?php esc_html_e('REST-Endpunkt mit JSON-Antwort. Wird nur genutzt, wenn REST/JSON als Quelle ausgewählt ist.', 'kb-markdown-importer'); ?></p>
<input class="regular-text" id="olm_base_url" name="kb_markdown_importer_settings[olm_base_url]" type="url" value="<?php echo esc_attr($settings['olm_base_url']); ?>" placeholder="https://olm.o-byte.com">
<p class="description"><?php esc_html_e('Wird für den OLM-Changelog-Sync nach dem Python-Script verwendet.', 'kb-markdown-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="product_updates_feed_limit"><?php esc_html_e('Anzahl Updates', 'kb-markdown-importer'); ?></label></th>
<td><input id="product_updates_feed_limit" name="kb_markdown_importer_settings[product_updates_feed_limit]" type="number" min="1" max="20" value="<?php echo esc_attr($settings['product_updates_feed_limit']); ?>"></td>
<th scope="row"><label for="olm_username"><?php esc_html_e('OLM Benutzername', 'kb-markdown-importer'); ?></label></th>
<td><input class="regular-text" id="olm_username" name="kb_markdown_importer_settings[olm_username]" type="text" value="<?php echo esc_attr($settings['olm_username']); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="product_updates_feed_item_path"><?php esc_html_e('Eintrag-Pfad', 'kb-markdown-importer'); ?></label></th>
<td>
<input class="regular-text" id="product_updates_feed_item_path" name="kb_markdown_importer_settings[product_updates_feed_item_path]" type="text" value="<?php echo esc_attr($settings['product_updates_feed_item_path']); ?>" placeholder="channel/item">
<p class="description"><?php esc_html_e('Pfad zum wiederholten Feed-Eintrag, zum Beispiel channel/item.', 'kb-markdown-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="product_updates_rest_list_path"><?php esc_html_e('REST Listenpfad', 'kb-markdown-importer'); ?></label></th>
<td>
<input class="regular-text" id="product_updates_rest_list_path" name="kb_markdown_importer_settings[product_updates_rest_list_path]" type="text" value="<?php echo esc_attr($settings['product_updates_rest_list_path']); ?>" placeholder="content,data,items">
<p class="description"><?php esc_html_e('Pfad zur Liste in der JSON-Antwort. Mehrere Alternativen mit Komma trennen. Leer lassen, wenn die Antwort direkt ein Array ist.', 'kb-markdown-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('XML-Felder', 'kb-markdown-importer'); ?></th>
<td>
<fieldset class="kb-feed-fields">
<p>
<label for="product_updates_feed_product_field"><?php esc_html_e('Produktname', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_feed_product_field" name="kb_markdown_importer_settings[product_updates_feed_product_field]" type="text" value="<?php echo esc_attr($settings['product_updates_feed_product_field']); ?>" placeholder="title">
</p>
<p>
<label for="product_updates_feed_version_field"><?php esc_html_e('Version', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_feed_version_field" name="kb_markdown_importer_settings[product_updates_feed_version_field]" type="text" value="<?php echo esc_attr($settings['product_updates_feed_version_field']); ?>" placeholder="category">
</p>
<p>
<label for="product_updates_feed_date_field"><?php esc_html_e('Datum', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_feed_date_field" name="kb_markdown_importer_settings[product_updates_feed_date_field]" type="text" value="<?php echo esc_attr($settings['product_updates_feed_date_field']); ?>" placeholder="pubDate">
</p>
<p>
<label for="product_updates_feed_changelog_field"><?php esc_html_e('Changelog', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_feed_changelog_field" name="kb_markdown_importer_settings[product_updates_feed_changelog_field]" type="text" value="<?php echo esc_attr($settings['product_updates_feed_changelog_field']); ?>" placeholder="description">
</p>
</fieldset>
<p class="description"><?php esc_html_e('Feldpfade relativ zum Eintrag, zum Beispiel product/name, version oder changelog. Namespaces wie dc:date werden unterstützt.', 'kb-markdown-importer'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('REST-Felder', 'kb-markdown-importer'); ?></th>
<td>
<fieldset class="kb-rest-fields">
<p>
<label for="product_updates_rest_product_field"><?php esc_html_e('Produktname', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_rest_product_field" name="kb_markdown_importer_settings[product_updates_rest_product_field]" type="text" value="<?php echo esc_attr($settings['product_updates_rest_product_field']); ?>" placeholder="product.name,productName,name">
</p>
<p>
<label for="product_updates_rest_version_field"><?php esc_html_e('Version', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_rest_version_field" name="kb_markdown_importer_settings[product_updates_rest_version_field]" type="text" value="<?php echo esc_attr($settings['product_updates_rest_version_field']); ?>" placeholder="version,versionName,name">
</p>
<p>
<label for="product_updates_rest_date_field"><?php esc_html_e('Datum', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_rest_date_field" name="kb_markdown_importer_settings[product_updates_rest_date_field]" type="text" value="<?php echo esc_attr($settings['product_updates_rest_date_field']); ?>" placeholder="releaseDate,date,updatedAt,createdAt">
</p>
<p>
<label for="product_updates_rest_changelog_field"><?php esc_html_e('Changelog', 'kb-markdown-importer'); ?></label><br>
<input class="regular-text" id="product_updates_rest_changelog_field" name="kb_markdown_importer_settings[product_updates_rest_changelog_field]" type="text" value="<?php echo esc_attr($settings['product_updates_rest_changelog_field']); ?>" placeholder="changelog,changeLog,description,changes">
</p>
</fieldset>
<p class="description"><?php esc_html_e('JSON-Feldpfade relativ zu einem Eintrag. Verschachtelte Felder mit Punkt oder Slash angeben, Alternativen mit Komma trennen.', 'kb-markdown-importer'); ?></p>
</td>
<th scope="row"><label for="olm_password"><?php esc_html_e('OLM Passwort', 'kb-markdown-importer'); ?></label></th>
<td><input class="regular-text" id="olm_password" name="kb_markdown_importer_settings[olm_password]" type="password" value="" placeholder="<?php echo $settings['olm_password'] ? esc_attr__('Passwort ist gespeichert; leer lassen zum Beibehalten', 'kb-markdown-importer') : ''; ?>"></td>
</tr>
</table>
<h2><?php esc_html_e('Frontend Design', 'kb-markdown-importer'); ?></h2>
@@ -287,8 +220,8 @@ final class SettingsPage
</form>
<form method="post">
<?php wp_nonce_field('kb_markdown_test_product_updates'); ?>
<?php submit_button(__('Produktupdate-Quelle testen', 'kb-markdown-importer'), 'secondary', 'kb_markdown_test_product_updates'); ?>
<p class="description"><?php esc_html_e('Der Test nutzt die gespeicherten Einstellungen der ausgewählten Update-Quelle. Bitte Änderungen vorher speichern.', 'kb-markdown-importer'); ?></p>
<?php submit_button(__('OLM Changelog synchronisieren', 'kb-markdown-importer'), 'secondary', 'kb_markdown_test_product_updates'); ?>
<p class="description"><?php esc_html_e('Nutzt die gespeicherten OLM-Einstellungen. Bitte Änderungen vorher speichern.', 'kb-markdown-importer'); ?></p>
</form>
<?php if (is_array($updatesTest)) : ?>
<div class="notice notice-<?php echo $updatesTest['ok'] ? 'success' : 'error'; ?>">
@@ -296,7 +229,7 @@ final class SettingsPage
<p><?php echo esc_html($updatesTest['message']); ?></p>
</div>
<?php if ('' !== $updatesTest['body']) : ?>
<h2><?php esc_html_e('Antwort der Produktupdate-Quelle', 'kb-markdown-importer'); ?></h2>
<h2><?php esc_html_e('Gespeicherte Changelog-Vorschau', 'kb-markdown-importer'); ?></h2>
<textarea class="large-text code" rows="16" readonly><?php echo esc_textarea($updatesTest['body']); ?></textarea>
<?php endif; ?>
<?php endif; ?>
@@ -324,73 +257,23 @@ final class SettingsPage
private static function handleProductUpdatesTest(): array
{
$settings = Plugin::settings();
$source = (string) ($settings['product_updates_source'] ?? 'rss');
$url = esc_url_raw((string) ('rest' === $source ? ($settings['product_updates_rest_url'] ?? '') : ($settings['product_updates_feed_url'] ?? '')));
if ('' === $url) {
return [
'ok' => false,
'title' => __('Keine Produktupdate-Quelle konfiguriert.', 'kb-markdown-importer'),
'message' => __('Bitte zuerst eine RSS/XML- oder REST/JSON-URL speichern.', 'kb-markdown-importer'),
'body' => '',
];
}
$response = wp_remote_get($url, [
'timeout' => 12,
'redirection' => 3,
'user-agent' => 'KB Markdown Importer/' . KB_MARKDOWN_IMPORTER_VERSION,
]);
if (is_wp_error($response)) {
return [
'ok' => false,
'title' => __('Produktupdate-Quelle nicht erreichbar.', 'kb-markdown-importer'),
'message' => $response->get_error_message(),
'body' => '',
];
}
$status = (int) wp_remote_retrieve_response_code($response);
$contentType = (string) wp_remote_retrieve_header($response, 'content-type');
$body = (string) wp_remote_retrieve_body($response);
$excerpt = substr($body, 0, 12000);
$validPayload = true;
$payloadNote = '';
if ('rest' === $source) {
json_decode($body, true);
$validPayload = JSON_ERROR_NONE === json_last_error();
if (! $validPayload) {
$payloadNote = ' ' . sprintf(
/* translators: %s: JSON parser error message. */
__('Die Antwort ist kein gültiges JSON: %s', 'kb-markdown-importer'),
json_last_error_msg()
);
}
}
$message = sprintf(
/* translators: 1: source type, 2: HTTP status code, 3: content type. */
__('Quelle: %1$s | HTTP-Status: %2$d | Content-Type: %3$s', 'kb-markdown-importer'),
'rest' === $source ? 'REST/JSON' : 'RSS/XML',
$status,
$contentType ?: '-'
);
$message .= $payloadNote;
if (strlen($body) > strlen($excerpt)) {
$message .= ' ' . __('Die Antwort wurde auf 12000 Zeichen gekürzt.', 'kb-markdown-importer');
}
$ok = $status >= 200 && $status < 300 && $validPayload;
$response = (new ChangelogSync())->sync();
$data = (array) $response->get_data();
$ok = true === ($data['success'] ?? false);
$items = ChangelogSync::items();
$preview = wp_json_encode(array_slice($items, 0, 5), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return [
'ok' => $ok,
'title' => $ok ? __('Produktupdate-Quelle erreichbar.', 'kb-markdown-importer') : __('Produktupdate-Quelle nicht nutzbar.', 'kb-markdown-importer'),
'message' => $message,
'body' => $excerpt,
'title' => $ok ? __('OLM Changelog synchronisiert.', 'kb-markdown-importer') : __('OLM Changelog konnte nicht synchronisiert werden.', 'kb-markdown-importer'),
'message' => $ok
? sprintf(
/* translators: %d: number of parsed changelog items. */
__('Gefundene Changelog-Einträge im Zeitraum: %d', 'kb-markdown-importer'),
count($items)
)
: (string) ($data['message'] ?? __('Unbekannter Fehler.', 'kb-markdown-importer')),
'body' => $preview ?: '',
];
}
@@ -425,19 +308,12 @@ final class SettingsPage
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtoupper($value) : $fallback;
}
private static function sanitizeXmlPath(string $value, string $fallback): string
private static function sanitizeOlmNumberList(string $value): string
{
$value = trim($value);
$value = preg_replace('/[^A-Za-z0-9_:@.\/-]/', '', $value) ?: '';
$items = array_filter(array_map(static function (string $item): string {
return strtolower(preg_replace('/[^a-zA-Z0-9_-]/', '', trim($item)) ?: '');
}, explode(',', $value)), static fn (string $item): bool => '' !== $item);
return '' !== $value ? $value : $fallback;
}
private static function sanitizePathList(string $value, string $fallback): string
{
$value = trim($value);
$value = preg_replace('/[^A-Za-z0-9_:@.\/,-]/', '', $value) ?: '';
return '' !== $value ? $value : $fallback;
return implode(',', array_values(array_unique($items)));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace KbMarkdownImporter\Admin;
use KbMarkdownImporter\Import\ImportLogger;
use KbMarkdownImporter\Olm\ChangelogSync;
use KbMarkdownImporter\Plugin;
final class StatusPage
@@ -26,6 +27,8 @@ final class StatusPage
<div class="kb-admin-card"><strong>Versions</strong><span><?php echo esc_html((string) $counts['versions']); ?></span></div>
<div class="kb-admin-card"><strong>Pages</strong><span><?php echo esc_html((string) $counts['pages']); ?></span></div>
<div class="kb-admin-card"><strong>Last sync</strong><span><?php echo esc_html((string) get_option('kb_markdown_importer_last_sync', __('Never', 'kb-markdown-importer'))); ?></span></div>
<div class="kb-admin-card"><strong>OLM updates</strong><span><?php echo esc_html((string) count(ChangelogSync::items())); ?></span></div>
<div class="kb-admin-card"><strong>Last OLM sync</strong><span><?php echo esc_html(ChangelogSync::lastSync() ?: __('Never', 'kb-markdown-importer')); ?></span></div>
<div class="kb-admin-card"><strong>Format</strong><span>Markdown</span></div>
</div>
<h2><?php esc_html_e('Recent Import Logs', 'kb-markdown-importer'); ?></h2>
@@ -40,6 +43,7 @@ final class StatusPage
'settings_complete' => (bool) (Plugin::settings()['gitlab_base_url'] && Plugin::settings()['gitlab_token']),
'counts' => self::counts(),
'last_sync' => get_option('kb_markdown_importer_last_sync', ''),
'last_changelog_sync' => ChangelogSync::lastSync(),
'last_error' => get_option('kb_markdown_importer_last_error', ''),
]);
}

View File

@@ -6,6 +6,7 @@ namespace KbMarkdownImporter\Admin;
use KbMarkdownImporter\GitLab\GitLabClient;
use KbMarkdownImporter\Import\ImportLogger;
use KbMarkdownImporter\Import\ImportManager;
use KbMarkdownImporter\Olm\ChangelogSync;
use KbMarkdownImporter\Plugin;
final class SyncPage
@@ -24,6 +25,7 @@ final class SyncPage
<form method="post" class="kb-sync-actions">
<?php wp_nonce_field('kb_markdown_sync'); ?>
<?php submit_button(__('Sync All', 'kb-markdown-importer'), 'primary', 'kb_markdown_sync_all', false); ?>
<?php submit_button(__('Sync OLM Changelog', 'kb-markdown-importer'), 'secondary', 'kb_markdown_sync_changelog', false); ?>
<?php submit_button(__('Dry Run', 'kb-markdown-importer'), 'secondary', 'kb_markdown_dry_run', false); ?>
</form>
@@ -63,9 +65,20 @@ final class SyncPage
{
if (isset($_POST['kb_markdown_sync_all']) && check_admin_referer('kb_markdown_sync')) {
(new ImportManager())->syncAll(false);
(new ChangelogSync())->sync();
echo '<div class="notice notice-success"><p>' . esc_html__('Synchronization finished.', 'kb-markdown-importer') . '</p></div>';
}
if (isset($_POST['kb_markdown_sync_changelog']) && check_admin_referer('kb_markdown_sync')) {
$response = (new ChangelogSync())->sync();
$data = (array) $response->get_data();
$success = true === ($data['success'] ?? false);
$message = $success
? __('OLM changelog synchronization finished.', 'kb-markdown-importer')
: (string) ($data['message'] ?? __('OLM changelog synchronization failed.', 'kb-markdown-importer'));
echo '<div class="notice notice-' . ($success ? 'success' : 'error') . '"><p>' . esc_html($message) . '</p></div>';
}
if (isset($_POST['kb_markdown_dry_run']) && check_admin_referer('kb_markdown_sync')) {
(new ImportManager())->syncAll(true);
echo '<div class="notice notice-info"><p>' . esc_html__('Dry run finished.', 'kb-markdown-importer') . '</p></div>';

View File

@@ -3,264 +3,12 @@ declare(strict_types=1);
namespace KbMarkdownImporter\Frontend;
use KbMarkdownImporter\Plugin;
use KbMarkdownImporter\Olm\ChangelogSync;
final class ProductUpdatesFeed
{
public static function items(array $settings = []): array
{
$settings = $settings ?: Plugin::settings();
$source = (string) ($settings['product_updates_source'] ?? 'rss');
$url = esc_url_raw((string) ('rest' === $source ? ($settings['product_updates_rest_url'] ?? '') : ($settings['product_updates_feed_url'] ?? '')));
if ('' === $url) {
return [];
}
$cacheKey = 'kb_product_updates_' . md5($source . $url . wp_json_encode([
$settings['product_updates_feed_item_path'] ?? '',
$settings['product_updates_feed_product_field'] ?? '',
$settings['product_updates_feed_version_field'] ?? '',
$settings['product_updates_feed_date_field'] ?? '',
$settings['product_updates_feed_changelog_field'] ?? '',
$settings['product_updates_rest_list_path'] ?? '',
$settings['product_updates_rest_product_field'] ?? '',
$settings['product_updates_rest_version_field'] ?? '',
$settings['product_updates_rest_date_field'] ?? '',
$settings['product_updates_rest_changelog_field'] ?? '',
]));
$cached = get_transient($cacheKey);
if (is_array($cached)) {
return $cached;
}
$response = wp_remote_get($url, [
'timeout' => 8,
'redirection' => 3,
'user-agent' => 'KB Markdown Importer/' . KB_MARKDOWN_IMPORTER_VERSION,
]);
if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
return [];
}
$body = (string) wp_remote_retrieve_body($response);
$items = 'rest' === $source ? self::parseJson($body, $settings) : self::parseXml($body, $settings);
set_transient($cacheKey, $items, 15 * MINUTE_IN_SECONDS);
return $items;
}
private static function parseXml(string $xml, array $settings): array
{
if ('' === trim($xml) || ! class_exists(\DOMDocument::class)) {
return [];
}
$previous = libxml_use_internal_errors(true);
$document = new \DOMDocument();
$loaded = $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previous);
if (! $loaded) {
return [];
}
$xpath = new \DOMXPath($document);
$itemNodes = $xpath->query(self::itemPath((string) ($settings['product_updates_feed_item_path'] ?? 'channel/item')));
if (! $itemNodes) {
return [];
}
$limit = max(1, min(20, (int) ($settings['product_updates_feed_limit'] ?? 5)));
$items = [];
foreach ($itemNodes as $itemNode) {
if (count($items) >= $limit) {
break;
}
$date = self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_date_field'] ?? 'pubDate'));
$items[] = [
'product' => self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_product_field'] ?? 'title')),
'version' => self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_version_field'] ?? 'category')),
'date' => self::formatDate($date),
'changelog' => self::fieldValue($xpath, $itemNode, (string) ($settings['product_updates_feed_changelog_field'] ?? 'description')),
];
}
return $items;
}
private static function parseJson(string $json, array $settings): array
{
$data = json_decode($json, true);
if (! is_array($data)) {
return [];
}
$nodes = self::jsonList($data, (string) ($settings['product_updates_rest_list_path'] ?? 'content,data,items'));
if (! $nodes) {
return [];
}
$limit = max(1, min(20, (int) ($settings['product_updates_feed_limit'] ?? 5)));
$items = [];
foreach ($nodes as $node) {
if (count($items) >= $limit || ! is_array($node)) {
break;
}
$date = self::jsonField($node, (string) ($settings['product_updates_rest_date_field'] ?? 'releaseDate,date,updatedAt,createdAt'));
$items[] = [
'product' => self::jsonField($node, (string) ($settings['product_updates_rest_product_field'] ?? 'product.name,productName,name')),
'version' => self::jsonField($node, (string) ($settings['product_updates_rest_version_field'] ?? 'version,versionName,name')),
'date' => self::formatDate($date),
'changelog' => self::jsonField($node, (string) ($settings['product_updates_rest_changelog_field'] ?? 'changelog,changeLog,description,changes')),
];
}
return $items;
}
private static function jsonList(array $data, string $paths): array
{
foreach (self::pathAlternatives($paths) as $path) {
$value = '' === $path ? $data : self::jsonValue($data, $path);
if (is_array($value) && array_is_list($value)) {
return $value;
}
}
return array_is_list($data) ? $data : [];
}
private static function jsonField(array $data, string $paths): string
{
foreach (self::pathAlternatives($paths) as $path) {
$value = self::jsonValue($data, $path);
if (is_scalar($value)) {
return trim(wp_strip_all_tags((string) $value));
}
if (is_array($value)) {
$text = implode(', ', array_filter(array_map(static fn ($item): string => is_scalar($item) ? (string) $item : '', $value)));
if ('' !== $text) {
return trim(wp_strip_all_tags($text));
}
}
}
return '';
}
private static function jsonValue(array $data, string $path): mixed
{
$value = $data;
foreach (self::pathSegments($path) as $segment) {
if (! is_array($value) || ! array_key_exists($segment, $value)) {
return null;
}
$value = $value[$segment];
}
return $value;
}
private static function pathAlternatives(string $paths): array
{
return array_values(array_map('trim', explode(',', $paths)));
}
private static function itemPath(string $path): string
{
$segments = self::pathSegments($path ?: 'channel/item');
$first = (string) array_shift($segments);
$query = '//*[local-name()="' . self::localName($first ?: 'item') . '"]';
foreach ($segments as $segment) {
$query .= '/*[local-name()="' . self::localName((string) $segment) . '"]';
}
return $query;
}
private static function fieldValue(\DOMXPath $xpath, \DOMNode $node, string $path): string
{
$segments = self::pathSegments($path);
if (! $segments) {
return '';
}
$attribute = null;
$last = (string) end($segments);
if (str_starts_with($last, '@')) {
$attribute = substr($last, 1);
array_pop($segments);
}
$query = '.';
foreach ($segments as $segment) {
$query .= '/*[local-name()="' . self::localName((string) $segment) . '"]';
}
$result = $xpath->query($query, $node);
$target = $result && $result->length > 0 ? $result->item(0) : null;
if (! $target) {
return '';
}
if ($attribute && $target instanceof \DOMElement) {
return trim(wp_strip_all_tags(html_entity_decode($target->getAttribute($attribute), ENT_QUOTES | ENT_XML1, get_bloginfo('charset'))));
}
return trim(wp_strip_all_tags(html_entity_decode($target->textContent, ENT_QUOTES | ENT_XML1, get_bloginfo('charset'))));
}
private static function pathSegments(string $path): array
{
$path = trim(str_replace('.', '/', $path), '/ ');
if ('' === $path) {
return [];
}
return array_values(array_filter(array_map('trim', explode('/', $path)), static fn (string $segment): bool => '' !== $segment));
}
private static function localName(string $segment): string
{
$segment = trim($segment);
$parts = explode(':', $segment);
return preg_replace('/[^A-Za-z0-9_-]/', '', (string) end($parts)) ?: '';
}
private static function formatDate(string $date): string
{
if ('' === $date) {
return '';
}
$timestamp = strtotime($date);
if (! $timestamp) {
return $date;
}
return wp_date((string) get_option('date_format'), $timestamp);
return ChangelogSync::items();
}
}

View File

@@ -6,6 +6,7 @@ namespace KbMarkdownImporter\Frontend;
use KbMarkdownImporter\Access\AccessController;
use KbMarkdownImporter\Plugin;
use KbMarkdownImporter\Repository\PageRepository;
use KbMarkdownImporter\Repository\ProductRepository;
final class Router
{
@@ -212,15 +213,18 @@ final class Router
private function captureProduct(string $productSlug): string
{
$product = get_term_by('slug', $productSlug, 'kb_product');
$productItem = self::frontendProduct($productSlug);
$product = $productItem['term'] ?? null;
if (! $product) {
return $this->capture404();
}
$productSlug = (string) $product->slug;
$versions = $this->versionsForProduct($productSlug);
return (new TemplateLoader())->capture('product', [
'product' => $product,
'product_item' => $productItem,
'versions' => $versions,
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
@@ -234,23 +238,30 @@ final class Router
private function captureVersion(string $productSlug, string $versionSlug): string
{
$product = get_term_by('slug', $productSlug, 'kb_product');
$productItem = self::frontendProduct($productSlug);
$product = $productItem['term'] ?? null;
$version = get_term_by('slug', $versionSlug, 'kb_version');
if (! $product || ! $version) {
return $this->capture404();
}
$productSlug = (string) $product->slug;
$landing = $this->landingPageForVersion($productSlug, $versionSlug);
if ($landing) {
return $this->captureDocPage($landing, $productSlug, $versionSlug);
$landingSlugs = $this->pageLinkSlugs([$landing], $productSlug);
return $this->captureDocPage($landing, $productSlug, $versionSlug, $productItem, (string) ($landingSlugs[$landing->ID] ?? ''));
}
$pages = $this->pagesForVersion($productSlug, $versionSlug);
return (new TemplateLoader())->capture('version', [
'product' => $product,
'product_item' => $productItem,
'version' => $version,
'versions' => $this->versionsForProduct($productSlug),
'pages' => $this->pagesForVersion($productSlug, $versionSlug),
'pages' => $pages,
'page_link_slugs' => $this->pageLinkSlugs($pages, $productSlug),
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
@@ -263,17 +274,35 @@ final class Router
private function capturePage(string $productSlug, string $versionSlug, string $pageSlug): string
{
$post = (new PageRepository())->findFrontendPage($productSlug, $versionSlug, $pageSlug);
$productItem = self::frontendProduct($productSlug);
if ($productItem && isset($productItem['term']->slug)) {
$productSlug = (string) $productItem['term']->slug;
}
$termSlugs = $this->sourceProductSlugs($productSlug);
$sourceSlug = '';
$realPageSlug = $pageSlug;
if (! $post && '' === $pageSlug) {
$post = (new PageRepository())->findFrontendPage($productSlug, $versionSlug, 'index');
if (str_contains($pageSlug, '--')) {
[$sourceSlug, $realPageSlug] = array_pad(explode('--', $pageSlug, 2), 2, '');
if (in_array($sourceSlug, $termSlugs, true)) {
$termSlugs = [$sourceSlug];
}
if ('index' === $realPageSlug) {
$realPageSlug = '';
}
}
$post = (new PageRepository())->findFrontendPageInProducts($termSlugs, $versionSlug, $realPageSlug);
if (! $post && '' === $realPageSlug) {
$post = (new PageRepository())->findFrontendPageInProducts($termSlugs, $versionSlug, 'index');
}
if (! $post) {
return $this->capture404();
}
return $this->captureDocPage($post, $productSlug, $versionSlug);
return $this->captureDocPage($post, $productSlug, $versionSlug, $productItem, $pageSlug);
}
private function renderDocPage(\WP_Post $post, string $productSlug, string $versionSlug): void
@@ -281,21 +310,26 @@ final class Router
echo $this->captureDocPage($post, $productSlug, $versionSlug);
}
private function captureDocPage(\WP_Post $post, string $productSlug, string $versionSlug): string
private function captureDocPage(\WP_Post $post, string $productSlug, string $versionSlug, ?array $productItem = null, string $activePageSlug = ''): string
{
$product = get_term_by('slug', $productSlug, 'kb_product');
$productItem = $productItem ?: self::frontendProduct($productSlug);
$product = $productItem['term'] ?? null;
$version = get_term_by('slug', $versionSlug, 'kb_version');
$navTree = json_decode((string) get_post_meta($post->ID, '_kb_nav_tree', true), true);
$pages = $this->pagesForVersion($productSlug, $versionSlug);
return (new TemplateLoader())->capture('page', [
'post' => $post,
'product' => $product,
'product_item' => $productItem,
'version' => $version,
'versions' => $this->versionsForProduct($productSlug),
'nav_tree' => is_array($navTree) ? $navTree : [],
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'product_slug' => $productSlug,
'version_slug' => $versionSlug,
'active_page_slug' => $activePageSlug,
'page_link_slugs' => $this->pageLinkSlugs($pages, $productSlug),
'url_builder' => UrlBuilder::class,
]);
}
@@ -316,18 +350,33 @@ final class Router
private function captureShell(string $content, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
$activePages = [];
$activeProduct = self::frontendProduct($productSlug);
if ($activeProduct && isset($activeProduct['term']->slug)) {
$productSlug = (string) $activeProduct['term']->slug;
}
if ($productSlug && $versionSlug) {
$activePages = $this->pagesForVersion($productSlug, $versionSlug);
if ('' === $pageSlug) {
$landing = $this->landingPageForVersion($productSlug, $versionSlug);
if ($landing) {
$landingSlugs = $this->pageLinkSlugs([$landing], $productSlug);
$pageSlug = (string) ($landingSlugs[$landing->ID] ?? '');
}
}
}
return (new TemplateLoader())->capture('docs-app', [
'content' => $content,
'products' => self::productsWithVersions(),
'active_product' => $activeProduct,
'active_product_slug' => $productSlug,
'active_version_slug' => $versionSlug,
'active_page_slug' => $pageSlug,
'active_pages' => $activePages,
'active_page_link_slugs' => $this->pageLinkSlugs($activePages, $productSlug),
'base_slug' => trim((string) Plugin::settings()['docs_base_slug'], '/'),
'url_builder' => UrlBuilder::class,
]);
@@ -356,37 +405,93 @@ final class Router
public static function productsWithVersions(): array
{
$products = get_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]);
$items = [];
$groups = [];
$repository = new ProductRepository();
if (is_wp_error($products)) {
return [];
}
foreach ($products as $product) {
$versions = (new self())->versionsForProduct($product->slug);
$meta = $repository->frontendMeta($product);
$groupSlug = (string) $meta['group_slug'];
if (! $versions) {
continue;
if (! isset($groups[$groupSlug])) {
$groups[$groupSlug] = [
'term' => (object) [
'term_id' => 0,
'name' => (string) $meta['group_name'],
'slug' => $groupSlug,
],
'source_terms' => [],
'parts' => [],
'category' => (string) $meta['category'],
'versions' => [],
];
}
$items[] = [
$groups[$groupSlug]['source_terms'][] = $product;
$groups[$groupSlug]['parts'][$product->slug] = [
'term' => $product,
'versions' => $versions,
'label' => (string) ($meta['part_label'] ?: $product->name),
'category' => (string) $meta['category'],
];
}
return $items;
$router = new self();
foreach ($groups as $groupSlug => &$group) {
$group['versions'] = $router->versionsForProduct((string) $groupSlug);
}
unset($group);
$groups = array_values(array_filter($groups, static fn (array $group): bool => ! empty($group['versions'])));
usort($groups, static function (array $a, array $b): int {
$categoryCompare = strcasecmp((string) ($a['category'] ?? ''), (string) ($b['category'] ?? ''));
return 0 !== $categoryCompare ? $categoryCompare : strcasecmp((string) $a['term']->name, (string) $b['term']->name);
});
return $groups;
}
public static function frontendProduct(string $productSlug): ?array
{
$items = self::productsWithVersions();
foreach ($items as $item) {
if ($productSlug === (string) $item['term']->slug) {
return $item;
}
}
$term = get_term_by('slug', $productSlug, 'kb_product');
if ($term instanceof \WP_Term) {
$meta = (new ProductRepository())->frontendMeta($term);
foreach ($items as $item) {
if ((string) $meta['group_slug'] === (string) $item['term']->slug) {
return $item;
}
}
}
return null;
}
private function versionsForProduct(string $productSlug): array
{
$productSlugs = $this->sourceProductSlugs($productSlug);
if (! $productSlugs) {
return [];
}
$query = new \WP_Query([
'post_type' => 'kb_doc_page',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'tax_query' => [
['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlug],
['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlugs],
],
]);
$versions = [];
@@ -404,6 +509,12 @@ final class Router
private function pagesForVersion(string $productSlug, string $versionSlug): array
{
$productSlugs = $this->sourceProductSlugs($productSlug);
if (! $productSlugs) {
return [];
}
$query = new \WP_Query([
'post_type' => 'kb_doc_page',
'post_status' => 'publish',
@@ -412,7 +523,7 @@ final class Router
'orderby' => ['meta_value_num' => 'ASC', 'title' => 'ASC'],
'tax_query' => [
'relation' => 'AND',
['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlug],
['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlugs],
['taxonomy' => 'kb_version', 'field' => 'slug', 'terms' => $versionSlug],
],
]);
@@ -423,7 +534,7 @@ final class Router
private function landingPageForVersion(string $productSlug, string $versionSlug): ?\WP_Post
{
$repository = new PageRepository();
$landing = $repository->findFrontendPage($productSlug, $versionSlug, '');
$landing = $repository->findFrontendPageInProducts($this->sourceProductSlugs($productSlug), $versionSlug, '');
if ($landing) {
return $landing;
@@ -433,4 +544,45 @@ final class Router
return $pages[0] ?? null;
}
private function sourceProductSlugs(string $productSlug): array
{
$terms = get_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]);
$repository = new ProductRepository();
$slugs = [];
if (is_wp_error($terms)) {
return [$productSlug];
}
foreach ($terms as $term) {
$meta = $repository->frontendMeta($term);
if ($productSlug === (string) $meta['group_slug']) {
$slugs[] = $term->slug;
}
}
return $slugs ?: [$productSlug];
}
private function pageLinkSlugs(array $pages, string $productSlug): array
{
$product = self::frontendProduct($productSlug);
$sourceTerms = (array) ($product['source_terms'] ?? []);
$multiPart = count($sourceTerms) > 1;
$slugs = [];
foreach ($pages as $page) {
if (! $page instanceof \WP_Post) {
continue;
}
$pageSlug = (string) get_post_meta($page->ID, '_kb_page_slug', true);
$sourceSlug = (string) get_post_meta($page->ID, '_kb_product_slug', true);
$slugs[$page->ID] = ($multiPart && '' !== $sourceSlug) ? $sourceSlug . '--' . ($pageSlug ?: 'index') : $pageSlug;
}
return $slugs;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace KbMarkdownImporter\Frontend;
use KbMarkdownImporter\Plugin;
use KbMarkdownImporter\Repository\ProductRepository;
final class UrlBuilder
{
@@ -41,9 +42,40 @@ final class UrlBuilder
public static function page(string $productSlug, string $versionSlug, string $pageSlug = ''): string
{
[$productSlug, $pageSlug] = self::normalizeProductRoute($productSlug, $pageSlug);
return self::route('page', $productSlug, $versionSlug, $pageSlug);
}
private static function normalizeProductRoute(string $productSlug, string $pageSlug): array
{
$term = get_term_by('slug', $productSlug, 'kb_product');
if (! $term instanceof \WP_Term) {
return [$productSlug, $pageSlug];
}
$repository = new ProductRepository();
$meta = $repository->frontendMeta($term);
$groupSlug = (string) $meta['group_slug'];
$groupTermCount = 0;
$terms = get_terms(['taxonomy' => 'kb_product', 'hide_empty' => false]);
if (! is_wp_error($terms)) {
foreach ($terms as $candidate) {
if ($groupSlug === (string) $repository->frontendMeta($candidate)['group_slug']) {
$groupTermCount++;
}
}
}
if ($groupTermCount > 1 && ! str_contains($pageSlug, '--')) {
$pageSlug = $term->slug . '--' . ($pageSlug ?: 'index');
}
return [$groupSlug, $pageSlug];
}
private static function route(string $route, string $productSlug = '', string $versionSlug = '', string $pageSlug = ''): string
{
if (self::isEmbed()) {

View File

@@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Olm;
use KbMarkdownImporter\Import\ImportLogger;
use KbMarkdownImporter\Plugin;
final class ChangelogSync
{
private const OPTION_ITEMS = 'kb_markdown_importer_product_updates';
private const OPTION_LAST_SYNC = 'kb_markdown_importer_changelog_last_sync';
private array $settings;
private string $baseUrl;
private array $headers = [];
public function __construct(?array $settings = null)
{
$this->settings = $settings ?: Plugin::settings();
$this->baseUrl = self::normalizeBaseUrl((string) ($this->settings['olm_base_url'] ?? ''));
}
public static function items(): array
{
$items = get_option(self::OPTION_ITEMS, []);
return is_array($items) ? $items : [];
}
public static function lastSync(): string
{
return (string) get_option(self::OPTION_LAST_SYNC, '');
}
public static function normalizeBaseUrl(string $url): string
{
$url = trim($url);
if ('' === $url) {
return '';
}
if (! preg_match('#^https?://#i', $url)) {
$url = 'https://' . $url;
}
return rtrim($url, '/');
}
public function sync(): \WP_REST_Response
{
ImportLogger::info('OLM changelog synchronization started.');
$token = $this->login();
if (is_wp_error($token)) {
ImportLogger::error('OLM changelog login failed: ' . $token->get_error_message());
return new \WP_REST_Response(['success' => false, 'message' => $token->get_error_message()], 500);
}
$this->headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => 'Bearer ' . $token,
];
$downloadIds = $this->productDownloadIds();
if (is_wp_error($downloadIds)) {
ImportLogger::error('OLM product lookup failed: ' . $downloadIds->get_error_message());
return new \WP_REST_Response(['success' => false, 'message' => $downloadIds->get_error_message()], 500);
}
$items = [];
foreach ($downloadIds as $downloadId) {
$versions = $this->downloadFieldVersions($downloadId);
if (is_wp_error($versions)) {
ImportLogger::warning('OLM download field lookup failed for ' . $downloadId . ': ' . $versions->get_error_message());
continue;
}
foreach ($versions as $version) {
$item = $this->normalizeVersion($version);
if (null !== $item) {
$items[] = $item;
}
}
}
usort($items, static fn (array $a, array $b): int => ($b['_timestamp'] ?? 0) <=> ($a['_timestamp'] ?? 0));
$items = array_filter($items, fn (array $item): bool => $this->isInDateWindow($item));
$items = array_map(static function (array $item): array {
unset($item['_timestamp']);
return $item;
}, array_values($items));
update_option(self::OPTION_ITEMS, $items, false);
update_option(self::OPTION_LAST_SYNC, current_time('mysql'), false);
ImportLogger::info('OLM changelog synchronization completed. Entries: ' . count($items));
return new \WP_REST_Response([
'success' => true,
'stats' => [
'downloads' => count($downloadIds),
'updates' => count($items),
],
]);
}
private function login(): string|\WP_Error
{
if ('' === $this->baseUrl) {
return new \WP_Error('kb_olm_missing_base_url', __('OLM base URL missing.', 'kb-markdown-importer'));
}
$username = trim((string) ($this->settings['olm_username'] ?? ''));
$password = (string) ($this->settings['olm_password'] ?? '');
if ('' === $username || '' === $password) {
return new \WP_Error('kb_olm_missing_credentials', __('OLM credentials missing.', 'kb-markdown-importer'));
}
$response = wp_remote_post($this->baseUrl . '/login', [
'timeout' => 12,
'redirection' => 3,
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => wp_json_encode([
'username' => $username,
'password' => $password,
]),
]);
if (is_wp_error($response)) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if ($status < 200 || $status >= 300 || ! is_array($data) || empty($data['bearerToken'])) {
return new \WP_Error(
'kb_olm_login_failed',
__('OLM login failed.', 'kb-markdown-importer'),
[
'status' => $status,
'response_excerpt' => substr(wp_strip_all_tags($body), 0, 1000),
]
);
}
return (string) $data['bearerToken'];
}
private function productDownloadIds(): array|\WP_Error
{
$ids = [];
$page = 1;
while (true) {
$data = $this->getJson($this->baseUrl . '/api/rest/v1/product?page=' . $page . '&size=1');
if (is_wp_error($data)) {
return $data;
}
$products = is_array($data['content'] ?? null) ? $data['content'] : [];
if (! $products) {
break;
}
foreach ($products as $product) {
if (! is_array($product)) {
continue;
}
foreach ((array) ($product['downloads'] ?? []) as $download) {
$id = is_array($download) ? (string) ($download['id'] ?? '') : '';
if ('' !== $id) {
$ids[$id] = $id;
}
}
}
$page++;
}
return array_values($ids);
}
private function downloadFieldVersions(string $downloadId): array|\WP_Error
{
$versions = [];
$page = 1;
while (true) {
$data = $this->getJson($this->baseUrl . '/api/rest/v1/download/field/' . rawurlencode($downloadId) . '?page=' . $page . '&size=1');
if (is_wp_error($data)) {
return $data;
}
$content = is_array($data['content'] ?? null) ? $data['content'] : [];
if (! $content) {
break;
}
foreach ($content as $version) {
if (is_array($version)) {
$versions[] = $version;
}
}
$page++;
}
return $versions;
}
private function getJson(string $url): array|\WP_Error
{
$response = wp_remote_get($url, [
'timeout' => 12,
'redirection' => 3,
'headers' => $this->headers,
'user-agent' => 'KB Markdown Importer/' . KB_MARKDOWN_IMPORTER_VERSION,
]);
if (is_wp_error($response)) {
return $response;
}
$status = (int) wp_remote_retrieve_response_code($response);
$body = (string) wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if ($status < 200 || $status >= 300 || ! is_array($data)) {
return new \WP_Error(
'kb_olm_request_failed',
__('OLM request failed.', 'kb-markdown-importer'),
[
'status' => $status,
'url' => $url,
'response_excerpt' => substr(wp_strip_all_tags($body), 0, 1000),
]
);
}
return $data;
}
private function normalizeVersion(array $version): ?array
{
$productVersion = is_array($version['productVersion'] ?? null) ? $version['productVersion'] : [];
$product = is_array($productVersion['product'] ?? null) ? $productVersion['product'] : [];
$downloadField = is_array($version['downloadField'] ?? null) ? $version['downloadField'] : [];
$productNo = strtolower((string) ($product['productNo'] ?? ''));
$publishedAt = (string) ($version['publishedAt'] ?? '');
if (true !== ($version['qa'] ?? false) || true !== ($product['published'] ?? false) || '' === $publishedAt) {
return null;
}
if (in_array($productNo, $this->ignoredOlmNumbers(), true)) {
return null;
}
$timestamp = $this->dateTimestamp($publishedAt);
$productName = $this->cleanText((string) ($product['name'] ?? ''));
$downloadName = $this->cleanText((string) ($downloadField['name'] ?? ''));
if (true !== ($downloadField['starfaceModule'] ?? false) && '' !== $downloadName) {
$productName = trim($productName . ' - ' . $downloadName);
}
$changelogLines = $this->changelogLines((string) ($version['changelog'] ?? ''));
return [
'product' => $productName,
'version' => $this->moduleVersion($productVersion, $version),
'date' => $this->formatDate($publishedAt),
'month_label' => $timestamp > 0 ? wp_date('F Y', $timestamp) : '',
'changelog' => implode(' ', $changelogLines),
'changelog_lines' => $changelogLines,
'starface_min' => $this->starfaceVersion(is_array($productVersion['minStarfaceVersion'] ?? null) ? $productVersion['minStarfaceVersion'] : []),
'starface_max' => $this->starfaceVersion(is_array($productVersion['maxStarfaceVersion'] ?? null) ? $productVersion['maxStarfaceVersion'] : []),
'link' => $this->validUrl((string) ($product['productPageURI'] ?? '')),
'download_link' => '' !== $productNo ? 'https://get.o-byte.com?olm=' . rawurlencode($productNo) : '',
'product_no' => $productNo,
'published_at' => $timestamp > 0 ? wp_date('Y-m-d', $timestamp) : '',
'_timestamp' => $timestamp,
];
}
private function isInDateWindow(array $item): bool
{
$timestamp = (int) ($item['_timestamp'] ?? 0);
if ($timestamp <= 0) {
return false;
}
$months = max(1, min(24, (int) ($this->settings['product_updates_olm_months'] ?? 4)));
$timezone = wp_timezone();
$date = (new \DateTimeImmutable('@' . $timestamp))->setTimezone($timezone);
$now = new \DateTimeImmutable('now', $timezone);
$start = $now->modify('first day of this month')->modify('-' . $months . ' months')->setTime(0, 0, 0);
return $date >= $start && $date <= $now;
}
private function ignoredOlmNumbers(): array
{
$value = strtolower((string) ($this->settings['product_updates_olm_ignore_numbers'] ?? ''));
return array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => '' !== $item));
}
private function moduleVersion(array $productVersion, array $downloadVersion): string
{
return implode('.', [
(string) ($productVersion['major'] ?? ''),
(string) ($productVersion['minor'] ?? ''),
(string) ($downloadVersion['bugfixVersion'] ?? ''),
]);
}
private function starfaceVersion(array $version): string
{
return implode('.', [
(string) ($version['major'] ?? ''),
(string) ($version['minor'] ?? ''),
(string) ($version['build'] ?? ''),
(string) ($version['revision'] ?? ''),
]);
}
private function changelogLines(string $value): array
{
$value = str_replace(["\r\n", "\r"], "\n", $value);
$lines = [];
foreach (explode("\n", $value) as $line) {
$line = $this->cleanText($line);
if ('' !== $line) {
$lines[] = $line;
}
}
return $lines;
}
private function cleanText(string $value): string
{
$value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, get_bloginfo('charset'));
$value = preg_replace('/<br\s*\/?>/i', ' ', $value) ?? $value;
$value = wp_strip_all_tags($value);
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return trim($value);
}
private function validUrl(string $value): string
{
$value = trim($value);
if ('' === $value || in_array($value, ['.', '-'], true)) {
return '';
}
return filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
}
private function dateTimestamp(string $date): int
{
if ('' === $date) {
return 0;
}
$timestamp = strtotime($date);
return $timestamp ?: 0;
}
private function formatDate(string $date): string
{
$timestamp = $this->dateTimestamp($date);
return $timestamp > 0 ? wp_date((string) get_option('date_format'), $timestamp) : $date;
}
}

View File

@@ -10,6 +10,7 @@ use KbMarkdownImporter\Admin\SyncPage;
use KbMarkdownImporter\Frontend\Router;
use KbMarkdownImporter\Frontend\SearchController;
use KbMarkdownImporter\Import\ImportManager;
use KbMarkdownImporter\Olm\ChangelogSync;
final class Plugin
{
@@ -135,7 +136,15 @@ final class Plugin
register_rest_route('kb-markdown/v1', '/sync', [
'methods' => 'POST',
'callback' => static fn (\WP_REST_Request $request): \WP_REST_Response => (new ImportManager())->syncAll((bool) $request->get_param('dry_run')),
'callback' => static function (\WP_REST_Request $request): \WP_REST_Response {
$response = (new ImportManager())->syncAll((bool) $request->get_param('dry_run'));
if (! (bool) $request->get_param('dry_run')) {
(new ChangelogSync())->sync();
}
return $response;
},
'permission_callback' => static fn (): bool => current_user_can('sync_kb_docs'),
]);
@@ -145,6 +154,12 @@ final class Plugin
'permission_callback' => static fn (): bool => current_user_can('sync_kb_docs'),
]);
register_rest_route('kb-markdown/v1', '/sync/changelog', [
'methods' => 'POST',
'callback' => static fn (): \WP_REST_Response => (new ChangelogSync())->sync(),
'permission_callback' => static fn (): bool => current_user_can('sync_kb_docs'),
]);
register_rest_route('kb-markdown/v1', '/search', [
'methods' => 'GET',
'callback' => [SearchController::class, 'restSearch'],
@@ -179,6 +194,7 @@ final class Plugin
public function runCronSync(): void
{
(new ImportManager())->syncAll(false);
(new ChangelogSync())->sync();
}
public function enqueueFrontendAssets(): void

View File

@@ -111,7 +111,18 @@ final class PageRepository
public function findFrontendPage(string $productSlug, string $versionSlug, string $pageSlug): ?\WP_Post
{
return $this->findFrontendPageInProducts([$productSlug], $versionSlug, $pageSlug);
}
public function findFrontendPageInProducts(array $productSlugs, string $versionSlug, string $pageSlug): ?\WP_Post
{
$productSlugs = array_values(array_filter(array_map('sanitize_title', $productSlugs)));
$pageSlug = $pageSlug ?: '';
if (! $productSlugs) {
return null;
}
$query = new \WP_Query([
'post_type' => 'kb_doc_page',
'post_status' => 'publish',
@@ -119,10 +130,12 @@ final class PageRepository
'no_found_rows' => true,
'meta_query' => [
'relation' => 'AND',
['key' => '_kb_product_slug', 'value' => $productSlug],
['key' => '_kb_version_slug', 'value' => $versionSlug],
['key' => '_kb_page_slug', 'value' => $pageSlug],
],
'tax_query' => [
['taxonomy' => 'kb_product', 'field' => 'slug', 'terms' => $productSlugs],
],
]);
return $query->have_posts() ? $query->posts[0] : null;

View File

@@ -5,6 +5,11 @@ namespace KbMarkdownImporter\Repository;
final class ProductRepository
{
public const META_GROUP_NAME = '_kb_product_group_name';
public const META_GROUP_SLUG = '_kb_product_group_slug';
public const META_PART_LABEL = '_kb_product_part_label';
public const META_CATEGORY = '_kb_product_category';
public function ensure(string $name, string $slug = ''): int
{
$slug = $slug ? sanitize_title($slug) : sanitize_title($name);
@@ -54,13 +59,14 @@ final class ProductRepository
'term' => $term,
'page_count' => count($pageIds),
'versions' => array_values($versions),
'meta' => $this->frontendMeta($term),
];
}
return $items;
}
public function update(int $termId, string $name, string $slug): \WP_Term|\WP_Error
public function update(int $termId, string $name, string $slug, array $frontend = []): \WP_Term|\WP_Error
{
$name = trim($name);
$slug = sanitize_title($slug ?: $name);
@@ -82,6 +88,23 @@ final class ProductRepository
update_post_meta($pageId, '_kb_product_slug', $slug);
}
$groupName = sanitize_text_field((string) ($frontend['group_name'] ?? ''));
$groupName = '' !== trim($groupName) ? $groupName : $name;
$groupSlugInput = sanitize_title((string) ($frontend['group_slug'] ?? ''));
$groupSlug = $groupSlugInput ?: sanitize_title($groupName);
if ($groupSlugInput === $slug && 0 !== strcasecmp($groupName, $name)) {
$groupSlug = sanitize_title($groupName);
}
$partLabel = sanitize_text_field((string) ($frontend['part_label'] ?? ''));
$category = sanitize_text_field((string) ($frontend['category'] ?? ''));
update_term_meta($termId, self::META_GROUP_NAME, $groupName ?: $name);
update_term_meta($termId, self::META_GROUP_SLUG, $groupSlug ?: $slug);
update_term_meta($termId, self::META_PART_LABEL, $partLabel);
update_term_meta($termId, self::META_CATEGORY, $category);
$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'));
@@ -115,6 +138,27 @@ final class ProductRepository
return true;
}
public function frontendMeta(\WP_Term $term): array
{
$storedGroupName = trim((string) get_term_meta($term->term_id, self::META_GROUP_NAME, true));
$storedGroupSlug = sanitize_title((string) get_term_meta($term->term_id, self::META_GROUP_SLUG, true));
$groupName = '' !== $storedGroupName ? $storedGroupName : $term->name;
$groupSlug = '' !== $storedGroupSlug ? $storedGroupSlug : ('' !== $storedGroupName ? sanitize_title($groupName) : $term->slug);
$partLabel = trim((string) get_term_meta($term->term_id, self::META_PART_LABEL, true));
$category = trim((string) get_term_meta($term->term_id, self::META_CATEGORY, true));
if ('' !== $storedGroupName && $groupSlug === $term->slug && 0 !== strcasecmp($groupName, $term->name)) {
$groupSlug = sanitize_title($groupName);
}
return [
'group_name' => $groupName,
'group_slug' => '' !== $groupSlug ? $groupSlug : $term->slug,
'part_label' => $partLabel,
'category' => $category,
];
}
private function pageIdsForProduct(int $termId, array $postStatus = ['publish']): array
{
$query = new \WP_Query([

View File

@@ -24,20 +24,12 @@ final class Settings
'custom_theme_css_url' => '',
'docs_home_intro_title' => 'So nutzt du die Dokumentation',
'docs_home_intro_content' => '<p>Wähle links ein Produkt aus und öffne anschließend die passende Version. Innerhalb einer Version findest du die zugehörigen Seiten der Dokumentation in der Navigation.</p>',
'product_updates_source' => 'rss',
'product_updates_feed_url' => '',
'product_updates_feed_limit' => '5',
'product_updates_feed_item_path' => 'channel/item',
'product_updates_feed_product_field' => 'title',
'product_updates_feed_version_field' => 'category',
'product_updates_feed_date_field' => 'pubDate',
'product_updates_feed_changelog_field' => 'description',
'product_updates_rest_url' => '',
'product_updates_rest_list_path' => 'content,data,items',
'product_updates_rest_product_field' => 'product.name,productName,name',
'product_updates_rest_version_field' => 'version,versionName,name',
'product_updates_rest_date_field' => 'releaseDate,date,updatedAt,createdAt',
'product_updates_rest_changelog_field' => 'changelog,changeLog,description,changes',
'product_updates_source' => 'olm_changelog',
'product_updates_olm_months' => '4',
'product_updates_olm_ignore_numbers' => 'olm-10109,olm-10110',
'olm_base_url' => 'https://olm.o-byte.com',
'olm_username' => '',
'olm_password' => '',
];
}
}

View File

@@ -35,4 +35,27 @@ Optional:
- WordPress-native Markdown rendering with internal `.md` link rewriting.
- Frontend routes under `/docs/`.
- Shortcodes: `[kb_docs]`, `[kb_docs_index]`, `[kb_product_index product="..."]`, `[kb_search]`.
- Stored OLM changelog sync using the saved OLM credentials, grouped by release month.
- Product management for frontend product grouping, documentation parts and categories.
- Wide documentation app layout with an always-visible active page navigation.
- Import logs without exposing secrets.
## Product grouping
Imported GitLab projects stay as individual `kb_product` terms. In **Knowledgebase > Products**, each imported term can be assigned to a shared frontend product by setting the same frontend product name and slug. Changes are edited in one table and persisted with **Save all products**.
Example:
```text
HubSpot App -> frontend product: HubSpot, part: App
HubSpot Modul -> frontend product: HubSpot, part: Modul
DATEV Exporter -> frontend product: DATEV, part: Exporter
```
Products can also be assigned a category such as `CRM`, `Telefonie` or `Integrationen`; the frontend groups product cards and sidebar entries by this category.
## OLM changelog sync
The changelog is synchronized manually from **Knowledgebase > Synchronization** with `Sync OLM Changelog`, or automatically whenever the configured documentation sync interval runs. `Sync All` runs both the GitLab import and the OLM changelog sync.
The sync follows the existing Python flow: login, load OLM products, collect download IDs, load download field versions, then store only QA-approved published changelog entries in WordPress. The documentation overview reads this stored data instead of calling OLM on every page view.

View File

@@ -4,6 +4,14 @@ defined('ABSPATH') || exit;
$active_product_slug = (string) ($active_product_slug ?? '');
$active_version_slug = (string) ($active_version_slug ?? '');
$active_page_slug = (string) ($active_page_slug ?? '');
$active_product = is_array($active_product ?? null) ? $active_product : null;
$active_page_link_slugs = (array) ($active_page_link_slugs ?? []);
$products_by_category = [];
foreach ((array) $products as $item) {
$category = trim((string) ($item['category'] ?? ''));
$products_by_category[$category ?: __('Weitere Produkte', 'kb-markdown-importer')][] = $item;
}
?>
<div class="kb-docs-wrap kb-docs-app">
<aside class="kb-app-sidebar" aria-label="<?php esc_attr_e('Dokumentationsnavigation', 'kb-markdown-importer'); ?>">
@@ -13,31 +21,49 @@ $active_page_slug = (string) ($active_page_slug ?? '');
<div class="kb-app-sidebar__search">
<?php echo do_shortcode('[kb_search]'); ?>
</div>
<?php if ($active_product && $active_version_slug && ! empty($active_pages)) : ?>
<section class="kb-app-active-nav" aria-label="<?php esc_attr_e('Aktive Dokumentation', 'kb-markdown-importer'); ?>">
<a class="kb-app-active-nav__title" href="<?php echo esc_url($url_builder::version($active_product_slug, $active_version_slug)); ?>">
<?php echo esc_html((string) $active_product['term']->name); ?>
</a>
<ul class="kb-app-page-list">
<?php foreach ((array) $active_pages as $page) : ?>
<?php
$linkSlug = (string) ($active_page_link_slugs[$page->ID] ?? get_post_meta($page->ID, '_kb_page_slug', true));
$isActivePage = $linkSlug === $active_page_slug || ('' === $active_page_slug && in_array($linkSlug, ['', 'index'], true));
$sourceSlug = (string) get_post_meta($page->ID, '_kb_product_slug', true);
$part = (array) (($active_product['parts'][$sourceSlug] ?? []) ?: []);
?>
<li class="<?php echo $isActivePage ? 'is-active' : ''; ?>">
<a href="<?php echo esc_url($url_builder::page($active_product_slug, $active_version_slug, $linkSlug)); ?>">
<?php if (! empty($part['label']) && count((array) $active_product['parts']) > 1) : ?>
<span class="kb-app-page-list__part"><?php echo esc_html((string) $part['label']); ?></span>
<?php endif; ?>
<?php echo esc_html(get_the_title($page)); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</section>
<?php endif; ?>
<nav class="kb-app-nav">
<?php foreach ((array) $products as $item) : ?>
<?php
$term = $item['term'];
$versions = (array) $item['versions'];
$latest = $versions[0] ?? null;
$isActiveProduct = $term->slug === $active_product_slug;
?>
<section class="kb-app-product <?php echo $isActiveProduct ? 'is-active' : ''; ?>">
<a class="kb-app-product__link" href="<?php echo esc_url($latest ? $url_builder::version($term->slug, $latest->slug) : $url_builder::product($term->slug)); ?>">
<?php echo esc_html($term->name); ?>
</a>
<?php if ($isActiveProduct && $active_version_slug && ! empty($active_pages)) : ?>
<ul class="kb-app-page-list">
<?php foreach ((array) $active_pages as $page) : ?>
<?php
$pageSlug = (string) get_post_meta($page->ID, '_kb_page_slug', true);
$isActivePage = $pageSlug === $active_page_slug || ('' === $active_page_slug && in_array($pageSlug, ['', 'index'], true));
?>
<li class="<?php echo $isActivePage ? 'is-active' : ''; ?>">
<a href="<?php echo esc_url($url_builder::page($term->slug, $active_version_slug, $pageSlug)); ?>"><?php echo esc_html(get_the_title($page)); ?></a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<h2 class="kb-app-nav__title"><?php esc_html_e('Alle Produkte', 'kb-markdown-importer'); ?></h2>
<?php foreach ($products_by_category as $category => $category_products) : ?>
<section class="kb-app-category">
<h3><?php echo esc_html((string) $category); ?></h3>
<?php foreach ((array) $category_products as $item) : ?>
<?php
$term = $item['term'];
$versions = (array) $item['versions'];
$latest = $versions[0] ?? null;
$isActiveProduct = $term->slug === $active_product_slug;
?>
<section class="kb-app-product <?php echo $isActiveProduct ? 'is-active' : ''; ?>">
<a class="kb-app-product__link" href="<?php echo esc_url($latest ? $url_builder::version($term->slug, $latest->slug) : $url_builder::product($term->slug)); ?>">
<?php echo esc_html($term->name); ?>
</a>
</section>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
</nav>

View File

@@ -3,6 +3,18 @@ defined('ABSPATH') || exit;
$settings = (array) ($settings ?? []);
$updates = (array) ($updates ?? []);
$updates_by_month = [];
$products_by_category = [];
foreach ($updates as $update) {
$month = (string) ($update['month_label'] ?? '');
$updates_by_month[$month ?: ''][] = $update;
}
foreach ((array) ($products ?? []) as $item) {
$category = trim((string) ($item['category'] ?? ''));
$products_by_category[$category ?: __('Weitere Produkte', 'kb-markdown-importer')][] = $item;
}
?>
<section class="kb-docs-home">
<h1><?php esc_html_e('Dokumentation', 'kb-markdown-importer'); ?></h1>
@@ -16,38 +28,85 @@ $updates = (array) ($updates ?? []);
<section class="kb-home-card kb-product-updates">
<h2><?php esc_html_e('Neueste Produktupdates', 'kb-markdown-importer'); ?></h2>
<?php if ($updates) : ?>
<ul class="kb-product-updates__list">
<?php foreach ($updates as $update) : ?>
<li>
<div class="kb-product-updates__meta">
<strong><?php echo esc_html((string) ($update['product'] ?: __('Produkt', 'kb-markdown-importer'))); ?></strong>
<?php if (! empty($update['version'])) : ?><span><?php echo esc_html((string) $update['version']); ?></span><?php endif; ?>
<?php if (! empty($update['date'])) : ?><time><?php echo esc_html((string) $update['date']); ?></time><?php endif; ?>
</div>
<?php if (! empty($update['changelog'])) : ?>
<p><?php echo esc_html((string) $update['changelog']); ?></p>
<?php endif; ?>
</li>
<?php foreach ($updates_by_month as $month => $month_updates) : ?>
<section class="kb-product-updates__month">
<?php if ('' !== $month) : ?><h3><?php echo esc_html($month); ?></h3><?php endif; ?>
<ul class="kb-product-updates__list">
<?php foreach ($month_updates as $update) : ?>
<li>
<div class="kb-product-updates__meta">
<?php if (! empty($update['link'])) : ?>
<strong><a href="<?php echo esc_url((string) $update['link']); ?>" target="_blank" rel="noopener"><?php echo esc_html((string) ($update['product'] ?: __('Produkt', 'kb-markdown-importer'))); ?></a></strong>
<?php else : ?>
<strong><?php echo esc_html((string) ($update['product'] ?: __('Produkt', 'kb-markdown-importer'))); ?></strong>
<?php endif; ?>
<?php if (! empty($update['version'])) : ?><span><?php echo esc_html((string) $update['version']); ?></span><?php endif; ?>
<?php if (! empty($update['date'])) : ?><time><?php echo esc_html((string) $update['date']); ?></time><?php endif; ?>
</div>
<?php if (! empty($update['changelog_lines']) && is_array($update['changelog_lines'])) : ?>
<ul class="kb-product-updates__changes">
<?php foreach ($update['changelog_lines'] as $line) : ?>
<li><?php echo esc_html((string) $line); ?></li>
<?php endforeach; ?>
</ul>
<?php elseif (! empty($update['changelog'])) : ?>
<p><?php echo esc_html((string) $update['changelog']); ?></p>
<?php endif; ?>
<?php if (! empty($update['starface_min']) || ! empty($update['starface_max']) || ! empty($update['link']) || ! empty($update['download_link'])) : ?>
<dl class="kb-product-updates__details">
<?php if (! empty($update['starface_min']) || ! empty($update['starface_max'])) : ?>
<div>
<dt><?php esc_html_e('STARFACE', 'kb-markdown-importer'); ?></dt>
<dd><?php echo esc_html(trim((string) ($update['starface_min'] ?? '') . ' - ' . (string) ($update['starface_max'] ?? ''), ' -')); ?></dd>
</div>
<?php endif; ?>
<?php if (! empty($update['link'])) : ?>
<div>
<dt><?php esc_html_e('Dokumentation', 'kb-markdown-importer'); ?></dt>
<dd><a href="<?php echo esc_url((string) $update['link']); ?>" target="_blank" rel="noopener"><?php echo esc_html((string) $update['link']); ?></a></dd>
</div>
<?php endif; ?>
<?php if (! empty($update['download_link'])) : ?>
<div>
<dt><?php esc_html_e('Download', 'kb-markdown-importer'); ?></dt>
<dd><a href="<?php echo esc_url((string) $update['download_link']); ?>" target="_blank" rel="noopener">get.o-byte.com</a></dd>
</div>
<?php endif; ?>
</dl>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</section>
<?php endforeach; ?>
</ul>
<?php else : ?>
<p class="kb-empty-state"><?php esc_html_e('Es wurden noch keine Produktupdates gefunden.', 'kb-markdown-importer'); ?></p>
<?php endif; ?>
</section>
</div>
<div class="kb-product-list">
<?php foreach ((array) $products as $item) : ?>
<?php $term = $item['term']; ?>
<?php $latest = $item['versions'][0] ?? null; ?>
<section class="kb-product-card">
<h2><a href="<?php echo esc_url($latest ? $url_builder::version($term->slug, $latest->slug) : $url_builder::product($term->slug)); ?>"><?php echo esc_html($term->name); ?></a></h2>
<?php if (! empty($item['versions'])) : ?>
<ul>
<?php foreach ($item['versions'] as $version) : ?>
<li><a href="<?php echo esc_url($url_builder::version($term->slug, $version->slug)); ?>"><?php echo esc_html($version->name); ?></a></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="kb-product-groups">
<?php foreach ($products_by_category as $category => $category_products) : ?>
<section class="kb-product-category">
<h2><?php echo esc_html((string) $category); ?></h2>
<div class="kb-product-list">
<?php foreach ((array) $category_products as $item) : ?>
<?php $term = $item['term']; ?>
<?php $latest = $item['versions'][0] ?? null; ?>
<section class="kb-product-card">
<h3><a href="<?php echo esc_url($latest ? $url_builder::version($term->slug, $latest->slug) : $url_builder::product($term->slug)); ?>"><?php echo esc_html($term->name); ?></a></h3>
<?php if (! empty($item['parts']) && count((array) $item['parts']) > 1) : ?>
<p class="kb-product-card__parts"><?php echo esc_html(implode(', ', array_map(static fn (array $part): string => (string) $part['label'], (array) $item['parts']))); ?></p>
<?php endif; ?>
<?php if (! empty($item['versions'])) : ?>
<ul>
<?php foreach ($item['versions'] as $version) : ?>
<li><a href="<?php echo esc_url($url_builder::version($term->slug, $version->slug)); ?>"><?php echo esc_html($version->name); ?></a></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
</div>

View File

@@ -1,10 +1,16 @@
<?php
defined('ABSPATH') || exit;
$product_item = is_array($product_item ?? null) ? $product_item : [];
$parts = (array) ($product_item['parts'] ?? []);
?>
<section class="kb-docs-product">
<nav class="kb-breadcrumbs"><a href="<?php echo esc_url($url_builder::docsIndex()); ?>"><?php esc_html_e('Dokumentation', 'kb-markdown-importer'); ?></a><span>/</span><?php echo esc_html($product->name); ?></nav>
<h1><?php echo esc_html($product->name); ?></h1>
<h2><?php esc_html_e('Verfügbare Versionen', 'kb-markdown-importer'); ?></h2>
<?php if (count($parts) > 1) : ?>
<p class="kb-product-parts"><?php echo esc_html(implode(', ', array_map(static fn (array $part): string => (string) $part['label'], $parts))); ?></p>
<?php endif; ?>
<h2><?php esc_html_e('Verfuegbare Versionen', 'kb-markdown-importer'); ?></h2>
<ul class="kb-version-list">
<?php foreach ((array) $versions as $index => $version) : ?>
<li>

View File

@@ -1,5 +1,8 @@
<?php
defined('ABSPATH') || exit;
$product_item = is_array($product_item ?? null) ? $product_item : [];
$parts = (array) ($product_item['parts'] ?? []);
$page_link_slugs = (array) ($page_link_slugs ?? []);
?>
<section class="kb-docs-version">
<header class="kb-doc-header">
@@ -26,8 +29,19 @@ defined('ABSPATH') || exit;
</header>
<ul class="kb-page-list">
<?php foreach ((array) $pages as $page) : ?>
<?php $slug = (string) get_post_meta($page->ID, '_kb_page_slug', true); ?>
<li><a href="<?php echo esc_url($url_builder::page($product->slug, $version->slug, $slug)); ?>"><?php echo esc_html(get_the_title($page)); ?></a></li>
<?php
$slug = (string) ($page_link_slugs[$page->ID] ?? get_post_meta($page->ID, '_kb_page_slug', true));
$sourceSlug = (string) get_post_meta($page->ID, '_kb_product_slug', true);
$part = (array) ($parts[$sourceSlug] ?? []);
?>
<li>
<a href="<?php echo esc_url($url_builder::page($product->slug, $version->slug, $slug)); ?>">
<?php if (! empty($part['label']) && count($parts) > 1) : ?>
<span class="kb-page-list__part"><?php echo esc_html((string) $part['label']); ?></span>
<?php endif; ?>
<?php echo esc_html(get_the_title($page)); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</section>