diff --git a/assets/css/app.css b/assets/css/app.css index cbee93a..2ed3cba 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -12,6 +12,10 @@ body { overflow-y: auto; } +.content-area { + min-width: 0; +} + .sidebar .nav-link { padding: 0.6rem 1rem; border-radius: 0.375rem; @@ -54,7 +58,54 @@ body { line-height: 1.7; } +.article-content, +.article-content * { + overflow-wrap: anywhere; +} + .btn-group .btn + form .btn { border-top-left-radius: 0; border-bottom-left-radius: 0; } + +.bp-toast-container { + z-index: 2000; + max-width: 420px; +} + +.bp-toast { + border-radius: 12px; +} + +.bp-toast .toast-body { + font-size: 0.95rem; +} + +.bp-toast.text-bg-warning .btn-close { + filter: none; +} + +.bp-toast-fallback { + margin-bottom: 0.5rem; + padding: 0.75rem 0.95rem; + border-radius: 10px; + background: #1f2a44; + color: #fff; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.25); +} + +.bp-confirm-modal .modal-content { + border: 0; + border-radius: 14px; + box-shadow: 0 1rem 2rem rgba(19, 31, 56, 0.2); +} + +.bp-confirm-modal .modal-title { + font-size: 1.1rem; + font-weight: 700; +} + +.bp-confirm-modal .modal-body p { + color: #445066; + line-height: 1.5; +} diff --git a/assets/js/app.js b/assets/js/app.js index 33f5a06..ac8caa3 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,76 +1,294 @@ // BackPRO - Frontend Scripts +(function () { + 'use strict'; -document.addEventListener('DOMContentLoaded', function () { + var confirmQueue = Promise.resolve(); + var confirmUi = null; - // Test connection buttons - document.querySelectorAll('.btn-test-connection').forEach(function (btn) { - btn.addEventListener('click', function () { - var siteId = this.dataset.siteId; - var button = this; - var originalHtml = button.innerHTML; + function getToastClass(type) { + if (type === 'success') return 'text-bg-success'; + if (type === 'danger' || type === 'error') return 'text-bg-danger'; + if (type === 'warning') return 'text-bg-warning'; + return 'text-bg-primary'; + } - button.innerHTML = ''; - button.disabled = true; + function getToastTitle(type) { + if (type === 'success') return 'Sukces'; + if (type === 'danger' || type === 'error') return 'Blad'; + if (type === 'warning') return 'Uwaga'; + return 'Informacja'; + } - fetch('/sites/' + siteId + '/test', { method: 'POST' }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (data.success) { - button.innerHTML = ''; - button.classList.remove('btn-outline-success'); - button.classList.add('btn-success'); - } else { - button.innerHTML = ''; - button.classList.remove('btn-outline-success'); - button.classList.add('btn-danger'); - alert('Błąd połączenia: ' + data.message); - } - }) - .catch(function () { - button.innerHTML = ''; - button.classList.add('btn-danger'); - alert('Błąd sieci'); - }) - .finally(function () { - button.disabled = false; - setTimeout(function () { - button.innerHTML = originalHtml; - button.className = button.className.replace('btn-success', 'btn-outline-success').replace('btn-danger', 'btn-outline-success'); - }, 3000); - }); + function ensureToastContainer() { + var container = document.getElementById('bpToastContainer'); + if (container) return container; + + container = document.createElement('div'); + container.id = 'bpToastContainer'; + container.className = 'toast-container position-fixed top-0 end-0 p-3 bp-toast-container'; + document.body.appendChild(container); + return container; + } + + function showToast(message, type, options) { + if (!message) return; + + if (!window.bootstrap || !window.bootstrap.Toast) { + var fallbackContainer = ensureToastContainer(); + var fallbackToast = document.createElement('div'); + fallbackToast.className = 'bp-toast-fallback'; + fallbackToast.textContent = message; + fallbackContainer.appendChild(fallbackToast); + setTimeout(function () { + fallbackToast.remove(); + }, 4000); + return; + } + + var opts = options || {}; + var container = ensureToastContainer(); + var toast = document.createElement('div'); + var toastClass = getToastClass(type || 'info'); + var title = opts.title || getToastTitle(type || 'info'); + var delay = typeof opts.delay === 'number' ? opts.delay : 5000; + + toast.className = 'toast border-0 shadow-sm bp-toast ' + toastClass; + toast.setAttribute('role', 'alert'); + toast.setAttribute('aria-live', 'assertive'); + toast.setAttribute('aria-atomic', 'true'); + toast.innerHTML = + '
' + + '
' + + '' + title + ': ' + message + + '
' + + '' + + '
'; + + container.appendChild(toast); + var bsToast = new bootstrap.Toast(toast, { delay: delay }); + toast.addEventListener('hidden.bs.toast', function () { + toast.remove(); }); - }); + bsToast.show(); + } - // Topic edit buttons - document.querySelectorAll('.btn-edit-topic').forEach(function (btn) { - btn.addEventListener('click', function () { - var id = this.dataset.id; - var form = document.getElementById('topicForm'); - var title = document.getElementById('topicFormTitle'); - var submit = document.getElementById('topicFormSubmit'); + function ensureConfirmUi() { + if (confirmUi) return confirmUi; - form.action = '/topics/' + id + '/update'; - title.textContent = 'Edytuj temat'; - submit.textContent = 'Zapisz zmiany'; + var existing = document.getElementById('bpConfirmModal'); + if (!existing) { + var modal = document.createElement('div'); + modal.className = 'modal fade bp-confirm-modal'; + modal.id = 'bpConfirmModal'; + modal.tabIndex = -1; + modal.setAttribute('aria-hidden', 'true'); + modal.innerHTML = + ''; + document.body.appendChild(modal); + existing = modal; + } - document.getElementById('topic_name').value = this.dataset.name; - document.getElementById('topic_description').value = this.dataset.description; - document.getElementById('topic_wp_category').value = this.dataset.wpCategory || ''; - document.getElementById('topic_is_active').checked = this.dataset.active === '1'; + confirmUi = { + el: existing, + modal: window.bootstrap && window.bootstrap.Modal + ? new bootstrap.Modal(existing, { backdrop: 'static', keyboard: false }) + : null, + titleEl: existing.querySelector('#bpConfirmTitle'), + messageEl: existing.querySelector('#bpConfirmMessage'), + cancelBtn: existing.querySelector('[data-role="cancel"]'), + confirmBtn: existing.querySelector('[data-role="confirm"]') + }; + return confirmUi; + } - var globalSelect = document.getElementById('topic_global_id'); - if (globalSelect) { - globalSelect.value = this.dataset.globalTopic || ''; + function showConfirmDialog(message, options) { + var opts = options || {}; + + if (!window.bootstrap || !window.bootstrap.Modal) { + showToast('Brak komponentu potwierdzenia. Operacja zostala wstrzymana.', 'warning'); + return Promise.resolve(false); + } + + var ui = ensureConfirmUi(); + ui.titleEl.textContent = opts.title || 'Potwierdzenie'; + ui.messageEl.textContent = message || 'Czy na pewno?'; + ui.cancelBtn.textContent = opts.cancelText || 'Anuluj'; + ui.confirmBtn.textContent = opts.confirmText || 'Potwierdz'; + ui.confirmBtn.className = 'btn ' + (opts.confirmClass || 'btn-danger'); + + return new Promise(function (resolve) { + var confirmed = false; + + function cleanup() { + ui.confirmBtn.removeEventListener('click', onConfirm); + ui.cancelBtn.removeEventListener('click', onCancel); + ui.el.removeEventListener('hidden.bs.modal', onHidden); + } + + function onConfirm() { + confirmed = true; + ui.modal.hide(); + } + + function onCancel() { + ui.modal.hide(); + } + + function onHidden() { + cleanup(); + resolve(confirmed); + } + + ui.confirmBtn.addEventListener('click', onConfirm); + ui.cancelBtn.addEventListener('click', onCancel); + ui.el.addEventListener('hidden.bs.modal', onHidden); + ui.modal.show(); + }); + } + + function queueConfirm(message, options) { + var run = function () { + return showConfirmDialog(message, options); + }; + var pending = confirmQueue.then(run, run); + confirmQueue = pending.catch(function () { return false; }); + return pending; + } + + function installConfirmForForms() { + document.addEventListener('submit', function (event) { + var form = event.target; + if (!(form instanceof HTMLFormElement)) return; + if (!form.matches('form[data-confirm]')) return; + + if (form.dataset.confirmBypass === '1') { + form.dataset.confirmBypass = ''; + return; + } + + event.preventDefault(); + queueConfirm(form.getAttribute('data-confirm'), { + title: form.dataset.confirmTitle || 'Potwierdzenie', + confirmText: form.dataset.confirmOk || 'Potwierdz', + cancelText: form.dataset.confirmCancel || 'Anuluj', + confirmClass: form.dataset.confirmClass || 'btn-danger' + }).then(function (ok) { + if (!ok) return; + form.dataset.confirmBypass = '1'; + form.submit(); + }); + }, true); + } + + function initTestConnectionButtons() { + document.querySelectorAll('.btn-test-connection').forEach(function (btn) { + btn.addEventListener('click', function () { + var siteId = this.dataset.siteId; + var button = this; + var originalHtml = button.innerHTML; + + button.innerHTML = ''; + button.disabled = true; + + fetch('/sites/' + siteId + '/test', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.success) { + button.innerHTML = ''; + button.classList.remove('btn-outline-success'); + button.classList.add('btn-success'); + showToast(data.message || 'Polaczenie poprawne.', 'success', { delay: 3500 }); + } else { + button.innerHTML = ''; + button.classList.remove('btn-outline-success'); + button.classList.add('btn-danger'); + showToast('Blad polaczenia: ' + (data.message || 'Nieznany blad'), 'danger'); + } + }) + .catch(function () { + button.innerHTML = ''; + button.classList.add('btn-danger'); + showToast('Blad sieci podczas testu polaczenia.', 'danger'); + }) + .finally(function () { + button.disabled = false; + setTimeout(function () { + button.innerHTML = originalHtml; + button.className = button.className.replace('btn-success', 'btn-outline-success').replace('btn-danger', 'btn-outline-success'); + }, 3000); + }); + }); + }); + } + + function initTopicEditButtons() { + document.querySelectorAll('.btn-edit-topic').forEach(function (btn) { + btn.addEventListener('click', function () { + var id = this.dataset.id; + var form = document.getElementById('topicForm'); + var title = document.getElementById('topicFormTitle'); + var submit = document.getElementById('topicFormSubmit'); + + if (!form || !title || !submit) return; + + form.action = '/topics/' + id + '/update'; + title.textContent = 'Edytuj temat'; + submit.textContent = 'Zapisz zmiany'; + + document.getElementById('topic_name').value = this.dataset.name; + document.getElementById('topic_description').value = this.dataset.description; + document.getElementById('topic_wp_category').value = this.dataset.wpCategory || ''; + document.getElementById('topic_is_active').checked = this.dataset.active === '1'; + + var globalSelect = document.getElementById('topic_global_id'); + if (globalSelect) { + globalSelect.value = this.dataset.globalTopic || ''; + } + }); + }); + } + + function highlightActiveSidebarLink() { + var currentPath = window.location.pathname; + document.querySelectorAll('.sidebar .nav-link').forEach(function (link) { + var href = link.getAttribute('href'); + if (currentPath === href || (href !== '/' && currentPath.startsWith(href))) { + link.classList.add('active'); } }); - }); + } - // Highlight active sidebar link - var currentPath = window.location.pathname; - document.querySelectorAll('.sidebar .nav-link').forEach(function (link) { - var href = link.getAttribute('href'); - if (currentPath === href || (href !== '/' && currentPath.startsWith(href))) { - link.classList.add('active'); - } + window.BackProUI = { + toast: showToast, + confirm: queueConfirm + }; + + window.backproNotify = function (message, type, options) { + showToast(message, type || 'info', options); + }; + + window.backproConfirm = function (message, options) { + return queueConfirm(message, options); + }; + + document.addEventListener('DOMContentLoaded', function () { + installConfirmForForms(); + initTestConnectionButtons(); + initTopicEditButtons(); + highlightActiveSidebarLink(); }); -}); +})(); diff --git a/assets/wp-theme-backpro-news/archive.php b/assets/wp-theme-backpro-news/archive.php new file mode 100644 index 0000000..36af037 --- /dev/null +++ b/assets/wp-theme-backpro-news/archive.php @@ -0,0 +1,28 @@ + + +
+

+
+
+ + + +
+

+
+

+
+ + +

Brak wpisow.

+ + +
+ 'plain'])); ?> +
+ + + diff --git a/assets/wp-theme-backpro-news/footer.php b/assets/wp-theme-backpro-news/footer.php new file mode 100644 index 0000000..d2a417d --- /dev/null +++ b/assets/wp-theme-backpro-news/footer.php @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/assets/wp-theme-backpro-news/front-page.php b/assets/wp-theme-backpro-news/front-page.php new file mode 100644 index 0000000..d3428cc --- /dev/null +++ b/assets/wp-theme-backpro-news/front-page.php @@ -0,0 +1,94 @@ + + + +
+ + 'post', + 'posts_per_page' => 5, + 'cat' => (int) $cat->term_id, + 'ignore_sticky_posts' => true, + ]); + if (!$query->have_posts()) { + wp_reset_postdata(); + continue; + } + $query->the_post(); + $lead_id = get_the_ID(); + $leadCats = get_the_category(); + $leadLabel = !empty($leadCats) ? $leadCats[0]->name : $cat->name; + ?> +
+
+

name); ?>

+ Wiecej +
+
+ + +
+
+ + +
+ +
+
+

Najnowsze wpisy

+
+
+ +
+
+ + + diff --git a/assets/wp-theme-backpro-news/functions.php b/assets/wp-theme-backpro-news/functions.php new file mode 100644 index 0000000..4858a41 --- /dev/null +++ b/assets/wp-theme-backpro-news/functions.php @@ -0,0 +1,36 @@ +get('Version') + ); +} +add_action('wp_enqueue_scripts', 'backpro_news_enqueue_assets'); + +function backpro_news_parent_categories(): array +{ + return get_categories([ + 'taxonomy' => 'category', + 'hide_empty' => true, + 'parent' => 0, + 'orderby' => 'name', + 'order' => 'ASC', + ]); +} + diff --git a/assets/wp-theme-backpro-news/header.php b/assets/wp-theme-backpro-news/header.php new file mode 100644 index 0000000..8131dfe --- /dev/null +++ b/assets/wp-theme-backpro-news/header.php @@ -0,0 +1,29 @@ + + +> + + + + + +> + + +
+
+ + + + +
+
+ +
+
+ diff --git a/assets/wp-theme-backpro-news/index.php b/assets/wp-theme-backpro-news/index.php new file mode 100644 index 0000000..bd8c1fa --- /dev/null +++ b/assets/wp-theme-backpro-news/index.php @@ -0,0 +1,34 @@ + + +
+

+
+
+ + + +
+

+
+ | +
+

+
+ + +
+ 'plain', + ])); ?> +
+ +
+

Brak wpisow.

+
+ + + + diff --git a/assets/wp-theme-backpro-news/search.php b/assets/wp-theme-backpro-news/search.php new file mode 100644 index 0000000..ac3865c --- /dev/null +++ b/assets/wp-theme-backpro-news/search.php @@ -0,0 +1,23 @@ + + +
+

Wyniki wyszukiwania

+
+
+ + + +
+

+

+
+ + +

Brak wynikow.

+ + + + diff --git a/assets/wp-theme-backpro-news/single.php b/assets/wp-theme-backpro-news/single.php new file mode 100644 index 0000000..c4f0e2e --- /dev/null +++ b/assets/wp-theme-backpro-news/single.php @@ -0,0 +1,128 @@ + + + + 'post', + 'posts_per_page' => 4, + 'post__not_in' => [$post_id], + 'ignore_sticky_posts' => true, + ]; + if ($primary_category instanceof WP_Term) { + $related_args['cat'] = (int) $primary_category->term_id; + } + $related_query = new WP_Query($related_args); + + $latest_query = new WP_Query([ + 'post_type' => 'post', + 'posts_per_page' => 5, + 'post__not_in' => [$post_id], + 'ignore_sticky_posts' => true, + ]); + ?> +
+
+
+ +
+ + name); ?> + +
+ + +

+ +
+ + + +
+ + +

