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

34309
apiquery.txt Normal file

File diff suppressed because it is too large Load Diff

221
changelog.py Normal file
View File

@@ -0,0 +1,221 @@
#!/bin/python3
import requests
import locale
import json
from datetime import datetime, timedelta
import urllib.request
from collections import defaultdict
import re
############################################################################
############################################################################
############################################################################
#Konfiguration OLM
olm_api = 'https://olm.o-byte.com'
olm_USERNAME = 'restapi'
olm_PASSWD = 'CHANGEME'
#Konfiguration sonstige
file_path = '/opt/knowledge/antora/docs-site/build/site/Dokumentationen/0.1/welcome.html'
#folgende OLM NUmmern werden ignoriert
ignoreOLMno = ["olm-10109", "olm-10110"]
#Deutsch für das Datum setzten
locale.setlocale(locale.LC_TIME, 'de_DE.UTF-8')
############################################################################
############################################################################
############################################################################
def get_olm_token(username, passwd):
authRequest = {}
authRequest['username'] = username
authRequest['password'] = passwd
req = urllib.request.Request(
olm_api + '/login', data=json.dumps(authRequest).encode('utf8'),
headers={'Content-type': 'application/json', 'Accept': 'application/json'})
response = urllib.request.urlopen(req)
responseText = response.read().decode('utf8')
return json.loads(responseText)
def sort_by_date(data):
return sorted(data, key=lambda x: datetime.strptime(x[0], "%Y-%m-%d"), reverse=True)
def wrap_with_ul(data):
lines = data.splitlines()
lines_with_li = [f"<li>{line}</li>" for line in lines]
return "<ul>\n" + "\n".join(lines_with_li) + "\n</ul>"
def parse_date(date_string):
return datetime.strptime(date_string, "%Y-%m-%d")
def replace_product_updates(file_path, new_content):
try:
# HTML-Datei öffnen und den Inhalt lesen
with open(file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
# Suchen nach dem Tag ###PRODUKTUPDATES### und Ersetzen durch den neuen Inhalt
updated_html = html_content.replace("###PRODUKTUPDATES###", new_content)
# Überschreiben der HTML-Datei mit dem aktualisierten Inhalt
with open(file_path, 'w', encoding='utf-8') as file:
file.write(updated_html)
print(f"Der Bereich ###PRODUKTUPDATES### wurde erfolgreich durch den neuen Inhalt ersetzt.")
except Exception as e:
print(f"Ein Fehler ist aufgetreten: {e}")
############################################################################
# Auth Tokens
############################################################################
# Auth Token OLM
token_olm = get_olm_token(olm_USERNAME, olm_PASSWD)
############################################################################
# Bearer Headers
############################################################################
headers_olm = {
'Content-type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer ' + token_olm['bearerToken']
}
product_ids = []
product_versions = []
page_products = 1
while True:
response = requests.get(olm_api + f"/api/rest/v1/product?page={page_products}&size=1", headers=headers_olm)
data = response.json()
products = data.get('content', [])
if not products:
break
for product in products:
ids = product.get('downloads', [])
for id in ids:
product_ids.append(id["id"])
page_products += 1
for product_id in product_ids:
page_versions = 1
while True:
response = requests.get(olm_api + f"/api/rest/v1/download/field/{product_id}?page={page_versions}&size=1", headers=headers_olm)
data = response.json()
versions = data.get('content', [])
if not versions:
break
for version in versions:
if version["qa"] and version["productVersion"]["product"]["published"] and version["publishedAt"]:
temp = []
if version["productVersion"]["product"]["productNo"] in ignoreOLMno:
continue
#temp.append(version["productVersion"]["createdOn"]) #0
temp.append(version["publishedAt"]) #0
temp.append(version["productVersion"]["product"]["name"]) #1
temp.append(version["changelog"]) #2
temp.append(version["productVersion"]["product"]["productPageURI"]) #3
temp.append(version["productVersion"]["product"]["productNo"]) #4
modul_version = str(version["productVersion"]["major"]) + "." + str(version["productVersion"]["minor"]) + "." + str(version["bugfixVersion"])
starface_min = str(version["productVersion"]["minStarfaceVersion"]["major"]) + "." + str(version["productVersion"]["minStarfaceVersion"]["minor"]) + "." + str(version["productVersion"]["minStarfaceVersion"]["build"]) + "." + str(version["productVersion"]["minStarfaceVersion"]["revision"])
starface_max = str(version["productVersion"]["maxStarfaceVersion"]["major"]) + "." + str(version["productVersion"]["maxStarfaceVersion"]["minor"]) + "." + str(version["productVersion"]["maxStarfaceVersion"]["build"]) + "." + str(version["productVersion"]["maxStarfaceVersion"]["revision"])
temp.append(modul_version) #5
temp.append(starface_min) #6
temp.append(starface_max) #7
temp.append(version["downloadField"]["name"]) #8
temp.append(version["downloadField"]["starfaceModule"]) #9
#print(temp)
product_versions.append(temp)
page_versions += 1
sorted_data = sort_by_date(product_versions)
aktuelles_datum = datetime.now()
drei_monate_zurueck = aktuelles_datum.replace(day=1) - timedelta(days=1)
drei_monate_zurueck = drei_monate_zurueck.replace(day=1) - timedelta(days=1)
drei_monate_zurueck = drei_monate_zurueck.replace(day=1) - timedelta(days=1)
drei_monate_zurueck = drei_monate_zurueck.replace(day=1) - timedelta(days=1)
anfang_vier_monate = drei_monate_zurueck.replace(day=1)
gefilterte_changelogs = []
for eintrag in sorted_data:
datum = parse_date(eintrag[0])
if anfang_vier_monate <= datum <= aktuelles_datum:
gefilterte_changelogs.append(eintrag)
sorted_data = gefilterte_changelogs
# Dictionary zum Gruppieren der Einträge nach Monat und Jahr
monthly_data = defaultdict(list)
# Daten in Monatsgruppen einordnen
for entry in sorted_data:
date_str = entry[0]
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
month_year = date_obj.strftime("%B %Y") # z.B. "September 2023"
monthly_data[month_year].append(entry)
#print("4")
# Erstellen der HTML-Datei
# Sortiere die Monatsblöcke von neu nach alt und füge sie der HTML hinzu
html = "
"
for month_year, entries in sorted(monthly_data.items(), key=lambda x: datetime.strptime(x[0], "%B %Y"), reverse=True):
# Monats-Überschrift in einem separaten Table-Block
html += f"""
<table width="100%" style="background-color: #00a7e6; border-collapse: separate; border-spacing: 0; border-radius: 10px; overflow: hidden;"
<tr>
<td><h3>{month_year}</h3></td>
</tr>
</table>
"""
# Tabelle für die Produkte
for entry in entries:
date_obj = datetime.strptime(entry[0], "%Y-%m-%d")
date = date_obj.strftime("%d.%m.%Y")
if entry[9]:
produktname = entry[1]
else:
produktname = entry[1] + " - " + entry[8]
changelog = entry[2].replace("\n", "
\n")
changelog = wrap_with_ul(changelog)
html += f"""
<table width="100%" style="border-collapse: separate; border-spacing: 0; border-radius: 10px; overflow: hidden; border: 1px solid #ddd;">
<tr style="background-color: #ddd;">
<td ><b>{produktname} ({entry[5]})</b></td>
<td style="text-align: right;"><b>{date}</b>&nbsp;</td>
</tr>
<tr>
<td colspan="2">
<u>Änderungen:</u>
{changelog}
</td>
</tr>
<tr>
<td style="border-top: 1px solid #ddd;" width="300px">kompatibel mit STARFACE Version:
</td>
<td style="border-top: 1px solid #ddd;">{entry[6]} - {entry[7]}
</td>
</tr>
<tr>
<td>Dokumentation
</td>
<td><a href='{entry[3]}' target='_blank' >{entry[3]}</a>
</td>
</tr>
<tr>
<td>Download
</td>
<td><a href='https://get.o-byte.com?olm={entry[4]}' target='_blank' >https://get.o-byte.com</a>
</td>
</tr>
</table>
"""
html += "
"
html += "
"
replace_product_updates(file_path, html)

View File

@@ -188,8 +188,11 @@ Shortcodes:
`[kb_docs]` bindet die Dokumentation in eine normale WordPress-Seite ein.
Die Ausgabe ist als Doku-App aufgebaut: Startseite rechts, persistente Sidebar links. Die Sidebar zeigt alle Produkte und fuer die aktive Version alle Seiten, sodass man direkt von der Portalseite in die konkrete Doku springen kann. Die Version wird nicht in der Sidebar gewechselt, sondern per Dropdown im Kopf der jeweiligen Dokumentationsseite.
Die Dokumentations-Startseite ist im Backend anpassbar. Sie enthaelt einen frei pflegbaren Anleitungstext zum Umgang mit der Dokumentation und einen Produktupdate-Bereich. Der Produktupdate-Bereich liest wahlweise einen konfigurierbaren RSS-/XML-Feed oder eine REST-/JSON-API aus und zeigt die neuesten Updates mit Produktname, Version, Datum und Changelog. Quelle, URL, Anzahl der Updates, Eintrag-/Listenpfad und die Feldpfade fuer Produktname, Version, Datum und Changelog sind im Backend frei definierbar.
Im Backend gibt es einen Testbutton fuer die konfigurierte Produktupdate-Quelle. Der Test zeigt HTTP-Status, Content-Type und einen gekuerzten Roh-Response an, damit die Feldzuordnung gegen die echte API-Antwort geprueft werden kann.
Die Dokumentations-Startseite ist im Backend anpassbar. Sie enthaelt einen frei pflegbaren Anleitungstext zum Umgang mit der Dokumentation und einen Produktupdate-Bereich. Produktupdates werden nicht bei jedem Seitenaufruf live geladen, sondern per OLM-Changelog-Sync in WordPress gespeichert und danach aus der Datenbank gelesen.
Der OLM-Changelog-Sync bildet die bestehende Python-Logik nach: `POST /login`, danach paginiert `/api/rest/v1/product?page=N&size=1`, sammelt `downloads[].id` und liest pro Download `/api/rest/v1/download/field/{id}?page=N&size=1`. Importiert werden nur Eintraege mit `qa=true`, `publishedAt`, veroeffentlichtem Produkt und nicht ignorierter `productNo`. Der Zeitraum und die ignorierten OLM-Nummern sind im Backend konfigurierbar.
Der Changelog-Sync laeuft manuell ueber die Synchronisationsseite und automatisch mit dem bestehenden Synchronisationsintervall. `Sync All` fuehrt sowohl den GitLab-Doku-Import als auch den OLM-Changelog-Sync aus.
Unter `Produkte` koennen importierte GitLab-Produkte einem gemeinsamen Frontend-Produkt zugeordnet werden. Damit lassen sich mehrere Teildokumentationen, z. B. App, Modul und Exporter, als ein Produkt in der Dokumentation anzeigen. Pro Produkt/Modul kann ausserdem eine Kategorie fuer die Frontend-Gruppierung gepflegt werden.
Die Sidebar zeigt die aktive Seitennavigation immer oben separat an. Die komplette Produktliste ist darunter nach Kategorien gruppiert, damit Nutzer nicht in langen Produktlisten zur aktuellen Seitennavigation scrollen muessen.
## 10. Admin-Einstellungen
@@ -203,7 +206,7 @@ Knowledgebase
Einstellungen
```
Unter `Produkte` koennen importierte Produkte verwaltet werden. Admins koennen Namen und Slugs korrigieren oder ein fehlerhaft importiertes Produkt inklusive der zugehoerigen Doku-Seiten in den Papierkorb verschieben.
Unter `Produkte` koennen importierte Produkte in einer Tabelle gesammelt verwaltet und mit einem `Save all`-Button gespeichert werden. Admins koennen Frontend-Produkt, Teildokumentation und Kategorie pflegen oder ausgewaehlte fehlerhafte Produkte inklusive der zugehoerigen Doku-Seiten in den Papierkorb verschieben.
Einstellungen:
@@ -219,8 +222,9 @@ Einstellungen:
- Frontend-Design
- Optionale eigene `theme.css`
- Dokumentations-Startseite mit Anleitungstext
- Produktupdate-Quelle als RSS/XML oder REST/JSON inkl. frei definierbarer Feldzuordnung fuer Produktname, Version, Datum und Changelog
- Testbutton fuer die Produktupdate-Quelle mit Anzeige der Rohantwort
- OLM-Zugangsdaten fuer den Changelog-Sync
- OLM-Changelog-Zeitraum und ignorierte OLM-Nummern
- Produktmanagement mit Frontend-Produkt, Teildokumentation und Kategorie
Es gibt keine Renderer-Modus-Einstellung mehr. Markdown wird direkt im Plugin verarbeitet.
@@ -251,9 +255,10 @@ Es gibt keine Renderer-Modus-Einstellung mehr. Markdown wird direkt im Plugin ve
- `/docs/` zeigt die Dokumentationsuebersicht.
- `[kb_docs]` zeigt eine Startseite mit persistenter Produkt- und Seitennavigation sowie Versionswechsel per Dropdown in der Dokumentationsseite.
- Die Dokumentations-Startseite zeigt den im Backend gepflegten Anleitungstext.
- Die Dokumentations-Startseite zeigt die neuesten Produktupdates aus einem konfigurierbaren RSS-/XML-Feed oder REST-/JSON-Endpunkt.
- Die XML- oder JSON-Felder fuer Produktname, Version, Datum und Changelog koennen im Backend frei zugeordnet werden.
- Die konfigurierte Produktupdate-Quelle kann im Backend getestet werden; Status, Content-Type und Rohantwort werden angezeigt.
- Die Dokumentations-Startseite zeigt die per OLM-Changelog-Sync gespeicherten Produktupdates.
- OLM-Changelog-Daten koennen manuell und per bestehendem Sync-Intervall aktualisiert werden.
- Mehrere importierte GitLab-Produkte koennen im Frontend als ein Produkt zusammengefasst werden.
- Produkte koennen Kategorien zugeordnet werden.
- `/docs/{product}/{version}/` zeigt die Startseite.
- Im Backend koennen Produkte bei fehlerhaften Importen verwaltet und entfernt werden.
- Synchronisation dupliziert unveraenderte Seiten nicht.

378
getolm.php Normal file
View File

@@ -0,0 +1,378 @@
<?php
require_once plugin_dir_path(__FILE__) . '../controller/logger.php';
require_once plugin_dir_path(__FILE__) . '../model/olm-login.php';
function get_all_host_from_olm_cache()
{
$olm_api = get_option('olm_url');
$token_olm = cache_get_olm_token();
$headers_olm = array(
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $token_olm['bearerToken']
);
$page = 1;
$size = 1000;
$allResults = [];
do {
echo "page: $page<br>";
flush();
$url = $olm_api . "/api/rest/v1/host?page={$page}&size={$size}";
$response = curl_get($url, $headers_olm);
flush();
$data = $response;
flush();
if ($data === null) {
echo "Fehlgeschlagen beim Abrufen der Seite {$page}. Ungültiges JSON.<br>";
throw new Exception("Ungültiges JSON auf Seite {$page}");
}
// Inhalte sammeln (falls vorhanden)
if (isset($data['content']) && is_array($data['content'])) {
$allResults = array_merge($allResults, $data['content']);
}
$isLast = $data['last'] ?? true; // Sicherheitshalber abbrechen, falls Feld fehlt
$page++;
} while ($isLast === false);
global $wpdb;
$table = $wpdb->prefix . 'api_cache';
foreach ($allResults as $item) {
flush();
$json_id = $item['id'];
$name = $item['name'];
$customer_no = $item['customer']['customerNo'];
$customer_name = $item['customer']['name'];
$identifier = $item['identifier'];
$reported_version = $item['reportedVersion'] ?? null;
// Falls assignedReseller.customerNo existiert
$assigned_reseller_no = $item['customer']['assignedReseller']['customerNo'] ?? null;
$wpdb->replace(
$table,
[
'json_id' => $json_id,
'name' => $name,
'customer_no' => $customer_no,
'customer' => $customer_name,
'identifier' => $identifier,
'assigned_reseller_no' => $assigned_reseller_no,
'reported_version' => $reported_version,
'response' => json_encode($item),
]
);
}
}
function _host_build_where_sql($wpdb, $search, $filters)
{
$filter_map = [
'name' => 'customer',
'customerNo' => 'customer_no',
'hostName' => 'name',
'licenseKey' => 'identifier',
];
$search = trim((string) $search);
$pbx_key = strtolower(str_replace('-', '', $search));
$where_parts = [];
$prepare_values = [];
if ($search !== '') {
if (!empty($filters)) {
foreach ($filters as $filter) {
if (!isset($filter_map[$filter])) continue;
$column = $filter_map[$filter];
$where_parts[] = "$column LIKE %s";
$prepare_values[] = '%' . $wpdb->esc_like($column === 'identifier' ? $pbx_key : $search) . '%';
}
} else {
$where_parts = ["customer LIKE %s", "name LIKE %s", "customer_no LIKE %s", "identifier LIKE %s"];
$prepare_values = [
'%' . $wpdb->esc_like($search) . '%',
'%' . $wpdb->esc_like($search) . '%',
'%' . $wpdb->esc_like($search) . '%',
'%' . $wpdb->esc_like($pbx_key) . '%',
];
}
}
$where_sql = empty($where_parts) ? '' : ' AND (' . implode(' OR ', $where_parts) . ')';
return [$where_sql, $prepare_values];
}
function host_db_reseller_cached($customer_no, $start, $size, $search, $filters = [])
{
global $wpdb;
$table = $wpdb->prefix . 'api_cache';
$start = (int) $start;
$size = (int) $size;
[$where_sql, $prepare_values] = _host_build_where_sql($wpdb, $search, $filters);
$total_count = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table}
WHERE (customer_no = %s OR assigned_reseller_no = %s) {$where_sql}",
array_merge([$customer_no, $customer_no], $prepare_values)
));
$result = $wpdb->get_results($wpdb->prepare(
"SELECT response FROM {$table}
WHERE (customer_no = %s OR assigned_reseller_no = %s) {$where_sql}
ORDER BY customer ASC LIMIT %d OFFSET %d",
array_merge([$customer_no, $customer_no], $prepare_values, [$size, $start])
), ARRAY_A);
foreach ($result as $item) {
$entry = json_decode($item['response'], true);
if ($uuid = $entry['id'] ?? null) queue_add_job('cachedKunden', $uuid);
}
return ['total' => (int) $total_count, 'data' => $result];
}
function host_db_reseller_cached_by_customer($customer_no, $page, $size, $search, $filters = [])
{
global $wpdb;
$table = $wpdb->prefix . 'api_cache';
$start = (int) (($page - 1) * $size);
$size = (int) $size;
[$where_sql, $prepare_values] = _host_build_where_sql($wpdb, $search, $filters);
$total_count = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(DISTINCT customer_no) FROM {$table}
WHERE (customer_no = %s OR assigned_reseller_no = %s) {$where_sql}",
array_merge([$customer_no, $customer_no], $prepare_values)
));
$customer_nos = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT customer_no FROM {$table}
WHERE (customer_no = %s OR assigned_reseller_no = %s) {$where_sql}
ORDER BY customer ASC LIMIT %d OFFSET %d",
array_merge([$customer_no, $customer_no], $prepare_values, [$size, $start])
));
if (empty($customer_nos)) {
return ['total' => (int) $total_count, 'data' => []];
}
$placeholders = implode(',', array_fill(0, count($customer_nos), '%s'));
$result = $wpdb->get_results($wpdb->prepare(
"SELECT response FROM {$table}
WHERE customer_no IN ($placeholders)
AND (customer_no = %s OR assigned_reseller_no = %s)
ORDER BY customer ASC, name ASC",
array_merge($customer_nos, [$customer_no, $customer_no])
), ARRAY_A);
foreach ($result as $item) {
$entry = json_decode($item['response'], true);
if ($uuid = $entry['id'] ?? null) queue_add_job('cachedKunden', $uuid);
}
return ['total' => (int) $total_count, 'data' => $result];
}
function refresh_host_by($id)
{
$url = get_option('olm_url') . "/api/rest/v1/host/{$id}";
$token_olm = cache_get_olm_token();
$headers_olm = array(
'Content-Type: application/json',
'Accept: application/json',
'Authorization: Bearer ' . $token_olm['bearerToken']
);
$item = curl_get($url, $headers_olm);
$json_id = $item['id'];
$name = $item['name'];
$customer_no = $item['customer']['customerNo'];
$customer_name = $item['customer']['name'];
$identifier = $item['identifier'];
$reported_version = $item['reportedVersion'] ?? null;
// Falls assignedReseller.customerNo existiert
$assigned_reseller_no = $item['customer']['assignedReseller']['customerNo'] ?? null;
global $wpdb;
$table = $wpdb->prefix . 'api_cache';
$wpdb->replace(
$table,
[
'json_id' => $json_id,
'name' => $name,
'customer_no' => $customer_no,
'customer' => $customer_name,
'identifier' => $identifier,
'assigned_reseller_no' => $assigned_reseller_no,
'reported_version' => $reported_version,
'response' => json_encode($item),
]
);
}
function curl_get($url, $headers)
{
$maxRetries = 3;
$attempt = 0;
$response = false;
$start = microtime(true);
$formattedHeaders = [];
foreach ($headers as $key => $value) {
$formattedHeaders[] = $key . ": " . $value;
}
do {
$opts = [
"http" => [
"method" => "GET",
"header" => $headers,
"timeout" => 3, // Timeout in Sekunden
]
// ,
// "ssl" => [
// "verify_peer" => false, // wie CURLOPT_SSL_VERIFYPEER
// "verify_peer_name" => false, // wie CURLOPT_SSL_VERIFYHOST
// ]
];
$context = stream_context_create($opts);
$response = @file_get_contents($url, false, $context);
$attempt++;
if ($response === false) {
$error = error_get_last();
log_msg("Versuch $attempt fehlgeschlagen: " . ($error['message'] ?? 'Unbekannter Fehler') . " url: $url<br>");
flush();
}
} while ($response === false && $attempt < $maxRetries);
if ($response === false) {
echo "Fehler nach $maxRetries Versuchen: ";
log_msg("❌ ERROR: CURL GET fehlgeschlagen nach $maxRetries Versuchen: " . ($error['message'] ?? 'Unbekannter Fehler') . " url: $url<br>");
return null;
}
return json_decode($response, true);
}
function curl_get_cached($url, $headers, $norefresh = true)
{
$hash = md5($url);
$kundennummer = explode('/', trim((explode('/api/rest/v1/customer/customerno/', $url)[1]), '/'))[0];
$cached = get_transient($hash);
if (!$norefresh) {
$response = curl_get($url, $headers);
delete_transient($hash);
$result = set_transient($hash, $response, 7200 * MINUTE_IN_SECONDS);
if (!$result) {
log_msg("❌ Transient konnte nicht gesetzt werden!");
}
return null;
}
if (!($cached === false)) {
queue_add_job('cachedKunden', $kundennummer);
return $cached; // aus Cache
}
$response = curl_get($url, $headers);
set_transient($hash, $response, 7200 * MINUTE_IN_SECONDS);
return $response;
}
function post_request($url, $headers, $data)
{
$options = array(
'http' => array(
'header' => implode("\r\n", $headers),
'method' => 'POST',
'content' => json_encode($data)
)
);
$context = stream_context_create($options);
$result = file_get_contents($url, false, $context);
return json_decode($result, true);
}
function queue_add_job($job_type, $payload = [])
{
global $wpdb;
$table = $wpdb->prefix . "job_queue";
// Prüfen ob Payload existiert
$exists = $wpdb->get_var(
$wpdb->prepare(
"SELECT 1 FROM {$table} WHERE payload = %s LIMIT 1",
$payload
)
);
if ($exists) {
return false; // oder true / ID zurückgeben je nach Logik
}
$wpdb->insert(
$table,
[
'job_type' => sanitize_text_field($job_type),
'payload' => ($payload),
'status' => 'pending',
'created_at' => current_time('mysql'),
]
);
return $wpdb->insert_id; // Job-ID zurückgeben
}
?>

Binary file not shown.

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>

28
olm-login.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
function get_olm_token()
{
$olm_api = get_option('olm_url');
$olm_USERNAME = get_option('olm_user');
$olm_PASSWD = get_option('olm_password');
$authRequest = array("username" => $olm_USERNAME, "password" => $olm_PASSWD);
try {
$response = post_request($olm_api . "/login",["Content-Type: application/json"],$authRequest );
} catch (Exception $e) {
log_msg("❌ ERROR: OLM Login failed " . $e->getMessage());
return null;
}
set_transient('olm_bearer_token', $response, 90);
return $response;
}
function cache_get_olm_token()
{
$token = get_transient('olm_bearer_token');
if ($token === false) {
return get_olm_token();
}
return $token;
}