+ +
+ + +
+ 'bp-single-hero-image']); ?> + + name); ?> + +
+ + +
+ +
+
+ + +
+ + + diff --git a/assets/wp-theme-backpro-news/style.css b/assets/wp-theme-backpro-news/style.css new file mode 100644 index 0000000..a942ae9 --- /dev/null +++ b/assets/wp-theme-backpro-news/style.css @@ -0,0 +1,604 @@ +/* +Theme Name: BackPRO News +Theme URI: https://backpro.projectpro.pl/ +Author: BackPRO +Author URI: https://backpro.projectpro.pl/ +Description: Lightweight magazine-style blog theme for BackPRO with auto category sections on homepage. +Version: 1.0.0 +Requires at least: 6.0 +Tested up to: 6.6 +Requires PHP: 7.4 +Text Domain: backpro-news +*/ + +:root { + --bp-bg: #f5f7fb; + --bp-surface: #ffffff; + --bp-text: #152033; + --bp-muted: #63708a; + --bp-accent: #0c6cf2; + --bp-border: #dce2ec; + --bp-radius: 14px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + color: var(--bp-text); + background: linear-gradient(180deg, #f7f8fc 0%, var(--bp-bg) 100%); +} + +a { + color: inherit; + text-decoration: none; +} + +.bp-shell { + max-width: 1220px; + margin: 0 auto; + padding: 0 20px; +} + +.bp-header { + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + position: sticky; + top: 0; + z-index: 20; +} + +.bp-header-inner { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 78px; + gap: 16px; +} + +.bp-brand { + font-size: 28px; + font-weight: 800; + letter-spacing: 0.3px; + color: var(--bp-accent); +} + +.bp-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.bp-nav a { + padding: 8px 12px; + border-radius: 999px; + color: var(--bp-muted); + font-weight: 600; + font-size: 14px; +} + +.bp-nav a:hover { + background: #ecf3ff; + color: var(--bp-accent); +} + +.bp-main { + padding: 26px 0 46px; +} + +.bp-grid { + display: grid; + gap: 20px; + grid-template-columns: 1fr; +} + +.bp-section { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); + overflow: hidden; +} + +.bp-section-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--bp-border); +} + +.bp-section-head h2 { + margin: 0; + font-size: 20px; + line-height: 1.2; +} + +.bp-section-head a { + font-size: 13px; + color: var(--bp-accent); + font-weight: 600; +} + +.bp-section-body { + padding: 16px; + display: grid; + gap: 14px; + grid-template-columns: 1.2fr 1fr; +} + +.bp-lead-title { + margin: 0 0 8px; + font-size: 24px; + line-height: 1.25; +} + +.bp-lead-media { + display: block; + margin-bottom: 12px; + position: relative; + overflow: hidden; + border-radius: 10px; +} + +.bp-lead-image { + width: 100%; + height: auto; + display: block; + border-radius: 10px; + border: 1px solid var(--bp-border); + aspect-ratio: 16 / 9; + object-fit: cover; + background: #e9eef7; +} + +.bp-lead-excerpt { + margin: 0; + color: var(--bp-muted); + font-size: 15px; + line-height: 1.5; +} + +.bp-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 12px; +} + +.bp-list a { + display: block; + padding: 10px 0; + border-bottom: 1px dashed var(--bp-border); + font-weight: 600; +} + +.bp-list li:last-child a { + border-bottom: 0; +} + +.bp-mini-grid { + margin: 0; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + align-content: start; + align-items: start; + grid-auto-rows: max-content; +} + +.bp-mini-card { + min-width: 0; + align-self: start; +} + +.bp-mini-media { + display: block; + position: relative; + aspect-ratio: 1 / 1; + overflow: hidden; + border: 1px solid var(--bp-border); + background: #e9eef7; +} + +.bp-mini-image { + width: 100%; + height: 100%; + display: block; + border-radius: 0; + border: 0; + object-fit: cover; + aspect-ratio: auto; +} + +.bp-mini-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #e6ebf5 0%, #d9e2f1 100%); +} + +.bp-mini-label { + position: absolute; + left: 8px; + bottom: 8px; + background: #1c2330; + color: #fff; + font-size: 12px; + line-height: 1; + font-weight: 700; + padding: 4px 7px; +} + +.bp-mini-title { + display: block; + margin-top: 8px; + font-size: 14px; + line-height: 1.35; + font-weight: 500; +} + +.bp-page-head { + margin-bottom: 20px; +} + +.bp-page-head h1 { + margin: 0 0 6px; + font-size: 34px; +} + +.bp-meta { + color: var(--bp-muted); + font-size: 14px; +} + +.bp-article-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); + padding: 22px; + margin-bottom: 14px; +} + +.bp-article-card h2 { + margin: 0 0 8px; + font-size: 28px; + line-height: 1.2; +} + +.bp-excerpt { + margin: 10px 0 0; + color: var(--bp-muted); +} + +.bp-single-layout { + display: grid; + grid-template-columns: minmax(0, 1.75fr) minmax(280px, 0.85fr); + gap: 18px; + align-items: start; +} + +.bp-single-article { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); + overflow: hidden; +} + +.bp-single-head { + padding: 22px 22px 14px; + border-bottom: 1px solid var(--bp-border); +} + +.bp-single-cats { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.bp-single-cats a { + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--bp-accent); +} + +.bp-single-title { + margin: 0; + font-size: 42px; + line-height: 1.1; +} + +.bp-single-meta-row { + margin-top: 14px; + display: flex; + flex-wrap: wrap; + gap: 14px; + color: var(--bp-muted); + font-size: 13px; + font-weight: 600; +} + +.bp-single-meta-row span { + position: relative; +} + +.bp-single-meta-row span:not(:last-child)::after { + content: ""; + display: inline-block; + width: 4px; + height: 4px; + border-radius: 50%; + margin-left: 14px; + vertical-align: middle; + background: #9da8bc; +} + +.bp-single-lead { + margin: 16px 0 0; + color: #33445f; + font-size: 19px; + line-height: 1.55; +} + +.bp-single-hero { + margin: 0; + position: relative; + border-bottom: 1px solid var(--bp-border); +} + +.bp-single-hero-image { + width: 100%; + height: auto; + display: block; + aspect-ratio: 16 / 9; + object-fit: cover; + background: #e9eef7; +} + +.bp-single-content { + padding: 24px 22px 26px; + font-size: 19px; + line-height: 1.75; +} + +.bp-single-content > *:first-child { + margin-top: 0; +} + +.bp-single-content > *:last-child { + margin-bottom: 0; +} + +.bp-single-content h2, +.bp-single-content h3, +.bp-single-content h4 { + margin: 32px 0 12px; + line-height: 1.25; +} + +.bp-single-content h2 { + font-size: 31px; +} + +.bp-single-content h3 { + font-size: 25px; +} + +.bp-single-content p { + margin: 0 0 18px; +} + +.bp-single-content ul, +.bp-single-content ol { + margin: 0 0 20px; + padding-left: 24px; +} + +.bp-single-content li { + margin-bottom: 8px; +} + +.bp-single-content blockquote { + margin: 28px 0; + padding: 18px 20px; + border-left: 4px solid var(--bp-accent); + background: #f0f5ff; + color: #1e2b42; + font-size: 21px; + line-height: 1.5; +} + +.bp-single-content img { + max-width: 100%; + height: auto; + border-radius: 10px; +} + +.bp-single-sidebar { + display: grid; + gap: 14px; + align-content: start; +} + +.bp-side-box { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); + padding: 14px; +} + +.bp-side-box h3 { + margin: 0 0 12px; + font-size: 18px; + line-height: 1.25; +} + +.bp-side-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 11px; +} + +.bp-side-item { + display: grid; + grid-template-columns: 88px 1fr; + gap: 10px; + align-items: start; +} + +.bp-side-thumb { + display: block; + border: 1px solid var(--bp-border); + overflow: hidden; + aspect-ratio: 1 / 1; + background: #e9eef7; +} + +.bp-side-thumb-image { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.bp-side-copy { + min-width: 0; +} + +.bp-side-title { + display: block; + font-size: 14px; + line-height: 1.35; + font-weight: 600; +} + +.bp-side-date { + margin-top: 4px; + font-size: 12px; + color: var(--bp-muted); +} + +.bp-side-plain { + margin: 0; + padding: 0; + list-style: none; +} + +.bp-side-plain li { + padding: 10px 0; + border-top: 1px dashed var(--bp-border); +} + +.bp-side-plain li:first-child { + border-top: 0; + padding-top: 0; +} + +.bp-side-plain a { + display: block; + font-size: 14px; + line-height: 1.4; + font-weight: 600; +} + +.bp-side-plain span { + display: block; + margin-top: 4px; + color: var(--bp-muted); + font-size: 12px; +} + +.bp-side-empty { + margin: 0; + color: var(--bp-muted); + font-size: 14px; +} + +.bp-pagination { + margin-top: 20px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.bp-pagination .page-numbers { + display: inline-block; + padding: 8px 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface); + color: var(--bp-muted); +} + +.bp-pagination .current { + color: #fff; + background: var(--bp-accent); + border-color: var(--bp-accent); +} + +.bp-footer { + border-top: 1px solid var(--bp-border); + padding: 28px 0; + color: var(--bp-muted); + font-size: 14px; +} + +@media (max-width: 900px) { + .bp-section-body { + grid-template-columns: 1fr; + } + + .bp-single-layout { + grid-template-columns: 1fr; + } + + .bp-mini-grid { + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .bp-page-head h1 { + font-size: 30px; + } + + .bp-single-title { + font-size: 33px; + } + + .bp-single-content { + font-size: 18px; + } +} + +@media (max-width: 520px) { + .bp-mini-grid { + grid-template-columns: 1fr; + } + + .bp-single-head { + padding: 18px 16px 12px; + } + + .bp-single-title { + font-size: 28px; + } + + .bp-single-content { + padding: 18px 16px 20px; + font-size: 17px; + } + + .bp-single-content blockquote { + font-size: 19px; + } +} diff --git a/config/routes.php b/config/routes.php index 02abbda..ebdf33c 100644 --- a/config/routes.php +++ b/config/routes.php @@ -27,6 +27,11 @@ $router->get('/sites/{id}/edit', 'SiteController', 'edit'); $router->post('/sites/{id}', 'SiteController', 'update'); $router->post('/sites/{id}/delete', 'SiteController', 'destroy'); $router->post('/sites/{id}/test', 'SiteController', 'testConnection'); +$router->get('/sites/{id}/dashboard', 'SiteController', 'dashboard'); +$router->post('/sites/{id}/dashboard/permalinks/enable', 'SiteController', 'enablePrettyPermalinks'); +$router->post('/sites/{id}/dashboard/remote-service/update', 'SiteController', 'updateRemoteService'); +$router->post('/sites/{id}/dashboard/theme/install', 'SiteController', 'installBackproNewsTheme'); +$router->post('/sites/{id}/dashboard/reinstall', 'SiteController', 'reinstallWordPress'); // Topics $router->get('/sites/{id}/topics', 'TopicController', 'index'); @@ -42,7 +47,9 @@ $router->post('/sites/{id}/categories/from-topics', 'CategoryController', 'creat // Articles $router->get('/articles', 'ArticleController', 'index'); +$router->post('/articles/import', 'ArticleController', 'importFromWordPress'); $router->get('/articles/{id}', 'ArticleController', 'show'); +$router->post('/articles/{id}/delete', 'ArticleController', 'destroy'); $router->post('/articles/{id}/replace-image', 'ArticleController', 'replaceImage'); // Publish (manual trigger) diff --git a/migrations/006_article_retry_tracking.sql b/migrations/006_article_retry_tracking.sql new file mode 100644 index 0000000..8e89716 --- /dev/null +++ b/migrations/006_article_retry_tracking.sql @@ -0,0 +1,5 @@ +-- Track retry attempts for unpublished/generated articles +ALTER TABLE articles + ADD COLUMN retry_count INT NOT NULL DEFAULT 0 AFTER error_message, + ADD COLUMN last_retry_at DATETIME NULL AFTER retry_count; + diff --git a/migrations/007_remote_service_fields.sql b/migrations/007_remote_service_fields.sql new file mode 100644 index 0000000..5f70be4 --- /dev/null +++ b/migrations/007_remote_service_fields.sql @@ -0,0 +1,6 @@ +-- BackPRO remote service metadata for WordPress management script +ALTER TABLE sites + ADD COLUMN remote_service_file VARCHAR(255) NULL AFTER wp_admin_email, + ADD COLUMN remote_service_token VARCHAR(255) NULL AFTER remote_service_file, + ADD COLUMN remote_service_installed_at DATETIME NULL AFTER remote_service_token; + diff --git a/src/Controllers/ArticleController.php b/src/Controllers/ArticleController.php index 45b643d..8832fc6 100644 --- a/src/Controllers/ArticleController.php +++ b/src/Controllers/ArticleController.php @@ -5,6 +5,8 @@ namespace App\Controllers; use App\Core\Auth; use App\Core\Controller; use App\Models\Article; +use App\Models\Site; +use App\Models\Topic; use App\Services\WordPressService; use App\Helpers\Logger; @@ -15,15 +17,27 @@ class ArticleController extends Controller Auth::requireLogin(); $page = max(1, (int) ($this->input('page', 1))); + $selectedSiteId = max(0, (int) $this->input('site_id', 0)); $perPage = 20; $offset = ($page - 1) * $perPage; - $articles = Article::findAllWithRelations($perPage, $offset); - $total = Article::count(); + $articles = Article::findAllWithRelations( + $perPage, + $offset, + $selectedSiteId > 0 ? $selectedSiteId : null + ); + + $total = $selectedSiteId > 0 + ? Article::count('site_id = :site_id', ['site_id' => $selectedSiteId]) + : Article::count(); + $totalPages = (int) ceil($total / $perPage); + $sites = Site::findAll('name ASC'); $this->view('articles/index', [ 'articles' => $articles, + 'sites' => $sites, + 'selectedSiteId' => $selectedSiteId, 'page' => $page, 'totalPages' => $totalPages, 'total' => $total, @@ -44,6 +58,215 @@ class ArticleController extends Controller $this->view('articles/show', ['article' => $article]); } + public function importFromWordPress(): void + { + Auth::requireLogin(); + + $siteId = max(0, (int) $this->input('site_id', 0)); + if ($siteId <= 0) { + $this->flash('danger', 'Wybierz strone do importu artykulow.'); + $this->redirect('/articles'); + return; + } + + $site = Site::find($siteId); + if (!$site) { + $this->flash('danger', 'Nie znaleziono wybranej strony.'); + $this->redirect('/articles'); + return; + } + + $topics = Topic::findBySite($siteId); + $defaultTopicId = $this->resolveImportFallbackTopicId($siteId, $topics); + if ($defaultTopicId <= 0) { + $this->flash('danger', 'Nie udalo sie przygotowac tematu dla importu artykulow.'); + $this->redirect('/articles?site_id=' . $siteId); + return; + } + + $topicByCategory = []; + foreach ($topics as $topic) { + if (!empty($topic['wp_category_id'])) { + $topicByCategory[(int) $topic['wp_category_id']] = (int) $topic['id']; + } + } + + $wp = new WordPressService(); + $posts = $wp->getPublishedPosts($site); + if ($posts === false) { + $this->flash('danger', 'Nie udalo sie pobrac artykulow z WordPress.'); + $this->redirect('/articles?site_id=' . $siteId); + return; + } + $uncategorizedIds = $this->resolveUncategorizedCategoryIds($site, $wp); + + $imported = 0; + $skipped = 0; + $failed = 0; + + foreach ($posts as $post) { + $wpPostId = (int) ($post['id'] ?? 0); + if ($wpPostId <= 0) { + $skipped++; + continue; + } + + if (Article::existsBySiteAndWpPostId($siteId, $wpPostId)) { + $skipped++; + continue; + } + + $topicId = $defaultTopicId; + $categories = $post['categories'] ?? []; + $categoryIds = is_array($categories) ? array_map('intval', $categories) : []; + $categoryIds = array_values(array_filter($categoryIds, function (int $categoryId) use ($uncategorizedIds): bool { + return $categoryId > 0 && !in_array($categoryId, $uncategorizedIds, true); + })); + + foreach ($categoryIds as $categoryId) { + if (isset($topicByCategory[$categoryId])) { + $topicId = $topicByCategory[$categoryId]; + break; + } + } + + $rawTitle = (string) ($post['title']['rendered'] ?? ''); + $title = trim(html_entity_decode(strip_tags($rawTitle), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ($title === '') { + $title = 'WP Post #' . $wpPostId; + } + + $content = (string) ($post['content']['rendered'] ?? ''); + $postDate = (string) ($post['date'] ?? ''); + $publishedAt = $postDate !== '' ? date('Y-m-d H:i:s', strtotime($postDate)) : date('Y-m-d H:i:s'); + if ($publishedAt === '1970-01-01 00:00:00') { + $publishedAt = date('Y-m-d H:i:s'); + } + + try { + Article::create([ + 'site_id' => $siteId, + 'topic_id' => $topicId, + 'title' => $title, + 'content' => $content, + 'wp_post_id' => $wpPostId, + 'status' => 'published', + 'published_at' => $publishedAt, + ]); + + Topic::incrementArticleCount($topicId); + $imported++; + } catch (\Throwable $e) { + $failed++; + Logger::error( + "Import WP post failed. site_id={$siteId}, wp_post_id={$wpPostId}, error=" . $e->getMessage(), + 'import' + ); + } + } + + if ($imported > 0) { + $this->flash('success', "Zaimportowano {$imported} artykulow. Pominieto: {$skipped}. Bledy: {$failed}."); + } else { + $this->flash('info', "Brak nowych artykulow do importu. Pominieto: {$skipped}. Bledy: {$failed}."); + } + + $this->redirect('/articles?site_id=' . $siteId); + } + + private function resolveImportFallbackTopicId(int $siteId, array &$topics): int + { + if (!empty($topics)) { + return (int) $topics[0]['id']; + } + + $fallbackTopicId = Topic::create([ + 'site_id' => $siteId, + 'name' => 'Import z WordPress', + 'description' => 'Temat techniczny dla wpisow importowanych bez mapowania kategorii.', + 'is_active' => 1, + ]); + + if ($fallbackTopicId > 0) { + $topics[] = [ + 'id' => $fallbackTopicId, + 'wp_category_id' => null, + ]; + } + + return (int) $fallbackTopicId; + } + + private function resolveUncategorizedCategoryIds(array $site, WordPressService $wp): array + { + $categories = $wp->getCategories($site); + if (!is_array($categories)) { + return []; + } + + $ids = []; + foreach ($categories as $category) { + $id = (int) ($category['id'] ?? 0); + if ($id <= 0) { + continue; + } + + $slug = strtolower(trim((string) ($category['slug'] ?? ''))); + $name = strtolower(trim((string) ($category['name'] ?? ''))); + + if (in_array($slug, ['uncategorized', 'bez-kategorii'], true) + || in_array($name, ['uncategorized', 'bez kategorii'], true) + ) { + $ids[] = $id; + } + } + + return array_values(array_unique($ids)); + } + + public function destroy(string $id): void + { + Auth::requireLogin(); + + $article = Article::findWithRelations((int) $id); + if (!$article) { + $this->flash('danger', 'Artykul nie znaleziony.'); + $this->redirect('/articles'); + return; + } + + $redirectUrl = '/articles?site_id=' . (int) $article['site_id']; + + if (!empty($article['wp_post_id'])) { + $site = [ + 'url' => $article['site_url'], + 'api_user' => $article['site_api_user'], + 'api_token' => $article['site_api_token'], + ]; + + $wp = new WordPressService(); + if (!$wp->deletePost($site, (int) $article['wp_post_id'])) { + $this->flash('danger', 'Nie udalo sie usunac artykulu z WordPress. Lokalny wpis nie zostal usuniety.'); + $this->redirect($redirectUrl); + return; + } + } + + $deleted = Article::delete((int) $id); + + if ($deleted && ($article['status'] ?? '') === 'published' && !empty($article['topic_id'])) { + Topic::decrementArticleCount((int) $article['topic_id']); + } + + if ($deleted) { + $this->flash('success', 'Artykul zostal usuniety.'); + } else { + $this->flash('danger', 'Nie udalo sie usunac artykulu z systemu.'); + } + + $this->redirect($redirectUrl); + } + public function replaceImage(string $id): void { Auth::requireLogin(); diff --git a/src/Controllers/SiteController.php b/src/Controllers/SiteController.php index c09cb71..4e98e6a 100644 --- a/src/Controllers/SiteController.php +++ b/src/Controllers/SiteController.php @@ -5,9 +5,11 @@ namespace App\Controllers; use App\Core\Auth; use App\Core\Controller; use App\Helpers\Validator; +use App\Models\Article; use App\Models\Site; use App\Models\Topic; use App\Models\GlobalTopic; +use App\Services\InstallerService; use App\Services\WordPressService; class SiteController extends Controller @@ -21,6 +23,13 @@ class SiteController extends Controller // Attach topic count for each site foreach ($sites as &$site) { $site['topic_count'] = Topic::count('site_id = :sid', ['sid' => $site['id']]); + $site['published_article_count'] = Article::count( + 'site_id = :sid AND status = :status', + [ + 'sid' => $site['id'], + 'status' => 'published', + ] + ); } $this->view('sites/index', ['sites' => $sites]); @@ -175,4 +184,130 @@ class SiteController extends Controller $this->json($result); } + + public function dashboard(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->flash('danger', 'Strona nie znaleziona.'); + $this->redirect('/sites'); + return; + } + + $wp = new WordPressService(); + $permalinkStatus = $wp->getPermalinkSettings($site); + $remoteServiceStatus = $wp->getRemoteServiceStatus($site); + + $this->view('sites/dashboard', [ + 'site' => $site, + 'permalinkStatus' => $permalinkStatus, + 'remoteServiceStatus' => $remoteServiceStatus, + ]); + } + + public function enablePrettyPermalinks(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->flash('danger', 'Strona nie znaleziona.'); + $this->redirect('/sites'); + return; + } + + $wp = new WordPressService(); + $result = $wp->enablePrettyPermalinks($site); + + if (!empty($result['success'])) { + $this->flash('success', (string) ($result['message'] ?? 'Zaktualizowano strukture linkow permanentnych.')); + } else { + $this->flash('danger', (string) ($result['message'] ?? 'Nie udalo sie zaktualizowac linkow permanentnych.')); + } + + $this->redirect("/sites/{$id}/dashboard"); + } + + public function updateRemoteService(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->flash('danger', 'Strona nie znaleziona.'); + $this->redirect('/sites'); + return; + } + + $wp = new WordPressService(); + $result = $wp->installBackproRemoteService($site); + $status = $wp->getRemoteServiceStatus($site); + + if (!empty($result['success'])) { + $this->flash( + 'success', + 'Zaktualizowano plik serwisowy BackPRO. Lokalna: ' + . ($status['local_version'] ?? '-') + . ', na serwerze: ' + . ($status['remote_version'] ?? '-') + ); + } else { + $this->flash( + 'danger', + (string) ($result['message'] ?? 'Nie udalo sie zaktualizowac pliku serwisowego.') + ); + } + + $this->redirect("/sites/{$id}/dashboard"); + } + + public function installBackproNewsTheme(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->flash('danger', 'Strona nie znaleziona.'); + $this->redirect('/sites'); + return; + } + + $wp = new WordPressService(); + $result = $wp->installBackproNewsTheme($site); + + if (!empty($result['success'])) { + $this->flash('success', (string) ($result['message'] ?? 'Zainstalowano motyw BackPRO News.')); + } else { + $this->flash('danger', (string) ($result['message'] ?? 'Nie udalo sie zainstalowac motywu.')); + } + + $this->redirect("/sites/{$id}/dashboard"); + } + + public function reinstallWordPress(string $id): void + { + Auth::requireLogin(); + + $site = Site::find((int) $id); + if (!$site) { + $this->json(['success' => false, 'message' => 'Strona nie znaleziona.'], 404); + return; + } + + $progressId = (string) $this->input('progress_id', ''); + if ($progressId === '' || !preg_match('/^[a-zA-Z0-9]{10,30}$/', $progressId)) { + $this->json(['success' => false, 'message' => 'Nieprawidlowy identyfikator postepu.'], 422); + return; + } + + $republish = (bool) ((int) $this->input('republish_articles', 1)); + + $installer = new InstallerService(); + $result = $installer->reinstallSite($site, $republish, $progressId); + InstallerService::cleanupProgress($progressId); + + $this->json($result, !empty($result['success']) ? 200 : 500); + } } diff --git a/src/Helpers/Logger.php b/src/Helpers/Logger.php index b7b84ff..546058d 100644 --- a/src/Helpers/Logger.php +++ b/src/Helpers/Logger.php @@ -4,7 +4,9 @@ namespace App\Helpers; class Logger { + private const LOG_RETENTION_DAYS = 7; private static string $basePath = ''; + private static bool $cleanupDone = false; public static function setBasePath(string $path): void { @@ -36,9 +38,42 @@ class Logger mkdir($logDir, 0755, true); } + self::cleanupOldLogs($logDir); + $logFile = "{$logDir}/{$channel}_{$date}.log"; $line = "[{$time}] {$level}: {$message}" . PHP_EOL; file_put_contents($logFile, $line, FILE_APPEND | LOCK_EX); } + + private static function cleanupOldLogs(string $logDir): void + { + if (self::$cleanupDone) { + return; + } + self::$cleanupDone = true; + + $files = glob($logDir . '/*.log'); + if ($files === false) { + return; + } + + $maxAge = self::LOG_RETENTION_DAYS * 86400; + $now = time(); + + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + + $mtime = @filemtime($file); + if ($mtime === false) { + continue; + } + + if (($now - $mtime) > $maxAge) { + @unlink($file); + } + } + } } diff --git a/src/Models/Article.php b/src/Models/Article.php index 85491f4..5765dcc 100644 --- a/src/Models/Article.php +++ b/src/Models/Article.php @@ -23,22 +23,46 @@ class Article extends Model return $stmt->fetchAll(); } - public static function findAllWithRelations(int $limit = 50, int $offset = 0): array + public static function findAllWithRelations(int $limit = 50, int $offset = 0, ?int $siteId = null): array { - $stmt = self::db()->prepare( - "SELECT a.*, t.name as topic_name, s.name as site_name - FROM articles a - JOIN topics t ON a.topic_id = t.id - JOIN sites s ON a.site_id = s.id - ORDER BY a.created_at DESC - LIMIT :limit OFFSET :offset" - ); + $sql = "SELECT a.*, t.name as topic_name, s.name as site_name + FROM articles a + JOIN topics t ON a.topic_id = t.id + JOIN sites s ON a.site_id = s.id"; + + if ($siteId !== null) { + $sql .= " WHERE a.site_id = :site_id"; + } + + $sql .= " ORDER BY a.created_at DESC + LIMIT :limit OFFSET :offset"; + + $stmt = self::db()->prepare($sql); + + if ($siteId !== null) { + $stmt->bindValue('site_id', $siteId, \PDO::PARAM_INT); + } + $stmt->bindValue('limit', $limit, \PDO::PARAM_INT); $stmt->bindValue('offset', $offset, \PDO::PARAM_INT); $stmt->execute(); return $stmt->fetchAll(); } + public static function findPublishedBySiteForRepublish(int $siteId): array + { + $stmt = self::db()->prepare( + "SELECT a.id, a.site_id, a.topic_id, a.title, a.content, a.published_at, a.created_at + FROM articles a + WHERE a.site_id = :site_id + AND a.status = 'published' + AND a.content <> '' + ORDER BY COALESCE(a.published_at, a.created_at) ASC, a.id ASC" + ); + $stmt->execute(['site_id' => $siteId]); + return $stmt->fetchAll(); + } + public static function findWithRelations(int $id): ?array { $stmt = self::db()->prepare( @@ -66,6 +90,49 @@ class Article extends Model return $stmt->fetchAll(\PDO::FETCH_COLUMN); } + public static function existsBySiteAndWpPostId(int $siteId, int $wpPostId): bool + { + $stmt = self::db()->prepare( + "SELECT id FROM articles + WHERE site_id = :site_id AND wp_post_id = :wp_post_id + LIMIT 1" + ); + $stmt->execute([ + 'site_id' => $siteId, + 'wp_post_id' => $wpPostId, + ]); + + return (bool) $stmt->fetchColumn(); + } + + public static function findNextRetryableBySite(int $siteId): ?array + { + $stmt = self::db()->prepare( + "SELECT a.* + FROM articles a + WHERE a.site_id = :site_id + AND a.wp_post_id IS NULL + AND a.status IN ('generated', 'failed') + AND a.content <> '' + ORDER BY a.created_at ASC, a.id ASC + LIMIT 1" + ); + $stmt->execute(['site_id' => $siteId]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function markRetryAttempt(int $articleId): void + { + $stmt = self::db()->prepare( + "UPDATE articles + SET retry_count = COALESCE(retry_count, 0) + 1, + last_retry_at = NOW() + WHERE id = :id" + ); + $stmt->execute(['id' => $articleId]); + } + public static function getStats(): array { $db = self::db(); diff --git a/src/Models/Topic.php b/src/Models/Topic.php index b85e1dc..64206ca 100644 --- a/src/Models/Topic.php +++ b/src/Models/Topic.php @@ -39,4 +39,11 @@ class Topic extends Model $stmt = $db->prepare("UPDATE topics SET article_count = article_count + 1 WHERE id = :id"); $stmt->execute(['id' => $id]); } + + public static function decrementArticleCount(int $id): void + { + $db = self::db(); + $stmt = $db->prepare("UPDATE topics SET article_count = GREATEST(article_count - 1, 0) WHERE id = :id"); + $stmt->execute(['id' => $id]); + } } diff --git a/src/Services/FtpService.php b/src/Services/FtpService.php index b893388..bf45b1a 100644 --- a/src/Services/FtpService.php +++ b/src/Services/FtpService.php @@ -95,6 +95,11 @@ class FtpService } } + public function downloadFile(string $remotePath, string $localPath): bool + { + return (bool) @ftp_get($this->connection, $localPath, $remotePath, FTP_BINARY); + } + public function ensureDirectory(string $path): void { $parts = explode('/', trim($path, '/')); @@ -106,6 +111,16 @@ class FtpService } } + public function deleteDirectoryContents(string $remoteDir): void + { + $remoteDir = $this->normalizePath($remoteDir); + $items = $this->listDirectory($remoteDir); + + foreach ($items as $item) { + $this->deletePathRecursive($item['path'], $item['is_dir']); + } + } + public function disconnect(): void { if ($this->connection) { @@ -137,4 +152,96 @@ class FtpService } return $count; } + + /** + * @return array + */ + private function listDirectory(string $remoteDir): array + { + $result = []; + + if (function_exists('ftp_mlsd')) { + $entries = @ftp_mlsd($this->connection, $remoteDir); + if (is_array($entries)) { + foreach ($entries as $entry) { + $name = (string) ($entry['name'] ?? ''); + if ($name === '' || $name === '.' || $name === '..') { + continue; + } + + $path = $this->normalizePath($remoteDir . '/' . $name); + $result[] = [ + 'path' => $path, + 'is_dir' => (($entry['type'] ?? '') === 'dir'), + ]; + } + + return $result; + } + } + + $entries = @ftp_nlist($this->connection, $remoteDir); + if (!is_array($entries)) { + return []; + } + + foreach ($entries as $entry) { + $normalizedEntry = str_replace('\\', '/', (string) $entry); + $name = basename($normalizedEntry); + if ($name === '' || $name === '.' || $name === '..') { + continue; + } + + $path = str_starts_with($normalizedEntry, '/') + ? $this->normalizePath($normalizedEntry) + : $this->normalizePath($remoteDir . '/' . $normalizedEntry); + + $result[] = [ + 'path' => $path, + 'is_dir' => $this->isDirectory($path), + ]; + } + + return $result; + } + + private function deletePathRecursive(string $path, bool $isDir): void + { + if ($isDir) { + foreach ($this->listDirectory($path) as $child) { + $this->deletePathRecursive($child['path'], $child['is_dir']); + } + + if (!@ftp_rmdir($this->connection, $path)) { + throw new \RuntimeException("FTP remove directory failed: {$path}"); + } + return; + } + + if (!@ftp_delete($this->connection, $path)) { + throw new \RuntimeException("FTP delete file failed: {$path}"); + } + } + + private function isDirectory(string $path): bool + { + $current = @ftp_pwd($this->connection); + if ($current === false) { + return false; + } + + if (@ftp_chdir($this->connection, $path)) { + @ftp_chdir($this->connection, $current); + return true; + } + + return false; + } + + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + $path = preg_replace('#/+#', '/', $path) ?? $path; + return '/' . ltrim($path, '/'); + } } diff --git a/src/Services/InstallerService.php b/src/Services/InstallerService.php index 75e39cb..b45aaa9 100644 --- a/src/Services/InstallerService.php +++ b/src/Services/InstallerService.php @@ -8,7 +8,9 @@ use GuzzleHttp\Client; use GuzzleHttp\Cookie\CookieJar; use GuzzleHttp\Exception\GuzzleException; use App\Helpers\Logger; +use App\Models\Article; use App\Models\Site; +use App\Models\Topic; class InstallerService { @@ -117,6 +119,18 @@ class InstallerService 'wp_admin_email' => $config['admin_email'], ]); + $registeredSite = Site::find($siteId); + if (is_array($registeredSite)) { + $wpTools = new WordPressService(); + $remoteInstall = $wpTools->ensureRemoteService($registeredSite); + if (empty($remoteInstall['success'])) { + Logger::warning( + "Remote service install skipped/failed for site_id={$siteId}: " . ($remoteInstall['message'] ?? 'unknown'), + 'installer' + ); + } + } + Logger::info("WordPress installed and site registered (ID: {$siteId})", 'installer'); $this->updateProgress(100, 'Instalacja zakończona pomyślnie!', 'completed'); @@ -140,6 +154,106 @@ class InstallerService } } + /** + * @return array{success: bool, message: string, site_id: int|null} + */ + public function reinstallSite(array $site, bool $republishPublishedArticles = true, string $progressId = ''): array + { + set_time_limit(1200); + ini_set('memory_limit', '512M'); + + $this->progressId = $progressId; + + $config = $this->buildConfigFromSite($site); + $missing = $this->validateReinstallConfig($config); + if (!empty($missing)) { + return [ + 'success' => false, + 'message' => 'Brak wymaganych danych do reinstalacji: ' . implode(', ', $missing), + 'site_id' => (int) ($site['id'] ?? 0), + ]; + } + + $siteId = (int) ($site['id'] ?? 0); + Logger::warning("Starting WordPress reinstall for site_id={$siteId}, url={$config['site_url']}", 'installer'); + $this->updateProgress(2, 'Przygotowanie reinstalacji WordPress...'); + + $articlesToRepublish = $republishPublishedArticles + ? Article::findPublishedBySiteForRepublish($siteId) + : []; + + try { + $this->updateProgress(8, 'Pobieranie WordPress...'); + $zipPath = $this->downloadWordPress($config['language']); + + $this->updateProgress(16, 'Rozpakowywanie archiwum...'); + $wpSourceDir = $this->extractZip($zipPath); + + $this->updateProgress(24, 'Generowanie wp-config.php...'); + $this->generateWpConfig($wpSourceDir, $config); + + $this->updateProgress(30, 'Czyszczenie katalogu FTP...'); + $this->clearRemoteFtpPath($config); + + $this->updateProgress(38, 'Czyszczenie bazy danych...'); + $this->clearDatabase($config); + + $this->updateProgress(44, 'Wgrywanie plikow WordPress...'); + $this->uploadViaFtp($wpSourceDir, $config); + + $this->updateProgress(85, 'Uruchamianie instalacji WordPress...'); + $this->triggerInstallation($config); + + $this->updateProgress(91, 'Tworzenie Application Password...'); + $appPassword = $this->createApplicationPassword($config); + + Site::update($siteId, [ + 'name' => $config['site_title'], + 'url' => $config['site_url'], + 'api_user' => $config['admin_user'], + 'api_token' => $appPassword, + 'wp_admin_user' => $config['admin_user'], + 'wp_admin_pass' => $config['admin_pass'], + 'wp_admin_email' => $config['admin_email'], + 'last_published_at' => null, + ]); + + $runtimeSite = Site::find($siteId); + if (!$runtimeSite) { + throw new \RuntimeException('Nie udalo sie odczytac strony po aktualizacji danych API.'); + } + + $this->updateProgress(95, 'Tworzenie kategorii i ponowna publikacja artykulow...'); + $republishStats = $this->republishArticlesAfterReinstall($runtimeSite, $articlesToRepublish); + + $this->updateProgress(100, 'Reinstalacja zakonczona pomyslnie!', 'completed'); + $this->cleanup(); + + $message = 'Reinstalacja WordPress zakonczona. '; + if ($republishPublishedArticles) { + $message .= "Artykuly odtworzone: {$republishStats['published']}, bledy: {$republishStats['failed']}."; + } else { + $message .= 'Pominieto ponowna publikacje artykulow.'; + } + + return [ + 'success' => true, + 'message' => $message, + 'site_id' => $siteId, + ]; + } catch (\Throwable $e) { + Logger::error("Reinstallation failed for site_id={$siteId}: " . $e->getMessage(), 'installer'); + $this->updateProgress(0, 'Blad: ' . $e->getMessage(), 'failed'); + $this->cleanup(); + + return [ + 'success' => false, + 'message' => 'Blad reinstalacji: ' . $e->getMessage(), + 'site_id' => $siteId, + ]; + } + } + private function downloadWordPress(string $language): string { Logger::info("Downloading WordPress ({$language})", 'installer'); @@ -303,6 +417,44 @@ PHP; } } + private function clearRemoteFtpPath(array $config): void + { + Logger::warning("Clearing FTP path {$config['ftp_path']} on {$config['ftp_host']}", 'installer'); + + $ftp = new FtpService( + $config['ftp_host'], + $config['ftp_user'], + $config['ftp_pass'], + $config['ftp_port'], + $config['ftp_ssl'] + ); + + try { + $ftp->connect(); + $ftp->ensureDirectory($config['ftp_path']); + $ftp->deleteDirectoryContents($config['ftp_path']); + } finally { + $ftp->disconnect(); + } + } + + private function clearDatabase(array $config): void + { + Logger::warning("Clearing database {$config['db_name']} on {$config['db_host']}", 'installer'); + + try { + $this->clearDatabaseViaPdo($config); + return; + } catch (\Throwable $e) { + Logger::warning( + 'Direct DB cleanup failed, trying remote service fallback: ' . $e->getMessage(), + 'installer' + ); + } + + $this->clearDatabaseViaRemoteService($config); + } + private function triggerInstallation(array $config): void { Logger::info("Triggering WordPress installation at {$config['site_url']}", 'installer'); @@ -439,4 +591,334 @@ PHP; @rmdir($dir); } + + private function buildConfigFromSite(array $site): array + { + return [ + 'ftp_host' => (string) ($site['ftp_host'] ?? ''), + 'ftp_user' => (string) ($site['ftp_user'] ?? ''), + 'ftp_pass' => (string) ($site['ftp_pass'] ?? ''), + 'ftp_path' => rtrim((string) ($site['ftp_path'] ?? ''), '/'), + 'ftp_port' => (int) ($site['ftp_port'] ?? 21), + 'ftp_ssl' => false, + 'db_host' => (string) ($site['db_host'] ?? ''), + 'db_name' => (string) ($site['db_name'] ?? ''), + 'db_user' => (string) ($site['db_user'] ?? ''), + 'db_pass' => (string) ($site['db_pass'] ?? ''), + 'db_prefix' => (string) ($site['db_prefix'] ?? 'wp_'), + 'site_url' => rtrim((string) ($site['url'] ?? ''), '/'), + 'site_title' => (string) ($site['name'] ?? 'WordPress'), + 'admin_user' => (string) ($site['wp_admin_user'] ?? $site['api_user'] ?? ''), + 'admin_pass' => (string) ($site['wp_admin_pass'] ?? ''), + 'admin_email' => (string) ($site['wp_admin_email'] ?? 'admin@example.com'), + 'language' => 'pl_PL', + ]; + } + + /** + * @return string[] + */ + private function validateReinstallConfig(array $config): array + { + $required = [ + 'ftp_host', + 'ftp_user', + 'ftp_pass', + 'ftp_path', + 'db_host', + 'db_name', + 'db_user', + 'db_pass', + 'site_url', + 'site_title', + 'admin_user', + 'admin_pass', + 'admin_email', + ]; + + $missing = []; + foreach ($required as $key) { + if (trim((string) ($config[$key] ?? '')) === '') { + $missing[] = $key; + } + } + + return $missing; + } + + private function republishArticlesAfterReinstall(array $site, array $articles): array + { + $wp = new WordPressService(); + $topicCategoryMap = $this->recreateTopicCategories($site, $wp); + + $published = 0; + $failed = 0; + + foreach ($articles as $article) { + $topicId = (int) ($article['topic_id'] ?? 0); + $categoryId = $topicCategoryMap[$topicId] ?? null; + + $wpPostId = $wp->createPost( + $site, + (string) ($article['title'] ?? ''), + (string) ($article['content'] ?? ''), + $categoryId, + null + ); + + if ($wpPostId) { + Article::update((int) $article['id'], [ + 'wp_post_id' => (int) $wpPostId, + 'status' => 'published', + 'error_message' => null, + ]); + $published++; + continue; + } + + $failed++; + Logger::error( + "Republish failed after reinstall. site_id={$site['id']}, article_id={$article['id']}", + 'installer' + ); + } + + return ['published' => $published, 'failed' => $failed]; + } + + /** + * @return array topic_id => wp_category_id + */ + private function recreateTopicCategories(array $site, WordPressService $wp): array + { + $topics = Topic::findBySite((int) $site['id']); + $map = []; + + foreach ($topics as $topic) { + $created = $wp->createCategory($site, (string) $topic['name'], 0); + $wpCategoryId = isset($created['id']) ? (int) $created['id'] : null; + + if ($wpCategoryId !== null && $wpCategoryId > 0) { + Topic::update((int) $topic['id'], ['wp_category_id' => $wpCategoryId]); + $map[(int) $topic['id']] = $wpCategoryId; + } else { + $map[(int) $topic['id']] = null; + } + } + + return $map; + } + + private function clearDatabaseViaPdo(array $config): void + { + $host = (string) $config['db_host']; + $port = null; + if (strpos($host, ':') !== false) { + [$hostOnly, $portPart] = explode(':', $host, 2); + if (is_numeric($portPart)) { + $host = $hostOnly; + $port = (int) $portPart; + } + } + + $dsn = "mysql:host={$host};dbname={$config['db_name']};charset=utf8mb4"; + if ($port !== null && $port > 0) { + $dsn .= ";port={$port}"; + } + + $pdo = new \PDO( + $dsn, + (string) $config['db_user'], + (string) $config['db_pass'], + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + ] + ); + + $tables = $pdo->query("SHOW FULL TABLES")->fetchAll(\PDO::FETCH_NUM); + $pdo->exec('SET FOREIGN_KEY_CHECKS=0'); + + foreach ($tables as $row) { + $tableName = (string) ($row[0] ?? ''); + $tableType = strtoupper((string) ($row[1] ?? 'BASE TABLE')); + if ($tableName === '') { + continue; + } + + if ($tableType === 'VIEW') { + $pdo->exec("DROP VIEW IF EXISTS `{$tableName}`"); + } else { + $pdo->exec("DROP TABLE IF EXISTS `{$tableName}`"); + } + } + + $pdo->exec('SET FOREIGN_KEY_CHECKS=1'); + } + + private function clearDatabaseViaRemoteService(array $config): void + { + $token = bin2hex(random_bytes(24)); + $scriptName = 'backpro-service-' . bin2hex(random_bytes(6)) . '.php'; + $serviceContent = $this->getRemoteDbServiceScriptContent($token); + $tmpFile = tempnam(sys_get_temp_dir(), 'backpro_service_'); + + if ($tmpFile === false) { + throw new \RuntimeException('Cannot create temporary file for remote DB service.'); + } + + $ftp = new FtpService( + $config['ftp_host'], + $config['ftp_user'], + $config['ftp_pass'], + $config['ftp_port'], + $config['ftp_ssl'] + ); + + $remoteDir = '/' . trim((string) $config['ftp_path'], '/'); + $remoteScriptPath = rtrim($remoteDir, '/') . '/' . $scriptName; + + try { + file_put_contents($tmpFile, $serviceContent); + $ftp->connect(); + $ftp->ensureDirectory($remoteDir); + $ftp->uploadFile($tmpFile, $remoteScriptPath); + $ftp->disconnect(); + + $endpoint = rtrim((string) $config['site_url'], '/') . '/' . $scriptName; + + $response = $this->http->post($endpoint, [ + 'form_params' => [ + 'token' => $token, + 'action' => 'db_clear', + 'db_host' => (string) $config['db_host'], + 'db_name' => (string) $config['db_name'], + 'db_user' => (string) $config['db_user'], + 'db_pass' => (string) $config['db_pass'], + ], + 'timeout' => 120, + 'headers' => [ + 'User-Agent' => 'BackPRO/1.0 Remote-Service', + ], + ]); + + $data = json_decode($response->getBody()->getContents(), true); + if (!is_array($data) || empty($data['success'])) { + $message = is_array($data) ? (string) ($data['message'] ?? 'unknown error') : 'invalid response'; + throw new \RuntimeException('Remote DB cleanup failed: ' . $message); + } + + try { + $this->http->post($endpoint, [ + 'form_params' => [ + 'token' => $token, + 'action' => 'cleanup', + ], + 'timeout' => 20, + ]); + } catch (\Throwable $e) { + Logger::warning('Remote service cleanup request failed: ' . $e->getMessage(), 'installer'); + } + + Logger::info('Database cleaned through remote service endpoint.', 'installer'); + } finally { + if (is_file($tmpFile)) { + @unlink($tmpFile); + } + } + } + + private function getRemoteDbServiceScriptContent(string $token): string + { + $safeToken = addslashes($token); + + return << false, 'message' => 'method_not_allowed']); + exit; +} + +\$expectedToken = '{$safeToken}'; +\$providedToken = (string) (\$_POST['token'] ?? ''); + +if (!hash_equals(\$expectedToken, \$providedToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'message' => 'forbidden']); + exit; +} + +\$action = (string) (\$_POST['action'] ?? ''); +if (\$action === 'cleanup') { + @unlink(__FILE__); + echo json_encode(['success' => true, 'message' => 'service_deleted']); + exit; +} + +if (\$action !== 'db_clear') { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'invalid_action']); + exit; +} + +\$dbHost = (string) (\$_POST['db_host'] ?? ''); +\$dbName = (string) (\$_POST['db_name'] ?? ''); +\$dbUser = (string) (\$_POST['db_user'] ?? ''); +\$dbPass = (string) (\$_POST['db_pass'] ?? ''); + +if (\$dbHost === '' || \$dbName === '' || \$dbUser === '') { + http_response_code(422); + echo json_encode(['success' => false, 'message' => 'missing_db_params']); + exit; +} + +try { + \$host = \$dbHost; + \$port = null; + if (strpos(\$host, ':') !== false) { + list(\$hostOnly, \$portPart) = explode(':', \$host, 2); + if (is_numeric(\$portPart)) { + \$host = \$hostOnly; + \$port = (int) \$portPart; + } + } + + \$dsn = "mysql:host={\$host};dbname={\$dbName};charset=utf8mb4"; + if (\$port !== null && \$port > 0) { + \$dsn .= ";port={\$port}"; + } + + \$pdo = new PDO(\$dsn, \$dbUser, \$dbPass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + + \$tables = \$pdo->query("SHOW FULL TABLES")->fetchAll(PDO::FETCH_NUM); + \$pdo->exec('SET FOREIGN_KEY_CHECKS=0'); + + foreach (\$tables as \$row) { + \$tableName = (string) (\$row[0] ?? ''); + \$tableType = strtoupper((string) (\$row[1] ?? 'BASE TABLE')); + if (\$tableName === '') { + continue; + } + + if (\$tableType === 'VIEW') { + \$pdo->exec("DROP VIEW IF EXISTS `{\$tableName}`"); + } else { + \$pdo->exec("DROP TABLE IF EXISTS `{\$tableName}`"); + } + } + + \$pdo->exec('SET FOREIGN_KEY_CHECKS=1'); + echo json_encode(['success' => true, 'message' => 'db_cleared']); +} catch (Throwable \$e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => \$e->getMessage()]); +} +PHP; + } } diff --git a/src/Services/OpenAIService.php b/src/Services/OpenAIService.php index ef6dd50..d604a4c 100644 --- a/src/Services/OpenAIService.php +++ b/src/Services/OpenAIService.php @@ -48,6 +48,7 @@ class OpenAIService ]); $userPrompt = "Napisz artykuł na temat: {$topicName}\n"; + $userPrompt .= "Tytul ma byc samodzielny i nie moze zaczynac sie od nazwy tematu ani kategorii.\n"; if (!empty($topicDescription)) { $userPrompt .= "Wytyczne: {$topicDescription}\n"; } diff --git a/src/Services/PublisherService.php b/src/Services/PublisherService.php index cbef461..cb7c091 100644 --- a/src/Services/PublisherService.php +++ b/src/Services/PublisherService.php @@ -24,13 +24,13 @@ class PublisherService public function publishNext(): array { - Logger::info('Rozpoczynam automatyczną publikację', 'publish'); + Logger::info('Rozpoczynam automatyczna publikacje', 'publish'); $sites = Site::findDueForPublishing(); if (empty($sites)) { Logger::info('Brak stron do publikacji', 'publish'); - return ['success' => false, 'message' => 'Brak stron wymagających publikacji.']; + return ['success' => false, 'message' => 'Brak stron wymagajacych publikacji.']; } $site = $sites[0]; @@ -41,19 +41,42 @@ class PublisherService { Logger::info("Publikacja dla strony: {$site['name']} (ID: {$site['id']})", 'publish'); - // 1. Select topic + // 1. Najpierw publikuj gotowe, nieopublikowane artykuly. + $retryArticle = Article::findNextRetryableBySite((int) $site['id']); + if ($retryArticle) { + $topic = Topic::find((int) $retryArticle['topic_id']); + if (!$topic) { + Logger::error("Nie znaleziono tematu dla artykulu ID {$retryArticle['id']}", 'publish'); + return ['success' => false, 'message' => 'Nie znaleziono tematu dla oczekujacego artykulu.']; + } + + Logger::info("Ponowna proba publikacji artykulu ID {$retryArticle['id']}: {$retryArticle['title']}", 'publish'); + Article::markRetryAttempt((int) $retryArticle['id']); + + return $this->publishPreparedArticle( + $site, + $topic, + [ + 'title' => (string) $retryArticle['title'], + 'content' => (string) $retryArticle['content'], + 'model' => $retryArticle['ai_model'] ?? null, + 'prompt' => $retryArticle['prompt_used'] ?? null, + ], + (int) $retryArticle['id'] + ); + } + + // 2. Gdy brak zaleglych, generuj nowy artykul. $topic = $this->topicBalancer->getNextTopic($site['id']); if (!$topic) { - Logger::error("Brak aktywnych tematów dla strony {$site['name']}", 'publish'); - return ['success' => false, 'message' => "Brak aktywnych tematów dla strony {$site['name']}."]; + Logger::error("Brak aktywnych tematow dla strony {$site['name']}", 'publish'); + return ['success' => false, 'message' => "Brak aktywnych tematow dla strony {$site['name']}."]; } Logger::info("Wybrany temat: {$topic['name']} (ID: {$topic['id']})", 'publish'); - // 2. Get existing titles to avoid repetition - $existingTitles = Article::getRecentTitlesByTopic($topic['id'], 20); + $existingTitles = Article::getRecentTitlesByTopic((int) $topic['id'], 20); - // 3. Generate article $article = $this->openAI->generateArticle( $topic['name'], $topic['description'] ?? '', @@ -61,16 +84,22 @@ class PublisherService ); if (!$article) { - $this->saveFailedArticle($site, $topic, 'Nie udało się wygenerować artykułu przez OpenAI.'); - return ['success' => false, 'message' => 'Błąd generowania artykułu przez AI.']; + $this->saveFailedArticle($site, $topic, 'Nie udalo sie wygenerowac artykulu przez OpenAI.'); + return ['success' => false, 'message' => 'Blad generowania artykulu przez AI.']; } - Logger::info("Wygenerowano artykuł: {$article['title']}", 'publish'); + Logger::info("Wygenerowano artykul: {$article['title']}", 'publish'); - // 4. Generate/fetch image + $article['title'] = $this->normalizeArticleTitle((string) ($article['title'] ?? ''), (string) $topic['name']); + + return $this->publishPreparedArticle($site, $topic, $article); + } + + private function publishPreparedArticle(array $site, array $topic, array $article, ?int $existingArticleId = null): array + { $imageUrl = null; $mediaId = null; - $image = $this->imageService->generate($article['title'], $topic['name']); + $image = $this->imageService->generate((string) $article['title'], (string) $topic['name']); if ($image) { $mediaId = $this->wordpress->uploadMedia($site, $image['data'], $image['filename']); @@ -78,42 +107,59 @@ class PublisherService Logger::info("Upload obrazka: media_id={$mediaId}", 'publish'); } } else { - Logger::warning('Nie udało się wygenerować obrazka, publikacja bez obrazka', 'publish'); + Logger::warning('Nie udalo sie wygenerowac obrazka, publikacja bez obrazka', 'publish'); } - // 5. Publish to WordPress $wpPostId = $this->wordpress->createPost( $site, - $article['title'], - $article['content'], + (string) $article['title'], + (string) $article['content'], $topic['wp_category_id'], $mediaId ); if (!$wpPostId) { - $this->saveFailedArticle($site, $topic, 'Nie udało się opublikować posta na WordPress.', $article); - return ['success' => false, 'message' => 'Błąd publikacji na WordPress.']; + $this->saveFailedArticle( + $site, + $topic, + 'Nie udalo sie opublikowac posta na WordPress.', + $article, + $existingArticleId + ); + return ['success' => false, 'message' => 'Blad publikacji na WordPress.']; } Logger::info("Opublikowano post: wp_post_id={$wpPostId}", 'publish'); - // 6. Save article in database - Article::create([ - 'site_id' => $site['id'], - 'topic_id' => $topic['id'], - 'title' => $article['title'], - 'content' => $article['content'], - 'wp_post_id' => $wpPostId, - 'image_url' => $imageUrl, - 'status' => 'published', - 'ai_model' => $article['model'], - 'prompt_used' => $article['prompt'], - 'published_at' => date('Y-m-d H:i:s'), - ]); + if ($existingArticleId !== null) { + Article::update($existingArticleId, [ + 'title' => (string) $article['title'], + 'content' => (string) $article['content'], + 'wp_post_id' => $wpPostId, + 'image_url' => $imageUrl, + 'status' => 'published', + 'ai_model' => $article['model'] ?? null, + 'prompt_used' => $article['prompt'] ?? null, + 'error_message' => null, + 'published_at' => date('Y-m-d H:i:s'), + ]); + } else { + Article::create([ + 'site_id' => $site['id'], + 'topic_id' => $topic['id'], + 'title' => (string) $article['title'], + 'content' => (string) $article['content'], + 'wp_post_id' => $wpPostId, + 'image_url' => $imageUrl, + 'status' => 'published', + 'ai_model' => $article['model'] ?? null, + 'prompt_used' => $article['prompt'] ?? null, + 'published_at' => date('Y-m-d H:i:s'), + ]); + } - // 7. Update counters - Topic::incrementArticleCount($topic['id']); - Site::updateLastPublished($site['id']); + Topic::incrementArticleCount((int) $topic['id']); + Site::updateLastPublished((int) $site['id']); $message = "Opublikowano: \"{$article['title']}\" na {$site['name']}"; Logger::info($message, 'publish'); @@ -121,18 +167,51 @@ class PublisherService return ['success' => true, 'message' => $message]; } - private function saveFailedArticle(array $site, array $topic, string $error, ?array $article = null): void + private function normalizeArticleTitle(string $title, string $topicName): string { - Article::create([ - 'site_id' => $site['id'], - 'topic_id' => $topic['id'], - 'title' => $article['title'] ?? 'FAILED - nie wygenerowano', - 'content' => $article['content'] ?? '', - 'status' => 'failed', - 'ai_model' => $article['model'] ?? null, - 'prompt_used' => $article['prompt'] ?? null, - 'error_message' => $error, - ]); + $title = trim($title); + $topicName = trim($topicName); + + if ($title === '' || $topicName === '') { + return $title; + } + + // Remove ": " / " - " prefixes from generated titles. + $topicPattern = preg_quote($topicName, '/'); + $normalized = preg_replace('/^' . $topicPattern . '\s*[:\-\x{2013}\x{2014}|]\s*/iu', '', $title); + $normalized = is_string($normalized) ? trim($normalized) : $title; + + return $normalized !== '' ? $normalized : $title; + } + + private function saveFailedArticle( + array $site, + array $topic, + string $error, + ?array $article = null, + ?int $existingArticleId = null + ): void { + if ($existingArticleId !== null) { + Article::update($existingArticleId, [ + 'title' => $article['title'] ?? 'FAILED - nie wygenerowano', + 'content' => $article['content'] ?? '', + 'status' => 'failed', + 'ai_model' => $article['model'] ?? null, + 'prompt_used' => $article['prompt'] ?? null, + 'error_message' => $error, + ]); + } else { + Article::create([ + 'site_id' => $site['id'], + 'topic_id' => $topic['id'], + 'title' => $article['title'] ?? 'FAILED - nie wygenerowano', + 'content' => $article['content'] ?? '', + 'status' => 'failed', + 'ai_model' => $article['model'] ?? null, + 'prompt_used' => $article['prompt'] ?? null, + 'error_message' => $error, + ]); + } Logger::error("Publikacja nieudana: {$error}", 'publish'); } diff --git a/src/Services/WordPressService.php b/src/Services/WordPressService.php index c1beffd..541abc8 100644 --- a/src/Services/WordPressService.php +++ b/src/Services/WordPressService.php @@ -4,10 +4,17 @@ namespace App\Services; use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; use App\Helpers\Logger; +use App\Models\Site; class WordPressService { + private const BACKPRO_MU_PLUGIN_FILENAME = 'backpro-remote-tools.php'; + private const BACKPRO_REMOTE_SERVICE_FILENAME = 'backpro-remote-service.php'; + private const BACKPRO_REMOTE_SERVICE_VERSION = '1.3.0'; + private const BACKPRO_NEWS_THEME_SLUG = 'backpro-news-mag'; + private const BACKPRO_NEWS_THEME_SOURCE_DIR = 'assets/wp-theme-backpro-news'; private Client $client; public function __construct() @@ -18,10 +25,13 @@ class WordPressService public function testConnection(array $site): array { try { - $response = $this->client->get($site['url'] . '/wp-json/wp/v2/posts', [ - 'auth' => [$site['api_user'], $site['api_token']], - 'query' => ['per_page' => 1], - ]); + $options = ['query' => ['per_page' => 1]]; + $auth = $this->buildAuthOption($site); + if ($auth !== null) { + $options['auth'] = $auth; + } + + $response = $this->requestWp($site, 'GET', 'wp/v2/posts', $options); if ($response->getStatusCode() === 200) { return ['success' => true, 'message' => 'Połączenie OK']; @@ -37,12 +47,25 @@ class WordPressService public function getCategories(array $site): array|false { try { - $response = $this->client->get($site['url'] . '/wp-json/wp/v2/categories', [ - 'auth' => [$site['api_user'], $site['api_token']], - 'query' => ['per_page' => 100], - ]); + $options = ['query' => ['per_page' => 100]]; + $auth = $this->buildAuthOption($site); + if ($auth !== null) { + $options['auth'] = $auth; + } - return json_decode($response->getBody()->getContents(), true); + $response = $this->requestWp($site, 'GET', 'wp/v2/categories', $options); + + $data = json_decode($response->getBody()->getContents(), true); + if (!is_array($data)) { + return false; + } + + if (isset($data['code']) && isset($data['message'])) { + Logger::error("WP getCategories API error for {$site['url']}: {$data['code']} {$data['message']}", 'wordpress'); + return false; + } + + return $data; } catch (GuzzleException $e) { Logger::error("WP getCategories failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); return false; @@ -51,9 +74,14 @@ class WordPressService public function createCategory(array $site, string $name, int $parent = 0): ?array { + $auth = $this->requireAuthOption($site, 'createCategory'); + if ($auth === null) { + return null; + } + try { - $response = $this->client->post($site['url'] . '/wp-json/wp/v2/categories', [ - 'auth' => [$site['api_user'], $site['api_token']], + $response = $this->requestWp($site, 'POST', 'wp/v2/categories', [ + 'auth' => $auth, 'json' => [ 'name' => $name, 'parent' => $parent, @@ -69,9 +97,15 @@ class WordPressService public function uploadMedia(array $site, string $imageData, string $filename): ?int { + $auth = $this->requireAuthOption($site, 'uploadMedia'); + if ($auth === null) { + return null; + } + + // Try REST API first. try { - $response = $this->client->post($site['url'] . '/wp-json/wp/v2/media', [ - 'auth' => [$site['api_user'], $site['api_token']], + $response = $this->requestWp($site, 'POST', 'wp/v2/media', [ + 'auth' => $auth, 'headers' => [ 'Content-Disposition' => "attachment; filename=\"{$filename}\"", 'Content-Type' => $this->getMimeType($filename), @@ -80,11 +114,17 @@ class WordPressService ]); $data = json_decode($response->getBody()->getContents(), true); - return $data['id'] ?? null; + if (is_array($data) && isset($data['id'])) { + return (int) $data['id']; + } + + Logger::warning("WP REST uploadMedia returned unexpected response for {$site['url']}, trying XML-RPC.", 'wordpress'); } catch (GuzzleException $e) { - Logger::error("WP uploadMedia failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); - return null; + Logger::warning("WP REST uploadMedia failed for {$site['url']}, trying XML-RPC: " . $e->getMessage(), 'wordpress'); } + + // Fall back to XML-RPC. + return $this->uploadMediaXmlRpc($site, $auth, $imageData, $filename); } public function createPost( @@ -94,6 +134,12 @@ class WordPressService ?int $categoryId = null, ?int $mediaId = null ): ?int { + $auth = $this->requireAuthOption($site, 'createPost'); + if ($auth === null) { + return null; + } + + // Try REST API first. try { $postData = [ 'title' => $title, @@ -109,24 +155,112 @@ class WordPressService $postData['featured_media'] = $mediaId; } - $response = $this->client->post($site['url'] . '/wp-json/wp/v2/posts', [ - 'auth' => [$site['api_user'], $site['api_token']], + $response = $this->requestWp($site, 'POST', 'wp/v2/posts', [ + 'auth' => $auth, 'json' => $postData, ]); $data = json_decode($response->getBody()->getContents(), true); - return $data['id'] ?? null; + if (is_array($data) && isset($data['id'])) { + return (int) $data['id']; + } + + Logger::warning("WP REST createPost returned unexpected response for {$site['url']}, trying XML-RPC.", 'wordpress'); } catch (GuzzleException $e) { - Logger::error("WP createPost failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); - return null; + Logger::warning("WP REST createPost failed for {$site['url']}, trying XML-RPC: " . $e->getMessage(), 'wordpress'); + } + + // Fall back to XML-RPC. + return $this->createPostXmlRpc($site, $auth, $title, $content, $categoryId, $mediaId); + } + + public function getPublishedPosts(array $site, int $perPage = 100): array|false + { + $perPage = max(1, min($perPage, 100)); + $allPosts = []; + $page = 1; + $totalPages = 1; + $auth = $this->buildAuthOption($site); + + try { + do { + $query = [ + 'status' => 'publish', + 'per_page' => $perPage, + 'page' => $page, + '_fields' => 'id,title,content,date,categories', + ]; + $options = ['query' => $query]; + if ($auth !== null) { + $options['auth'] = $auth; + } + + $response = $this->requestWp($site, 'GET', 'wp/v2/posts', $options); + $batch = json_decode($response->getBody()->getContents(), true); + + // Some hosts return HTML/invalid payload when invalid basic auth is sent. + if ((!is_array($batch) || (isset($batch['code']) && isset($batch['message']))) && $auth !== null) { + $response = $this->requestWp($site, 'GET', 'wp/v2/posts', ['query' => $query]); + $batch = json_decode($response->getBody()->getContents(), true); + } + + if (!is_array($batch)) { + throw new \RuntimeException('Invalid JSON response from WordPress posts endpoint.'); + } + if (isset($batch['code']) && isset($batch['message'])) { + throw new \RuntimeException("WordPress API error: {$batch['code']} {$batch['message']}"); + } + + $allPosts = array_merge($allPosts, $batch); + + $totalPages = max(1, (int) $response->getHeaderLine('X-WP-TotalPages')); + $page++; + } while ($page <= $totalPages); + + return $allPosts; + } catch (\Throwable $e) { + Logger::error("WP getPublishedPosts failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); + return false; + } + } + + public function deletePost(array $site, int $wpPostId): bool + { + $auth = $this->requireAuthOption($site, 'deletePost'); + if ($auth === null) { + return false; + } + + try { + $this->requestWp($site, 'DELETE', 'wp/v2/posts/' . $wpPostId, [ + 'auth' => $auth, + 'query' => ['force' => true], + ]); + return true; + } catch (GuzzleException $e) { + if ($e instanceof RequestException && $e->hasResponse()) { + $status = $e->getResponse()->getStatusCode(); + if (in_array($status, [404, 410], true)) { + Logger::warning("WP post {$wpPostId} already removed for {$site['url']}", 'wordpress'); + return true; + } + } + + Logger::error("WP deletePost failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); + return false; } } public function getPostFeaturedMedia(array $site, int $wpPostId): ?int { + $auth = $this->requireAuthOption($site, 'getPostFeaturedMedia'); + if ($auth === null) { + return null; + } + try { - $response = $this->client->get($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [ - 'auth' => [$site['api_user'], $site['api_token']], + $response = $this->requestWp($site, 'GET', 'wp/v2/posts/' . $wpPostId, [ + 'auth' => $auth, 'query' => ['_fields' => 'featured_media'], ]); @@ -141,9 +275,14 @@ class WordPressService public function updatePostFeaturedMedia(array $site, int $wpPostId, int $mediaId): bool { + $auth = $this->requireAuthOption($site, 'updatePostFeaturedMedia'); + if ($auth === null) { + return false; + } + try { - $this->client->post($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [ - 'auth' => [$site['api_user'], $site['api_token']], + $this->requestWp($site, 'POST', 'wp/v2/posts/' . $wpPostId, [ + 'auth' => $auth, 'json' => ['featured_media' => $mediaId], ]); return true; @@ -155,9 +294,14 @@ class WordPressService public function deleteMedia(array $site, int $mediaId): bool { + $auth = $this->requireAuthOption($site, 'deleteMedia'); + if ($auth === null) { + return false; + } + try { - $this->client->delete($site['url'] . '/wp-json/wp/v2/media/' . $mediaId, [ - 'auth' => [$site['api_user'], $site['api_token']], + $this->requestWp($site, 'DELETE', 'wp/v2/media/' . $mediaId, [ + 'auth' => $auth, 'query' => ['force' => true], ]); return true; @@ -167,6 +311,618 @@ class WordPressService } } + public function getPermalinkSettings(array $site): array + { + $result = $this->callRemoteService($site, 'get_permalink'); + if (!empty($result['success'])) { + return [ + 'success' => true, + 'permalink_structure' => (string) ($result['permalink_structure'] ?? ''), + 'pretty_enabled' => (bool) ($result['pretty_enabled'] ?? false), + 'source' => 'backpro_remote_service', + 'message' => (string) ($result['message'] ?? 'OK'), + ]; + } + + $ensure = $this->ensureRemoteService($site); + if (!empty($ensure['success'])) { + $refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site; + $retry = $this->callRemoteService($refreshedSite, 'get_permalink'); + if (!empty($retry['success'])) { + return [ + 'success' => true, + 'permalink_structure' => (string) ($retry['permalink_structure'] ?? ''), + 'pretty_enabled' => (bool) ($retry['pretty_enabled'] ?? false), + 'source' => 'backpro_remote_service', + 'message' => (string) ($retry['message'] ?? 'OK'), + ]; + } + } + + return [ + 'success' => false, + 'code' => 'endpoint_missing', + 'message' => (string) ($ensure['message'] ?? $result['message'] ?? 'Brak endpointu BackPRO na WordPress.'), + ]; + } + + public function enablePrettyPermalinks(array $site): array + { + $result = $this->removeIndexPhpFromPermalinks($site); + $this->ensureHtaccessForPrettyPermalinks($site); + + // Try a dedicated rewrite refresh even when structure did not change. + $flush = $this->callRemoteService($site, 'flush_rewrite'); + if (empty($flush['success'])) { + Logger::warning( + "Remote flush_rewrite failed for {$site['url']}: " . ($flush['message'] ?? 'unknown'), + 'wordpress' + ); + } + + return $result; + } + + public function removeIndexPhpFromPermalinks(array $site): array + { + $status = $this->getPermalinkSettings($site); + if (empty($status['success'])) { + return $status; + } + + $current = (string) ($status['permalink_structure'] ?? ''); + if ($current === '') { + return $this->setPermalinkStructure($site, '/%postname%/'); + } + + $updated = preg_replace('#^/?index\.php/?#i', '/', $current); + $updated = is_string($updated) ? $updated : $current; + $updated = '/' . ltrim($updated, '/'); + $updated = preg_replace('#/+#', '/', $updated) ?? $updated; + + if ($updated === $current || !str_contains($current, 'index.php')) { + $this->ensureHtaccessForPrettyPermalinks($site); + $flush = $this->callRemoteService($site, 'flush_rewrite'); + if (empty($flush['success'])) { + Logger::warning( + "Remote flush_rewrite (no-index branch) failed for {$site['url']}: " . ($flush['message'] ?? 'unknown'), + 'wordpress' + ); + } + return [ + 'success' => true, + 'permalink_structure' => $current, + 'pretty_enabled' => true, + 'message' => 'Struktura permalink juz nie zawiera index.php. Odswiezono rewrite i .htaccess.', + ]; + } + + return $this->setPermalinkStructure($site, $updated); + } + + public function setPermalinkStructure(array $site, string $structure): array + { + $result = $this->callRemoteService($site, 'set_permalink', ['structure' => $structure]); + if (!empty($result['success'])) { + $this->ensureHtaccessForPrettyPermalinks($site); + return [ + 'success' => true, + 'message' => (string) ($result['message'] ?? 'Zmieniono strukturę permalink.'), + 'permalink_structure' => (string) ($result['permalink_structure'] ?? $structure), + 'pretty_enabled' => (bool) ($result['pretty_enabled'] ?? true), + ]; + } + + $ensure = $this->ensureRemoteService($site); + if (empty($ensure['success'])) { + return ['success' => false, 'message' => (string) ($ensure['message'] ?? 'Brak endpointu BackPRO na WordPress.')]; + } + + $refreshedSite = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site; + $retry = $this->callRemoteService($refreshedSite, 'set_permalink', ['structure' => $structure]); + + if (!empty($retry['success'])) { + $this->ensureHtaccessForPrettyPermalinks($refreshedSite); + return [ + 'success' => true, + 'message' => (string) ($retry['message'] ?? 'Zmieniono strukturę permalink.'), + 'permalink_structure' => (string) ($retry['permalink_structure'] ?? $structure), + 'pretty_enabled' => (bool) ($retry['pretty_enabled'] ?? true), + ]; + } + + return ['success' => false, 'message' => (string) ($retry['message'] ?? 'Blad zmiany permalink.')]; + } + + public function ensureRemoteService(array $site): array + { + $siteData = $this->prepareRemoteServiceMetadata($site); + $probe = $this->callRemoteService($siteData, 'ping'); + $remoteVersion = (string) ($probe['version'] ?? ''); + if (!empty($probe['success']) && $remoteVersion === self::BACKPRO_REMOTE_SERVICE_VERSION) { + return ['success' => true, 'message' => 'Remote service juz aktywny.']; + } + + if (!empty($probe['success'])) { + Logger::info( + "Remote service version mismatch for {$siteData['url']}: remote={$remoteVersion}, local=" . self::BACKPRO_REMOTE_SERVICE_VERSION, + 'wordpress' + ); + } + + return $this->installBackproRemoteService($siteData); + } + + public function getRemoteServiceStatus(array $site): array + { + $siteData = $this->prepareRemoteServiceMetadata($site); + $probe = $this->callRemoteService($siteData, 'ping'); + + return [ + 'success' => !empty($probe['success']), + 'local_version' => self::BACKPRO_REMOTE_SERVICE_VERSION, + 'remote_version' => (string) ($probe['version'] ?? 'brak'), + 'service_url' => $this->buildRemoteServiceUrl($siteData), + 'message' => (string) ($probe['message'] ?? 'Brak odpowiedzi pliku serwisowego.'), + ]; + } + + public function installBackproNewsTheme(array $site): array + { + $ftpHost = trim((string) ($site['ftp_host'] ?? '')); + $ftpUser = trim((string) ($site['ftp_user'] ?? '')); + $ftpPass = (string) ($site['ftp_pass'] ?? ''); + $ftpPort = (int) ($site['ftp_port'] ?? 21); + + if ($ftpHost === '' || $ftpUser === '' || $ftpPass === '') { + return ['success' => false, 'message' => 'Brak danych FTP. Uzupelnij je w ustawieniach strony.']; + } + + $themeSourceDir = dirname(__DIR__, 2) . '/' . self::BACKPRO_NEWS_THEME_SOURCE_DIR; + if (!is_dir($themeSourceDir)) { + return ['success' => false, 'message' => 'Brak lokalnych plikow motywu BackPRO News.']; + } + + $ftp = new FtpService($ftpHost, $ftpUser, $ftpPass, $ftpPort > 0 ? $ftpPort : 21); + + $basePath = trim((string) ($site['ftp_path'] ?? ''), "/ \t\n\r\0\x0B"); + $remoteThemesDir = ($basePath !== '' ? '/' . $basePath : '') . '/wp-content/themes'; + $remoteThemeDir = $remoteThemesDir . '/' . self::BACKPRO_NEWS_THEME_SLUG; + + try { + $ftp->connect(); + $ftp->ensureDirectory($remoteThemesDir); + $ftp->ensureDirectory($remoteThemeDir); + $ftp->deleteDirectoryContents($remoteThemeDir); + $ftp->uploadDirectory($themeSourceDir, $remoteThemeDir); + $ftp->disconnect(); + } catch (\Throwable $e) { + $ftp->disconnect(); + Logger::error("BackPRO News theme upload failed for {$site['url']}: " . $e->getMessage(), 'wordpress'); + return ['success' => false, 'message' => 'Nie udalo sie wgrac motywu: ' . $e->getMessage()]; + } + + $ensure = $this->ensureRemoteService($site); + if (empty($ensure['success'])) { + return ['success' => false, 'message' => 'Motyw wgrany, ale nie udalo sie przygotowac pliku serwisowego: ' . ($ensure['message'] ?? '')]; + } + + $siteData = !empty($site['id']) ? (Site::find((int) $site['id']) ?: $site) : $site; + $activate = $this->callRemoteService($siteData, 'activate_theme', [ + 'stylesheet' => self::BACKPRO_NEWS_THEME_SLUG, + ]); + + if (empty($activate['success'])) { + return ['success' => false, 'message' => 'Motyw wgrany, ale aktywacja nieudana: ' . ($activate['message'] ?? 'unknown')]; + } + + return [ + 'success' => true, + 'message' => 'Zainstalowano i aktywowano motyw BackPRO News. Sekcje kategorii na stronie glownej tworza sie automatycznie.', + ]; + } + + public function installBackproRemoteService(array $site): array + { + $ftpHost = trim((string) ($site['ftp_host'] ?? '')); + $ftpUser = trim((string) ($site['ftp_user'] ?? '')); + $ftpPass = (string) ($site['ftp_pass'] ?? ''); + $ftpPort = (int) ($site['ftp_port'] ?? 21); + + if ($ftpHost === '' || $ftpUser === '' || $ftpPass === '') { + return [ + 'success' => false, + 'message' => 'Brak danych FTP. Uzupelnij je w ustawieniach strony.', + ]; + } + + $siteData = $this->prepareRemoteServiceMetadata($site); + $serviceFile = (string) $siteData['remote_service_file']; + $serviceToken = (string) $siteData['remote_service_token']; + + $ftp = new FtpService($ftpHost, $ftpUser, $ftpPass, $ftpPort > 0 ? $ftpPort : 21); + $tmpFile = tempnam(sys_get_temp_dir(), 'backpro_remote_'); + + if ($tmpFile === false) { + return ['success' => false, 'message' => 'Nie udalo sie przygotowac pliku serwisowego.']; + } + + $basePath = trim((string) ($siteData['ftp_path'] ?? ''), "/ \t\n\r\0\x0B"); + $remoteDir = ($basePath !== '' ? '/' . $basePath : ''); + $remotePath = rtrim($remoteDir, '/') . '/' . $serviceFile; + + try { + file_put_contents($tmpFile, $this->getBackproRemoteServiceContent($serviceToken)); + $ftp->connect(); + $ftp->ensureDirectory($remoteDir === '' ? '/' : $remoteDir); + $ftp->uploadFile($tmpFile, $remotePath); + $ftp->disconnect(); + + if (!empty($siteData['id'])) { + try { + Site::update((int) $siteData['id'], [ + 'remote_service_file' => $serviceFile, + 'remote_service_token' => $serviceToken, + 'remote_service_installed_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + Logger::warning('Could not persist remote service installation metadata: ' . $e->getMessage(), 'wordpress'); + } + } + + Logger::info("BackPRO remote service uploaded to {$siteData['url']} ({$remotePath})", 'wordpress'); + return ['success' => true, 'message' => 'Zainstalowano plik serwisowy BackPRO na WordPress.']; + } catch (\Throwable $e) { + Logger::error("BackPRO remote service upload failed for {$siteData['url']}: " . $e->getMessage(), 'wordpress'); + return ['success' => false, 'message' => 'Nie udalo sie wgrac pliku serwisowego: ' . $e->getMessage()]; + } finally { + if (is_file($tmpFile)) { + @unlink($tmpFile); + } + } + } + + // ── XML-RPC fallback methods ────────────────────────────────────── + + private function createPostXmlRpc(array $site, array $auth, string $title, string $content, ?int $categoryId, ?int $mediaId): ?int + { + $fields = 'post_title' . $this->xmlEsc($title) . '' + . 'post_content' . $this->xmlEsc($content) . '' + . 'post_statuspublish'; + + if ($categoryId) { + $fields .= 'terms' + . 'category' + . '' . (int) $categoryId . '' + . '' + . ''; + } + + if ($mediaId) { + $fields .= 'post_thumbnail' . (int) $mediaId . ''; + } + + $xml = $this->xmlRpcEnvelope('wp.newPost', $auth, '' . $fields . ''); + + $result = $this->sendXmlRpc($site['url'], $xml); + if ($result === null) { + return null; + } + + $postId = (int) $result; + if ($postId <= 0) { + Logger::error("WP XML-RPC createPost returned invalid id for {$site['url']}.", 'wordpress'); + return null; + } + + Logger::info("WP XML-RPC createPost success for {$site['url']}: post_id={$postId}", 'wordpress'); + return $postId; + } + + private function uploadMediaXmlRpc(array $site, array $auth, string $imageData, string $filename): ?int + { + $fields = 'name' . $this->xmlEsc($filename) . '' + . 'type' . $this->getMimeType($filename) . '' + . 'bits' . base64_encode($imageData) . ''; + + $xml = $this->xmlRpcEnvelope('wp.uploadFile', $auth, '' . $fields . ''); + + $result = $this->sendXmlRpc($site['url'], $xml, 60); + if ($result === null) { + return null; + } + + // wp.uploadFile returns a struct; parse for 'id' or 'attachment_id'. + if (is_array($result)) { + $id = (int) ($result['id'] ?? $result['attachment_id'] ?? 0); + if ($id > 0) { + Logger::info("WP XML-RPC uploadFile success for {$site['url']}: media_id={$id}", 'wordpress'); + return $id; + } + } + + Logger::error("WP XML-RPC uploadFile returned unexpected response for {$site['url']}.", 'wordpress'); + return null; + } + + /** + * Send an XML-RPC request and return the parsed response value, or null on failure. + */ + private function sendXmlRpc(string $siteUrl, string $xml, int $timeout = 30): mixed + { + $url = rtrim($siteUrl, '/') . '/xmlrpc.php'; + + try { + $response = $this->client->request('POST', $url, [ + 'body' => $xml, + 'headers' => ['Content-Type' => 'text/xml; charset=UTF-8'], + 'timeout' => $timeout, + ]); + + $body = $response->getBody()->getContents(); + return $this->parseXmlRpcResponse($body, $siteUrl); + } catch (GuzzleException $e) { + Logger::error("WP XML-RPC request failed for {$siteUrl}: " . $e->getMessage(), 'wordpress'); + return null; + } + } + + private function xmlRpcEnvelope(string $method, array $auth, string $contentParam): string + { + return '' + . '' . $method . '' + . '1' + . '' . $this->xmlEsc($auth[0]) . '' + . '' . $this->xmlEsc($auth[1]) . '' + . $contentParam + . ''; + } + + private function parseXmlRpcResponse(string $body, string $siteUrl): mixed + { + try { + $prev = libxml_use_internal_errors(true); + $doc = new \SimpleXMLElement($body); + libxml_use_internal_errors($prev); + } catch (\Throwable $e) { + Logger::error("WP XML-RPC invalid XML from {$siteUrl}: " . $e->getMessage(), 'wordpress'); + return null; + } + + // Check for fault. + if (isset($doc->fault)) { + $members = $doc->fault->value->struct->member ?? []; + $faultString = 'Unknown'; + foreach ($members as $m) { + if ((string) $m->name === 'faultString') { + $faultString = (string) ($m->value->string ?? $m->value); + } + } + Logger::error("WP XML-RPC fault from {$siteUrl}: {$faultString}", 'wordpress'); + return null; + } + + // Extract return value. + $val = $doc->params->param->value ?? null; + if ($val === null) { + return null; + } + + return $this->xmlRpcValue($val); + } + + private function xmlRpcValue(\SimpleXMLElement $val): mixed + { + if (isset($val->string)) { + return (string) $val->string; + } + if (isset($val->int)) { + return (int) (string) $val->int; + } + if (isset($val->i4)) { + return (int) (string) $val->i4; + } + if (isset($val->boolean)) { + return (bool) (int) (string) $val->boolean; + } + if (isset($val->struct)) { + $result = []; + foreach ($val->struct->member as $m) { + $key = (string) $m->name; + $result[$key] = $this->xmlRpcValue($m->value); + } + return $result; + } + if (isset($val->array)) { + $result = []; + foreach ($val->array->data->value as $v) { + $result[] = $this->xmlRpcValue($v); + } + return $result; + } + + // Bare text — treat as string. + return trim((string) $val); + } + + private function xmlEsc(string $str): string + { + return htmlspecialchars($str, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + } + + // ── REST API transport layer ────────────────────────────────────── + + private function requestWp(array $site, string $method, string $route, array $options = []) + { + try { + return $this->requestWpWithOptions($site, $method, $route, $options); + } catch (RequestException $e) { + $status = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0; + $isRead = strtoupper($method) === 'GET'; + $hasAuth = isset($options['auth']); + + if ($isRead && $hasAuth && in_array($status, [401, 403], true)) { + $fallbackOptions = $options; + unset($fallbackOptions['auth']); + return $this->requestWpWithOptions($site, $method, $route, $fallbackOptions); + } + + throw $e; + } + } + + private function requestWpWithOptions(array $site, string $method, string $route, array $options = []) + { + // 1. Try standard /wp-json/ URL. + try { + return $this->client->request($method, $this->buildWpJsonUrl($site, $route), $options); + } catch (RequestException $e) { + $status = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0; + if ($status !== 404) { + throw $e; + } + } + + // 2. Try /index.php/wp-json/ (PATHINFO-based URL). + // Works on many hosts without pretty permalinks where /wp-json/ returns 404. + // Some hosts ignore PATH_INFO and return the homepage HTML – detect via Content-Type. + $pathInfoUrl = rtrim($site['url'], '/') . '/index.php/wp-json/' . ltrim($route, '/'); + try { + $response = $this->client->request($method, $pathInfoUrl, $options); + $ct = $response->getHeaderLine('Content-Type'); + if (stripos($ct, 'json') !== false) { + return $response; + } + // Non-JSON (HTML page) – host ignores PATH_INFO, try next fallback + } catch (RequestException $e) { + $status = $e->hasResponse() ? (int) $e->getResponse()->getStatusCode() : 0; + if ($status !== 404) { + throw $e; + } + } + + // 3. Fall back to ?rest_route= parameter. + // Build URLs with literal slashes (avoid Guzzle encoding / as %2F). + $extraQuery = $options['query'] ?? []; + if (!is_array($extraQuery)) { + $extraQuery = []; + } + $fallbackOptions = $options; + unset($fallbackOptions['query']); + + $rootUrl = $this->buildRestRouteUrl($site['url'], $route, $extraQuery, false); + $indexUrl = $this->buildRestRouteUrl($site['url'], $route, $extraQuery, true); + + if (strtoupper($method) !== 'GET') { + // For writes, try /index.php?rest_route= first (no redirects). + // Some hosts return rest_post_exists on /?rest_route= because the WP + // main query resolves the homepage ID before the REST handler runs. + try { + $noRedirectOpts = $fallbackOptions; + $noRedirectOpts['allow_redirects'] = false; + $response = $this->client->request($method, $indexUrl, $noRedirectOpts); + $statusCode = $response->getStatusCode(); + if ($statusCode < 300 && $this->isRestApiResponse($response)) { + return $response; + } + // Non-REST response or redirect – fall through to root URL + } catch (RequestException $e) { + // /index.php?rest_route= failed – fall through to root URL + } + + return $this->client->request($method, $rootUrl, $fallbackOptions); + } + + // For reads, try root URL first, then /index.php + try { + return $this->client->request($method, $rootUrl, $fallbackOptions); + } catch (RequestException $rootError) { + try { + return $this->client->request($method, $indexUrl, $fallbackOptions); + } catch (RequestException $e) { + throw $rootError; + } + } + } + + /** + * Check if a response looks like a WordPress REST API response (not a page render). + * Reads and rewinds the body stream for further consumption by the caller. + */ + private function isRestApiResponse($response): bool + { + $contentType = $response->getHeaderLine('Content-Type'); + if (stripos($contentType, 'json') === false) { + return false; + } + + $body = (string) $response->getBody(); + $data = json_decode($body, true); + + if ($response->getBody()->isSeekable()) { + $response->getBody()->rewind(); + } + + // A valid REST API write response has 'id', an error has 'code'+'message'. + // The REST API discovery/root response has 'namespaces' – that is NOT a write result. + if (!is_array($data)) { + return false; + } + if (isset($data['id']) || (isset($data['code']) && isset($data['message']))) { + return true; + } + + return false; + } + + private function buildWpJsonUrl(array $site, string $route): string + { + return rtrim($site['url'], '/') . '/wp-json/' . ltrim($route, '/'); + } + + private function buildAuthOption(array $site): ?array + { + $user = trim((string) ($site['api_user'] ?? '')); + $token = trim((string) ($site['api_token'] ?? '')); + $token = preg_replace('/\s+/', '', $token) ?? $token; + + if ($user === '' || $token === '') { + return null; + } + + return [$user, $token]; + } + + private function requireAuthOption(array $site, string $operation): ?array + { + $auth = $this->buildAuthOption($site); + if ($auth !== null) { + return $auth; + } + + Logger::error( + "WP {$operation} skipped for {$site['url']}: missing api_user/api_token in site settings.", + 'wordpress' + ); + return null; + } + + private function buildRestRouteUrl(string $siteUrl, string $route, array $extraQuery = [], bool $useIndexPhp = false): string + { + $base = rtrim($siteUrl, '/'); + $base .= $useIndexPhp ? '/index.php' : '/'; + + // Use literal slashes in rest_route to avoid %2F encoding issues on some hosts. + $restRoute = '/' . ltrim($route, '/'); + $parts = ['rest_route=' . $restRoute]; + + foreach ($extraQuery as $key => $value) { + $parts[] = urlencode((string) $key) . '=' . urlencode((string) $value); + } + + return $base . '?' . implode('&', $parts); + } + private function getMimeType(string $filename): string { $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); @@ -177,4 +933,332 @@ class WordPressService default => 'image/jpeg', }; } + + private function prepareRemoteServiceMetadata(array $site): array + { + $data = $site; + $token = trim((string) ($data['remote_service_token'] ?? '')); + $file = trim((string) ($data['remote_service_file'] ?? '')); + + $changed = false; + if ($token === '') { + $base = (string) ($data['url'] ?? '') . '|' . (string) ($data['api_token'] ?? '') . '|backpro-remote-service'; + $token = substr(hash('sha256', $base), 0, 48); + if ($token === '') { + $token = bin2hex(random_bytes(24)); + } + $data['remote_service_token'] = $token; + $changed = true; + } + + if ($file === '') { + $file = self::BACKPRO_REMOTE_SERVICE_FILENAME; + $data['remote_service_file'] = $file; + $changed = true; + } + + if ($changed && !empty($data['id'])) { + try { + Site::update((int) $data['id'], [ + 'remote_service_token' => $token, + 'remote_service_file' => $file, + ]); + } catch (\Throwable $e) { + Logger::warning('Could not persist remote service metadata: ' . $e->getMessage(), 'wordpress'); + } + } + + return $data; + } + + private function buildRemoteServiceUrl(array $site): string + { + $siteData = $this->prepareRemoteServiceMetadata($site); + $file = (string) $siteData['remote_service_file']; + return rtrim((string) $siteData['url'], '/') . '/' . ltrim($file, '/'); + } + + private function callRemoteService(array $site, string $action, array $params = []): array + { + $siteData = $this->prepareRemoteServiceMetadata($site); + $url = $this->buildRemoteServiceUrl($siteData); + $token = (string) $siteData['remote_service_token']; + + if ($token === '') { + return ['success' => false, 'message' => 'Brak tokenu pliku serwisowego BackPRO.']; + } + + try { + $response = $this->client->post($url, [ + 'timeout' => 20, + 'headers' => ['User-Agent' => 'BackPRO/1.0 Remote-Service'], + 'form_params' => array_merge([ + 'token' => $token, + 'action' => $action, + ], $params), + ]); + + $data = json_decode($response->getBody()->getContents(), true); + if (!is_array($data)) { + return ['success' => false, 'message' => 'Nieprawidlowa odpowiedz pliku serwisowego BackPRO.']; + } + + return $data; + } catch (\Throwable $e) { + Logger::warning( + "Remote service call failed ({$action}) for {$siteData['url']}: " . $e->getMessage(), + 'wordpress' + ); + return ['success' => false, 'message' => 'Blad komunikacji z plikiem serwisowym: ' . $e->getMessage()]; + } + } + + private function getBackproRemoteServiceContent(string $token): string + { + $safeToken = addslashes($token); + + return << false, 'message' => 'method_not_allowed']); + exit; +} + +\$expectedToken = '{$safeToken}'; +\$providedToken = (string) (\$_POST['token'] ?? ''); +if (!hash_equals(\$expectedToken, \$providedToken)) { + http_response_code(403); + echo json_encode(['success' => false, 'message' => 'forbidden']); + exit; +} + +if (!defined('ABSPATH')) { + \$wpLoad = __DIR__ . '/wp-load.php'; + if (!file_exists(\$wpLoad)) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => 'wp_load_not_found']); + exit; + } + require_once \$wpLoad; +} + +\$action = (string) (\$_POST['action'] ?? ''); +if (\$action === 'ping') { + echo json_encode(['success' => true, 'message' => 'pong', 'version' => '1.3.0']); + exit; +} + +if (\$action === 'get_permalink') { + \$structure = (string) get_option('permalink_structure', ''); + echo json_encode([ + 'success' => true, + 'permalink_structure' => \$structure, + 'pretty_enabled' => trim(\$structure) !== '', + 'message' => 'OK', + ]); + exit; +} + +if (\$action === 'set_permalink') { + \$structure = trim((string) (\$_POST['structure'] ?? '')); + if (\$structure === '') { + http_response_code(422); + echo json_encode(['success' => false, 'message' => 'missing_structure']); + exit; + } + + update_option('permalink_structure', \$structure); + // Hard flush rewrites to update .htaccess when possible. + flush_rewrite_rules(true); + \$applied = (string) get_option('permalink_structure', ''); + + echo json_encode([ + 'success' => true, + 'permalink_structure' => \$applied, + 'pretty_enabled' => trim(\$applied) !== '', + 'message' => 'Permalinki zaktualizowane.', + ]); + exit; +} + +if (\$action === 'flush_rewrite') { + flush_rewrite_rules(true); + echo json_encode([ + 'success' => true, + 'message' => 'Rewrite rules odswiezone.', + ]); + exit; +} + +if (\$action === 'activate_theme') { + \$stylesheet = sanitize_key((string) (\$_POST['stylesheet'] ?? '')); + if (\$stylesheet === '') { + http_response_code(422); + echo json_encode(['success' => false, 'message' => 'missing_stylesheet']); + exit; + } + + \$theme = wp_get_theme(\$stylesheet); + if (!\$theme->exists()) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => 'theme_not_found']); + exit; + } + + switch_theme(\$stylesheet); + echo json_encode([ + 'success' => true, + 'message' => 'Motyw aktywowany.', + 'active_stylesheet' => get_stylesheet(), + ]); + exit; +} + +if (\$action === 'cleanup') { + @unlink(__FILE__); + echo json_encode(['success' => true, 'message' => 'service_deleted']); + exit; +} + +http_response_code(400); +echo json_encode(['success' => false, 'message' => 'invalid_action']); +PHP; + } + + private function ensureHtaccessForPrettyPermalinks(array $site): void + { + $ftpHost = trim((string) ($site['ftp_host'] ?? '')); + $ftpUser = trim((string) ($site['ftp_user'] ?? '')); + $ftpPass = (string) ($site['ftp_pass'] ?? ''); + $ftpPort = (int) ($site['ftp_port'] ?? 21); + + if ($ftpHost === '' || $ftpUser === '' || $ftpPass === '') { + return; + } + + $ftp = new FtpService($ftpHost, $ftpUser, $ftpPass, $ftpPort > 0 ? $ftpPort : 21); + $tmpIn = tempnam(sys_get_temp_dir(), 'backpro_hta_in_'); + $tmpOut = tempnam(sys_get_temp_dir(), 'backpro_hta_out_'); + if ($tmpIn === false || $tmpOut === false) { + return; + } + + $basePath = trim((string) ($site['ftp_path'] ?? ''), "/ \t\n\r\0\x0B"); + $remoteDir = ($basePath !== '' ? '/' . $basePath : ''); + $remoteHtaccess = rtrim($remoteDir, '/') . '/.htaccess'; + + try { + $ftp->connect(); + $existing = ''; + if ($ftp->downloadFile($remoteHtaccess, $tmpIn) && is_file($tmpIn)) { + $existing = (string) file_get_contents($tmpIn); + } + + $updated = $this->injectWordPressHtaccessBlock($existing); + file_put_contents($tmpOut, $updated); + $ftp->uploadFile($tmpOut, $remoteHtaccess); + + Logger::info("Ensured .htaccess WordPress rewrite rules for {$site['url']}", 'wordpress'); + } catch (\Throwable $e) { + Logger::warning("Could not update .htaccess for {$site['url']}: " . $e->getMessage(), 'wordpress'); + } finally { + $ftp->disconnect(); + @unlink($tmpIn); + @unlink($tmpOut); + } + } + + private function injectWordPressHtaccessBlock(string $content): string + { + $block = "# BEGIN WordPress\n" + . "\n" + . "RewriteEngine On\n" + . "RewriteBase /\n" + . "RewriteRule ^index\\.php$ - [L]\n" + . "RewriteCond %{REQUEST_FILENAME} !-f\n" + . "RewriteCond %{REQUEST_FILENAME} !-d\n" + . "RewriteRule . /index.php [L]\n" + . "\n" + . "# END WordPress"; + + $trimmed = trim($content); + if ($trimmed === '') { + return $block . "\n"; + } + + $pattern = '/# BEGIN WordPress.*?# END WordPress/s'; + if (preg_match($pattern, $content) === 1) { + return (string) preg_replace($pattern, $block, $content); + } + + return rtrim($content) . "\n\n" . $block . "\n"; + } + + private function getBackproMuPluginContent(): string + { + return <<<'PHP' + 'GET', + 'callback' => function (): \WP_REST_Response { + $structure = (string) get_option('permalink_structure', ''); + $pretty = trim($structure) !== ''; + + return new \WP_REST_Response([ + 'success' => true, + 'source' => 'backpro_mu_plugin', + 'permalink_structure' => $structure, + 'pretty_enabled' => $pretty, + 'message' => $pretty ? 'Przyjazne linki sa wlaczone.' : 'Przyjazne linki sa wylaczone.', + ], 200); + }, + 'permission_callback' => function (): bool { + return current_user_can('manage_options'); + }, + ]); + + register_rest_route('backpro/v1', '/permalinks', [ + 'methods' => 'POST', + 'callback' => function (\WP_REST_Request $request): \WP_REST_Response { + $structure = (string) $request->get_param('structure'); + $structure = trim($structure); + + if ($structure === '') { + return new \WP_REST_Response([ + 'success' => false, + 'message' => 'Brak struktury permalink.', + ], 400); + } + + update_option('permalink_structure', $structure); + flush_rewrite_rules(false); + + return new \WP_REST_Response([ + 'success' => true, + 'permalink_structure' => (string) get_option('permalink_structure', ''), + 'pretty_enabled' => true, + 'message' => 'Zmieniono ustawienie permalink i odswiezono rewrite rules.', + ], 200); + }, + 'permission_callback' => function (): bool { + return current_user_can('manage_options'); + }, + ]); +}); +PHP; + } } diff --git a/storage/logs/image_2026-02-17.log b/storage/logs/image_2026-02-17.log new file mode 100644 index 0000000..70aae79 --- /dev/null +++ b/storage/logs/image_2026-02-17.log @@ -0,0 +1 @@ +[2026-02-17 18:27:04] INFO: Replaced image for article 47: old=, new=6 diff --git a/storage/logs/installer_2026-02-17.log b/storage/logs/installer_2026-02-17.log new file mode 100644 index 0000000..159649a --- /dev/null +++ b/storage/logs/installer_2026-02-17.log @@ -0,0 +1,59 @@ +[2026-02-17 18:12:39] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:12:39] INFO: FTP disconnected +[2026-02-17 18:21:55] WARNING: Starting WordPress reinstall for site_id=3, url=https://ladek-zdroj.com.pl +[2026-02-17 18:21:55] INFO: Downloading WordPress (pl_PL) +[2026-02-17 18:22:08] INFO: Downloaded WordPress (34.5 MB) +[2026-02-17 18:22:08] INFO: Extracting WordPress ZIP +[2026-02-17 18:22:09] INFO: Extracted to /tmp/backpro_wp_6994a3b337243/extracted/wordpress +[2026-02-17 18:22:09] INFO: Generating wp-config.php +[2026-02-17 18:22:09] INFO: wp-config.php generated +[2026-02-17 18:22:09] WARNING: Clearing FTP path /public_html on host117523.hostido.net.pl +[2026-02-17 18:22:09] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:22:13] INFO: FTP disconnected +[2026-02-17 18:22:13] WARNING: Clearing database host117523_ladek_zdroj_compl on localhost +[2026-02-17 18:22:13] ERROR: Reinstallation failed for site_id=3: SQLSTATE[HY000] [1045] Access denied for user 'host117523_ladek_zdroj_compl'@'localhost' (using password: YES) +[2026-02-17 18:22:13] INFO: Cleaned up temp directory +[2026-02-17 18:22:44] WARNING: Starting WordPress reinstall for site_id=3, url=https://ladek-zdroj.com.pl +[2026-02-17 18:22:44] INFO: Downloading WordPress (pl_PL) +[2026-02-17 18:22:47] INFO: Downloaded WordPress (34.5 MB) +[2026-02-17 18:22:47] INFO: Extracting WordPress ZIP +[2026-02-17 18:22:47] INFO: Extracted to /tmp/backpro_wp_6994a3e42830d/extracted/wordpress +[2026-02-17 18:22:47] INFO: Generating wp-config.php +[2026-02-17 18:22:48] INFO: wp-config.php generated +[2026-02-17 18:22:48] WARNING: Clearing FTP path /public_html on host117523.hostido.net.pl +[2026-02-17 18:22:48] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:22:48] INFO: FTP disconnected +[2026-02-17 18:22:48] WARNING: Clearing database host117523_ladek_zdroj_compl on localhost +[2026-02-17 18:22:48] ERROR: Reinstallation failed for site_id=3: SQLSTATE[HY000] [1045] Access denied for user 'host117523_ladek_zdroj_compl'@'localhost' (using password: YES) +[2026-02-17 18:22:48] INFO: Cleaned up temp directory +[2026-02-17 18:25:28] WARNING: Starting WordPress reinstall for site_id=3, url=https://ladek-zdroj.com.pl +[2026-02-17 18:25:28] INFO: Downloading WordPress (pl_PL) +[2026-02-17 18:25:42] INFO: Downloaded WordPress (34.5 MB) +[2026-02-17 18:25:42] INFO: Extracting WordPress ZIP +[2026-02-17 18:25:42] INFO: Extracted to /tmp/backpro_wp_6994a48838d06/extracted/wordpress +[2026-02-17 18:25:42] INFO: Generating wp-config.php +[2026-02-17 18:25:42] INFO: wp-config.php generated +[2026-02-17 18:25:42] WARNING: Clearing FTP path /public_html on host117523.hostido.net.pl +[2026-02-17 18:25:42] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:25:42] INFO: FTP disconnected +[2026-02-17 18:25:42] WARNING: Clearing database host117523_ladek_zdroj_compl on localhost +[2026-02-17 18:25:42] WARNING: Direct DB cleanup failed, trying remote service fallback: SQLSTATE[HY000] [1045] Access denied for user 'host117523_ladek_zdroj_compl'@'localhost' (using password: YES) +[2026-02-17 18:25:42] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:25:42] INFO: FTP disconnected +[2026-02-17 18:25:43] INFO: Database cleaned through remote service endpoint. +[2026-02-17 18:25:43] INFO: Starting FTP upload to host117523.hostido.net.pl:/public_html +[2026-02-17 18:25:43] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:25:46] INFO: FTP upload completed +[2026-02-17 18:25:46] INFO: FTP disconnected +[2026-02-17 18:25:46] INFO: Triggering WordPress installation at https://ladek-zdroj.com.pl +[2026-02-17 18:25:47] INFO: WordPress installation triggered successfully +[2026-02-17 18:25:47] INFO: Creating Application Password via WP cookie auth +[2026-02-17 18:25:50] INFO: Logging in to WordPress admin +[2026-02-17 18:25:51] INFO: Fetching REST API nonce +[2026-02-17 18:25:51] INFO: Got REST nonce, creating Application Password +[2026-02-17 18:25:51] INFO: Application Password created successfully +[2026-02-17 18:25:51] INFO: Cleaned up temp directory +[2026-02-17 18:37:31] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:37:31] INFO: FTP disconnected +[2026-02-17 18:40:46] INFO: FTP connected to host117523.hostido.net.pl +[2026-02-17 18:40:46] INFO: FTP disconnected diff --git a/storage/logs/openai_2026-02-17.log b/storage/logs/openai_2026-02-17.log new file mode 100644 index 0000000..a38cd09 --- /dev/null +++ b/storage/logs/openai_2026-02-17.log @@ -0,0 +1 @@ +[2026-02-17 17:54:58] INFO: Generated article: Od Zielonej Murawy po Wielkie Stadiony: Współczesne Oblicza Futbolu diff --git a/storage/logs/publish_2026-02-17.log b/storage/logs/publish_2026-02-17.log new file mode 100644 index 0000000..98a11eb --- /dev/null +++ b/storage/logs/publish_2026-02-17.log @@ -0,0 +1,31 @@ +[2026-02-17 17:54:18] INFO: Rozpoczynam automatyczną publikację +[2026-02-17 17:54:18] INFO: Publikacja dla strony: Lądek Zdrój - lokalny portal informacyjny (ID: 3) +[2026-02-17 17:54:18] INFO: Wybrany temat: Piłka nożna (ID: 14) +[2026-02-17 17:54:58] INFO: Wygenerowano artykuł: Od Zielonej Murawy po Wielkie Stadiony: Współczesne Oblicza Futbolu +[2026-02-17 17:54:59] INFO: Upload obrazka: media_id=5 +[2026-02-17 17:54:59] INFO: Opublikowano post: wp_post_id=6 +[2026-02-17 17:54:59] INFO: Opublikowano: "Od Zielonej Murawy po Wielkie Stadiony: Współczesne Oblicza Futbolu" na Lądek Zdrój - lokalny portal informacyjny +[2026-02-17 18:00:29] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:00:29] INFO: Brak stron do publikacji +[2026-02-17 18:05:34] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:05:34] INFO: Brak stron do publikacji +[2026-02-17 18:10:33] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:10:33] INFO: Brak stron do publikacji +[2026-02-17 18:15:34] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:15:34] INFO: Brak stron do publikacji +[2026-02-17 18:20:33] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:20:33] INFO: Brak stron do publikacji +[2026-02-17 18:25:34] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:25:34] INFO: Brak stron do publikacji +[2026-02-17 18:30:33] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:30:33] INFO: Publikacja dla strony: Lądek Zdrój - lokalny portal informacyjny (ID: 3) +[2026-02-17 18:30:33] INFO: Ponowna proba publikacji artykulu ID 22: Zrównoważone podejście do zdrowia: klucz do długowieczności +[2026-02-17 18:35:33] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:35:33] INFO: Publikacja dla strony: Lądek Zdrój - lokalny portal informacyjny (ID: 3) +[2026-02-17 18:35:33] INFO: Ponowna proba publikacji artykulu ID 22: Zrównoważone podejście do zdrowia: klucz do długowieczności +[2026-02-17 18:40:34] INFO: Rozpoczynam automatyczna publikacje +[2026-02-17 18:40:34] INFO: Publikacja dla strony: Lądek Zdrój - lokalny portal informacyjny (ID: 3) +[2026-02-17 18:40:34] INFO: Ponowna proba publikacji artykulu ID 22: Zrównoważone podejście do zdrowia: klucz do długowieczności +[2026-02-17 18:40:35] INFO: Upload obrazka: media_id=8 +[2026-02-17 18:40:35] INFO: Opublikowano post: wp_post_id=9 +[2026-02-17 18:40:35] INFO: Opublikowano: "Zrównoważone podejście do zdrowia: klucz do długowieczności" na Lądek Zdrój - lokalny portal informacyjny diff --git a/storage/logs/wordpress_2026-02-17.log b/storage/logs/wordpress_2026-02-17.log new file mode 100644 index 0000000..17143ce --- /dev/null +++ b/storage/logs/wordpress_2026-02-17.log @@ -0,0 +1,12 @@ +[2026-02-17 17:54:59] WARNING: WP REST uploadMedia failed for https://ladek-zdroj.com.pl, trying XML-RPC: Client error: `POST https://ladek-zdroj.com.pl/?rest_route=/wp/v2/media` resulted in a `400 Bad Request` response: +{"code":"rest_post_exists","message":"Nie mo\u017cna stworzy\u0107 ju\u017c istniej\u0105cego wpisu.","data":{"status":4 (truncated...) + +[2026-02-17 17:54:59] INFO: WP XML-RPC uploadFile success for https://ladek-zdroj.com.pl: media_id=5 +[2026-02-17 17:54:59] WARNING: WP REST createPost failed for https://ladek-zdroj.com.pl, trying XML-RPC: Client error: `POST https://ladek-zdroj.com.pl/?rest_route=/wp/v2/posts` resulted in a `400 Bad Request` response: +{"code":"rest_post_exists","message":"Nie mo\u017cna stworzy\u0107 ju\u017c istniej\u0105cego wpisu.","data":{"status":4 (truncated...) + +[2026-02-17 17:54:59] INFO: WP XML-RPC createPost success for https://ladek-zdroj.com.pl: post_id=6 +[2026-02-17 18:12:39] INFO: BackPRO MU plugin uploaded to https://ladek-zdroj.com.pl (/public_html/wp-content/mu-plugins/backpro-remote-tools.php) +[2026-02-17 18:37:31] INFO: BackPRO remote service uploaded to https://ladek-zdroj.com.pl (/public_html/backpro-remote-service.php) +[2026-02-17 18:40:46] INFO: BackPRO remote service uploaded to https://ladek-zdroj.com.pl (/public_html/backpro-remote-service.php) +[2026-02-17 18:44:28] INFO: BackPRO remote service uploaded to https://ladek-zdroj.com.pl (/public_html/backpro-remote-service.php) diff --git a/templates/articles/index.php b/templates/articles/index.php index f5cc15c..ebf75c4 100644 --- a/templates/articles/index.php +++ b/templates/articles/index.php @@ -1,5 +1,29 @@ -
-

Artykuły ()

+
+

Artykuły ()

+
+
+ + + + + Wyczyść + +
+ + +
+ + +
+ +
@@ -8,7 +32,7 @@ # - Tytuł + Tytuł Strona Temat Status @@ -18,7 +42,7 @@ - Brak artykułów + Brak artykułów @@ -34,16 +58,31 @@ Opublikowany - Błąd + Błąd Wygenerowany + 0): ?> +
+ Retry: + + () + +
+ - - - +
+ + + +
+ +
+
@@ -57,10 +96,18 @@ + + diff --git a/templates/articles/show.php b/templates/articles/show.php index be7a8cb..1272d68 100644 --- a/templates/articles/show.php +++ b/templates/articles/show.php @@ -6,6 +6,12 @@ + 0): ?> + Retry: + + + Ostatnia proba: + Opublikowany @@ -60,15 +66,24 @@ + diff --git a/templates/categories/index.php b/templates/categories/index.php index e5bdc15..5b796c0 100644 --- a/templates/categories/index.php +++ b/templates/categories/index.php @@ -232,8 +232,12 @@ function createCategory(e) { return false; } -function createFromTopics() { - if (!confirm('Utworzyć kategorie w WordPress na podstawie przypisanych tematyk?\n\nIstniejące kategorie nie będą duplikowane.')) return; +async function createFromTopics() { + var ok = await backproConfirm( + 'Utworzyc kategorie w WordPress na podstawie przypisanych tematyk? Istniejace kategorie nie beda duplikowane.', + { title: 'Tworzenie kategorii', confirmText: 'Utworz', cancelText: 'Anuluj', confirmClass: 'btn-success' } + ); + if (!ok) return; var btn = document.getElementById('btnFromTopics'); var resultAlert = document.getElementById('resultAlert'); @@ -273,7 +277,7 @@ function createFromTopics() { .catch(function() { btn.disabled = false; btn.innerHTML = 'Utwórz kategorie z tematyk'; - alert('Błąd połączenia'); + backproNotify('Blad polaczenia', 'danger'); }); } @@ -283,3 +287,4 @@ function escapeHtml(text) { return div.innerHTML; } + diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php index 753a431..6dbf065 100644 --- a/templates/dashboard/index.php +++ b/templates/dashboard/index.php @@ -128,11 +128,20 @@
+ + + diff --git a/templates/sites/edit.php b/templates/sites/edit.php index 036d20b..14efe03 100644 --- a/templates/sites/edit.php +++ b/templates/sites/edit.php @@ -1,8 +1,11 @@

Edytuj stronę:

-
-
@@ -54,7 +57,6 @@
-
@@ -71,7 +73,6 @@
-
FTP @@ -102,7 +103,6 @@
-
Baza danych @@ -133,7 +133,6 @@
-
Panel administratora @@ -172,7 +171,6 @@
-
Tematy ()
@@ -217,7 +215,6 @@
-
Dodaj temat z biblioteki
@@ -259,11 +256,17 @@ function quickFillTopic(select) { document.getElementById('quick_topic_desc').value = opt.dataset.desc || ''; } -document.querySelectorAll('.btn-delete-topic').forEach(function(btn) { - btn.addEventListener('click', function() { +document.querySelectorAll('.btn-delete-topic').forEach(function (btn) { + btn.addEventListener('click', async function () { var id = this.dataset.id; var name = this.dataset.name; - if (!confirm('Usunąć temat "' + name + '"?')) return; + var ok = await backproConfirm('Usunac temat "' + name + '"?', { + title: 'Usuwanie tematu', + confirmText: 'Usun', + cancelText: 'Anuluj', + confirmClass: 'btn-danger' + }); + if (!ok) return; var row = this.closest('tr'); btn.disabled = true; @@ -272,21 +275,24 @@ document.querySelectorAll('.btn-delete-topic').forEach(function(btn) { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) - .then(function(r) { return r.json(); }) - .then(function(data) { + .then(function (r) { return r.json(); }) + .then(function (data) { if (data.success) { row.style.transition = 'opacity .3s'; row.style.opacity = '0'; - setTimeout(function() { row.remove(); }, 300); + setTimeout(function () { row.remove(); }, 300); + backproNotify('Temat zostal usuniety.', 'success', { delay: 2500 }); } else { - alert(data.message || 'Błąd usuwania'); + backproNotify(data.message || 'Blad usuwania', 'danger'); btn.disabled = false; } }) - .catch(function() { - alert('Błąd połączenia'); + .catch(function () { + backproNotify('Blad polaczenia', 'danger'); btn.disabled = false; }); }); }); + + diff --git a/templates/sites/index.php b/templates/sites/index.php index 639a817..7d89d6c 100644 --- a/templates/sites/index.php +++ b/templates/sites/index.php @@ -1,7 +1,7 @@ @@ -13,7 +13,8 @@ Nazwa URL Tematy - Interwał + Opublikowane + Interwał Ostatnia publikacja Status Akcje @@ -21,7 +22,7 @@ - Brak stron. Dodaj pierwszą stronę WordPress. + Brak stron. Dodaj pierwszą stronę WordPress. @@ -34,7 +35,12 @@ - tematów + tematów + + + + + opublikowanych co h @@ -51,17 +57,20 @@ + + + - -
-
@@ -74,3 +83,4 @@
+ diff --git a/templates/topics/index.php b/templates/topics/index.php index 217da67..0189a82 100644 --- a/templates/topics/index.php +++ b/templates/topics/index.php @@ -159,11 +159,17 @@ function fillFromLibrary(select) { } } -document.querySelectorAll('.btn-delete-topic').forEach(function(btn) { - btn.addEventListener('click', function() { +document.querySelectorAll('.btn-delete-topic').forEach(function (btn) { + btn.addEventListener('click', async function () { var id = this.dataset.id; var name = this.dataset.name; - if (!confirm('Na pewno usunąć temat "' + name + '"?')) return; + var ok = await backproConfirm('Na pewno usunac temat "' + name + '"?', { + title: 'Usuwanie tematu', + confirmText: 'Usun', + cancelText: 'Anuluj', + confirmClass: 'btn-danger' + }); + if (!ok) return; var row = this.closest('tr'); btn.disabled = true; @@ -173,26 +179,27 @@ document.querySelectorAll('.btn-delete-topic').forEach(function(btn) { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) - .then(function(r) { return r.json(); }) - .then(function(data) { + .then(function (r) { return r.json(); }) + .then(function (data) { if (data.success) { row.style.transition = 'opacity .3s'; row.style.opacity = '0'; - setTimeout(function() { + setTimeout(function () { row.remove(); var tbody = document.querySelector('table tbody'); - if (!tbody.querySelector('tr')) { - tbody.innerHTML = 'Brak tematów. Dodaj tematy z biblioteki lub utwórz własny.'; + if (tbody && !tbody.querySelector('tr')) { + tbody.innerHTML = 'Brak tematow. Dodaj tematy z biblioteki lub utworz wlasny.'; } }, 300); + backproNotify('Temat zostal usuniety.', 'success', { delay: 2500 }); } else { - alert(data.message || 'Błąd usuwania'); + backproNotify(data.message || 'Blad usuwania', 'danger'); btn.disabled = false; btn.innerHTML = ''; } }) - .catch(function() { - alert('Błąd połączenia'); + .catch(function () { + backproNotify('Blad polaczenia', 'danger'); btn.disabled = false; btn.innerHTML = ''; });