Compare commits

...

11 Commits

Author SHA1 Message Date
Matt Batchelder
3954d58e8a Update Oribi Tech Sync binary distribution 2026-02-23 19:35:46 -05:00
Matt Batchelder
cdf176e224 Add force pull functionality and improve post content handling 2026-02-23 19:35:33 -05:00
Matt Batchelder
3b51382797 Enhance Gitea support by improving base64 decoding and UTF-8 encoding handling 2026-02-21 20:08:18 -05:00
Matt Batchelder
634e93236f Enhance Gitea support and ensure UTF-8 encoding in API requests 2026-02-21 19:02:26 -05:00
Matt Batchelder
6c5e503eb2 Remove REST API endpoints and related functionality from Oribi Sync 2026-02-21 13:07:41 -05:00
Matt Batchelder
b01e7e0e88 Implement AJAX handler for admin bar pull button and remove REST API endpoint for pulling pages 2026-02-21 12:21:55 -05:00
Matt Batchelder
158fb53d24 Add "Pull Page" functionality to admin bar and REST API endpoint 2026-02-21 12:19:19 -05:00
Matt Batchelder
d56d46490a Add post synchronization functionality for Markdown files
- Implemented a parser for YAML front-matter in Markdown files.
- Developed functions to convert Markdown content to HTML.
- Created a pipeline to sync WordPress posts from a specified folder in a Git repository.
- Added media import capabilities to handle images referenced in Markdown.
- Implemented author resolution and post slug generation.
- Included error handling and logging for sync operations.
- Enabled trashing of posts that are no longer present in the repository.
2026-02-21 10:44:34 -05:00
Matt Batchelder
3c8c38acde Refactor Oribi Sync settings: remove pages folder option and enhance case-insensitive directory handling 2026-02-20 22:15:46 -05:00
Matt Batchelder
9e93ca27b4 Add pull functionality to Oribi Sync with user confirmation 2026-02-20 21:09:14 -05:00
Matt Batchelder
d2228ed0fb Add REST API endpoints for repo folder listing and page pushing
- Implemented `GET /repo-folders` to list available sub-folders in the configured repository.
- Added `POST /push` to push a single page to the repository.
- Introduced `POST /push-all` to push all synced pages back to the repository.
- Enhanced `oribi_sync_rest_sync` to push local changes after pulling, except during dry runs.
- Created `oribi_sync_push_page` and `oribi_sync_push_all` functions to handle page pushing logic.
- Updated post meta on successful pushes to track last push time and SHA.
- Added logging for push actions and errors.

Enhance sync engine to support theme file synchronization

- Added functionality to auto-apply changed theme files from the repository's theme directory.
- Created `oribi_sync_apply_theme_files` to handle theme file updates during sync.
- Ensured the existence of a minimal theme structure in the `ots-theme` directory.

Refactor uninstall process to clean up additional post meta

- Updated `uninstall.php` to remove new post meta related to push operations.
- Ensured comprehensive cleanup of options and metadata upon plugin uninstallation.

Introduce push client for handling page pushes to Gitea

- Created `push-client.php` to encapsulate logic for pushing pages back to the Git repository.
- Implemented conflict resolution by creating branches and opening pull requests when necessary.
- Added helper functions for authenticated API requests to Gitea.
2026-02-20 21:03:48 -05:00
11 changed files with 2746 additions and 243 deletions

View File

@@ -4,12 +4,13 @@ WordPress plugin that syncs pages and theme files from a remote Git repository.
## Features ## Features
- **Page sync** — Reads Gutenberg HTML files from the repo's `pages/` directory and creates/updates WordPress pages automatically. - **Page sync (pull)** — Reads Gutenberg HTML files from the repo's `pages/` directory and creates/updates WordPress pages automatically.
- **Page push** — Push WordPress page content back to the repo as PHP page-data files. On conflict (remote file changed since last sync), automatically creates a branch and opens a pull request for review.
- **Theme file preview & apply** — Fetches files from the repo's `theme/` directory, shows a side-by-side preview against the active theme, and lets an admin selectively apply changes. - **Theme file preview & apply** — Fetches files from the repo's `theme/` directory, shows a side-by-side preview against the active theme, and lets an admin selectively apply changes.
- **Encrypted PAT storage** — Personal Access Tokens are stored encrypted (AES-256-CBC) in the database with `autoload=false`. - **Encrypted PAT storage** — Personal Access Tokens are stored encrypted (AES-256-CBC) in the database with `autoload=false`.
- **Dry-run mode** — Preview what a sync would do without making any changes. - **Dry-run mode** — Preview what a sync would do without making any changes.
- **Sync log** — Keeps a history of the last 20 syncs with details on created, updated, trashed, and skipped pages. - **Sync log** — Keeps a history of the last 20 syncs with details on created, updated, trashed, and skipped pages.
- **REST API & webhook** — Trigger syncs programmatically or via Git host webhooks. - **REST API & webhook** — Trigger syncs and pushes programmatically or via Git host webhooks.
- **Trash policy** — Pages removed from the repo are moved to Trash for manual review. - **Trash policy** — Pages removed from the repo are moved to Trash for manual review.
## Repository Layout ## Repository Layout
@@ -56,7 +57,7 @@ repo/
| **GitHub** (github.com + GHE) | `Bearer` token | Fine-grained PAT with `Contents: Read` | | **GitHub** (github.com + GHE) | `Bearer` token | Fine-grained PAT with `Contents: Read` |
| **GitLab** (gitlab.com + self-hosted) | `PRIVATE-TOKEN` header | Project/personal access token with `read_repository` | | **GitLab** (gitlab.com + self-hosted) | `PRIVATE-TOKEN` header | Project/personal access token with `read_repository` |
| **Bitbucket Cloud** | Basic or Bearer | App password (`username:app_password`) or repository token | | **Bitbucket Cloud** | Basic or Bearer | App password (`username:app_password`) or repository token |
| **Gitea / Forgejo** | `token` header | Application token with repo read access | | **Gitea / Forgejo** | `token` header | Application token with repo **read + write** access |
| **Azure DevOps** | Basic (`:PAT`) | Personal access token with `Code: Read` scope | | **Azure DevOps** | Basic (`:PAT`) | Personal access token with `Code: Read` scope |
Select your provider on the settings page, or leave it on "Auto-detect" to infer from the URL. Select your provider on the settings page, or leave it on "Auto-detect" to infer from the URL.
@@ -78,6 +79,11 @@ Select your provider on the settings page, or leave it on "Auto-detect" to infer
- Click **Dry Run** to preview changes without modifying anything. - Click **Dry Run** to preview changes without modifying anything.
- Click **Preview Theme Files** to fetch and review theme files from the repo. - Click **Preview Theme Files** to fetch and review theme files from the repo.
### Push Pages to Repo
- The **Push Pages to Repo** section lists all synced pages with individual **Push** buttons and a **Push All Pages** button.
- Pushing converts the page's Gutenberg content into a PHP page-data file and commits it to the configured branch.
- **Conflict handling:** If the remote file has changed since the last sync (SHA mismatch), the plugin creates a branch named `oribi-sync/{slug}-{timestamp}` and opens a **pull request** for manual review. A link to the PR is shown in the admin UI.
### REST API ### REST API
All REST endpoints require `manage_options` capability (authenticated admin). All REST endpoints require `manage_options` capability (authenticated admin).
@@ -93,6 +99,18 @@ curl -X POST "https://yoursite.com/wp-json/oribi-sync/v1/sync?dry_run=1" \
-H "X-WP-Nonce: <nonce>" \ -H "X-WP-Nonce: <nonce>" \
--cookie "wordpress_logged_in_...=..." --cookie "wordpress_logged_in_...=..."
# Push a single page
curl -X POST https://yoursite.com/wp-json/oribi-sync/v1/push \
-H "X-WP-Nonce: <nonce>" \
-H "Content-Type: application/json" \
-d '{"post_id": 123}' \
--cookie "wordpress_logged_in_...=..."
# Push all synced pages
curl -X POST https://yoursite.com/wp-json/oribi-sync/v1/push-all \
-H "X-WP-Nonce: <nonce>" \
--cookie "wordpress_logged_in_...=..."
# Get status # Get status
curl https://yoursite.com/wp-json/oribi-sync/v1/status \ curl https://yoursite.com/wp-json/oribi-sync/v1/status \
-H "X-WP-Nonce: <nonce>" \ -H "X-WP-Nonce: <nonce>" \
@@ -131,6 +149,9 @@ Set up a webhook on your Git host to trigger syncs on push:
| File removed from `pages/` | Move corresponding WP page to Trash | | File removed from `pages/` | Move corresponding WP page to Trash |
| New file in `theme/` | Available for preview & manual apply | | New file in `theme/` | Available for preview & manual apply |
| Changed file in `theme/` | Available for preview & manual apply | | Changed file in `theme/` | Available for preview & manual apply |
| **Push:** page not in repo | Create `.php` file on target branch |
| **Push:** page in repo, no conflict | Update `.php` file on target branch |
| **Push:** page in repo, SHA conflict | Create branch `oribi-sync/{slug}-{timestamp}`, commit, open PR |
## Requirements ## Requirements

View File

@@ -35,3 +35,55 @@
.oribi-sync-wrap .description code { .oribi-sync-wrap .description code {
font-size: 12px; font-size: 12px;
} }
/* ── Simplified layout ───────────────────────────────────── */
.oribi-sync-muted {
color: #646970;
font-size: 13px;
}
.oribi-sync-error-text {
color: #d63638;
}
.oribi-sync-danger-link {
color: #d63638;
margin-left: 4px;
}
.oribi-sync-actions .button {
margin-right: 8px;
}
.oribi-sync-result-list {
list-style: disc;
padding-left: 1.5rem;
margin: 4px 0 8px;
}
.oribi-sync-pages {
max-width: 700px;
margin-top: 12px;
}
.oribi-sync-pages td form {
margin: 0;
}
.oribi-sync-pr-badge {
display: inline-block;
background: #ddf4ff;
color: #0969da;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 10px;
margin-left: 6px;
text-decoration: none;
vertical-align: middle;
}
.oribi-sync-pr-badge:hover {
background: #b6e3ff;
}

Binary file not shown.

View File

@@ -8,6 +8,168 @@
if ( ! defined( 'ABSPATH' ) ) exit; if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Admin bar pull buttons (front-end) ─────────────────────────────────────
add_action( 'admin_bar_menu', function ( WP_Admin_Bar $wp_admin_bar ) {
if ( is_admin() ) return;
if ( ! is_user_logged_in() ) return;
if ( ! current_user_can( 'manage_options' ) ) return;
// "Pull All" — visible everywhere on the front-end
$wp_admin_bar->add_node( [
'id' => 'oribi-sync-pull-all',
'title' => '<span class="ab-icon dashicons dashicons-update" aria-hidden="true"></span><span class="ab-label">Pull All</span>',
'href' => '#',
'meta' => [
'title' => 'Pull all pages and theme from Git',
],
] );
// "Pull Page" — only on singular pages/posts
if ( is_singular() ) {
$post = get_queried_object();
if ( $post instanceof WP_Post ) {
$wp_admin_bar->add_node( [
'id' => 'oribi-sync-pull',
'title' => '<span class="ab-icon dashicons dashicons-download" aria-hidden="true"></span><span class="ab-label">Pull Page</span>',
'href' => '#',
'meta' => [
'title' => 'Pull this page and theme from Git',
],
] );
}
}
}, 100 );
// AJAX handler for the admin bar "Pull All" button
add_action( 'wp_ajax_oribi_sync_pull_all_pages', function () {
check_ajax_referer( 'oribi_sync_pull_all_pages' );
if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Permission denied.', 403 );
$result = oribi_sync_run();
$result['ok'] ? wp_send_json_success( $result ) : wp_send_json_error( $result, 500 );
} );
// AJAX handler for the admin bar pull button (no REST API exposure)
add_action( 'wp_ajax_oribi_sync_pull_page', function () {
check_ajax_referer( 'oribi_sync_pull_page' );
if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Permission denied.', 403 );
$post_id = (int) ( $_POST['post_id'] ?? 0 );
if ( $post_id < 1 ) wp_send_json_error( 'Missing or invalid post_id.', 400 );
$result = oribi_sync_pull_page_from_repo( $post_id );
$result['ok'] ? wp_send_json_success( $result ) : wp_send_json_error( $result, 500 );
} );
// Front-end script for the "Pull All" admin bar button
add_action( 'wp_footer', function () {
if ( ! is_user_logged_in() ) return;
if ( ! current_user_can( 'manage_options' ) ) return;
if ( ! is_admin_bar_showing() ) return;
$ajax_url = admin_url( 'admin-ajax.php' );
$nonce_all = wp_create_nonce( 'oribi_sync_pull_all_pages' );
?>
<script>
(function () {
'use strict';
var btn = document.getElementById('wp-admin-bar-oribi-sync-pull-all');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.preventDefault();
var link = btn.querySelector('a');
var label = btn.querySelector('.ab-label');
if (link) { link.style.opacity = '0.5'; link.style.pointerEvents = 'none'; }
if (label) { label.textContent = 'Pulling…'; }
var data = new URLSearchParams({
action: 'oribi_sync_pull_all_pages',
_ajax_nonce: <?php echo wp_json_encode( $nonce_all ); ?>
});
fetch(<?php echo wp_json_encode( $ajax_url ); ?>, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: data.toString()
})
.then(function (r) { return r.json(); })
.then(function () {
var url = new URL(window.location.href);
url.searchParams.set('_nocache', Date.now());
window.location.replace(url.toString());
})
.catch(function (err) {
if (label) { label.textContent = 'Pull All'; }
if (link) { link.style.opacity = ''; link.style.pointerEvents = ''; }
alert('Oribi Sync pull failed: ' + err);
});
});
})();
</script>
<?php
} );
// Front-end script that wires up the admin bar pull button
add_action( 'wp_footer', function () {
if ( ! is_user_logged_in() ) return;
if ( ! current_user_can( 'manage_options' ) ) return;
if ( ! is_singular() ) return;
if ( ! is_admin_bar_showing() ) return;
$post = get_queried_object();
if ( ! $post instanceof WP_Post ) return;
$ajax_url = admin_url( 'admin-ajax.php' );
$nonce = wp_create_nonce( 'oribi_sync_pull_page' );
$post_id = (int) $post->ID;
?>
<script>
(function () {
'use strict';
var btn = document.getElementById('wp-admin-bar-oribi-sync-pull');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.preventDefault();
var link = btn.querySelector('a');
var label = btn.querySelector('.ab-label');
if (link) { link.style.opacity = '0.5'; link.style.pointerEvents = 'none'; }
if (label) { label.textContent = 'Pulling…'; }
var data = new URLSearchParams({
action: 'oribi_sync_pull_page',
_ajax_nonce: <?php echo wp_json_encode( $nonce ); ?>,
post_id: <?php echo $post_id; ?>
});
fetch(<?php echo wp_json_encode( $ajax_url ); ?>, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: data.toString()
})
.then(function (r) { return r.json(); })
.then(function () {
// Hard reload — cache-busting param forces a fresh response
var url = new URL(window.location.href);
url.searchParams.set('_nocache', Date.now());
window.location.replace(url.toString());
})
.catch(function (err) {
if (label) { label.textContent = 'Pull Page'; }
if (link) { link.style.opacity = ''; link.style.pointerEvents = ''; }
alert('Oribi Sync pull failed: ' + err);
});
});
})();
</script>
<?php
} );
// ─── Register admin menu ────────────────────────────────────────────────────── // ─── Register admin menu ──────────────────────────────────────────────────────
add_action( 'admin_menu', function () { add_action( 'admin_menu', function () {
add_options_page( add_options_page(
@@ -39,6 +201,13 @@ add_action( 'admin_post_oribi_sync_save_settings', function () {
update_option( 'oribi_sync_branch', $branch, 'no' ); update_option( 'oribi_sync_branch', $branch, 'no' );
update_option( 'oribi_sync_provider', $provider, 'no' ); update_option( 'oribi_sync_provider', $provider, 'no' );
// Posts sync settings
$posts_enabled = isset( $_POST['oribi_sync_posts_enabled'] ) ? '1' : '0';
$posts_folder = sanitize_text_field( wp_unslash( $_POST['oribi_sync_posts_folder'] ?? 'posts' ) );
$posts_folder = trim( $posts_folder, '/' ) ?: 'posts';
update_option( 'oribi_sync_posts_enabled', $posts_enabled, 'no' );
update_option( 'oribi_sync_posts_folder', $posts_folder, 'no' );
// Only update PAT if a new one was provided (non-empty) // Only update PAT if a new one was provided (non-empty)
if ( ! empty( $pat ) ) { if ( ! empty( $pat ) ) {
oribi_sync_save_pat( $pat ); oribi_sync_save_pat( $pat );
@@ -54,6 +223,10 @@ add_action( 'admin_post_oribi_sync_run', function () {
$result = oribi_sync_run(); $result = oribi_sync_run();
// After pulling, push any local changes back to the repo
$push = oribi_sync_push_all();
$result['push'] = $push['results'];
set_transient( 'oribi_sync_result', $result, 60 ); set_transient( 'oribi_sync_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_done', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) ); wp_redirect( add_query_arg( 'oribi_sync_done', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
@@ -72,6 +245,30 @@ add_action( 'admin_post_oribi_sync_dry_run', function () {
exit; exit;
} ); } );
add_action( 'admin_post_oribi_sync_pull', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_pull' );
$result = oribi_sync_run();
set_transient( 'oribi_sync_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_done', 'pull', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_force_pull', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_force_pull' );
$result = oribi_sync_run( false, true );
set_transient( 'oribi_sync_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_done', 'force_pull', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_clear_pat', function () { add_action( 'admin_post_oribi_sync_clear_pat', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' ); if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_clear_pat' ); check_admin_referer( 'oribi_sync_clear_pat' );
@@ -82,6 +279,36 @@ add_action( 'admin_post_oribi_sync_clear_pat', function () {
exit; exit;
} ); } );
// ─── Push page(s) to repo ─────────────────────────────────────────────────────
add_action( 'admin_post_oribi_sync_push', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_push' );
$post_id = (int) ( $_POST['oribi_sync_push_post_id'] ?? 0 );
if ( $post_id > 0 ) {
$post = get_post( $post_id );
$result = ( $post && $post->post_type === 'post' )
? oribi_sync_push_post( $post_id )
: oribi_sync_push_page( $post_id );
set_transient( 'oribi_sync_push_result', $result, 60 );
}
wp_redirect( add_query_arg( 'oribi_sync_pushed', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_push_all', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_push_all' );
$result = oribi_sync_push_all();
set_transient( 'oribi_sync_push_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_pushed', 'all', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
// ─── Settings page renderer ────────────────────────────────────────────────── // ─── Settings page renderer ──────────────────────────────────────────────────
function oribi_sync_settings_page() { function oribi_sync_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) return; if ( ! current_user_can( 'manage_options' ) ) return;
@@ -93,9 +320,11 @@ function oribi_sync_settings_page() {
$last_run = get_option( 'oribi_sync_last_run', '' ); $last_run = get_option( 'oribi_sync_last_run', '' );
$log = get_option( 'oribi_sync_log', [] ); $log = get_option( 'oribi_sync_log', [] );
// Transient result (after sync / dry-run) // Transient results
$sync_result = get_transient( 'oribi_sync_result' ); $sync_result = get_transient( 'oribi_sync_result' );
if ( $sync_result ) delete_transient( 'oribi_sync_result' ); if ( $sync_result ) delete_transient( 'oribi_sync_result' );
$push_result = get_transient( 'oribi_sync_push_result' );
if ( $push_result ) delete_transient( 'oribi_sync_push_result' );
$saved = $_GET['oribi_sync_saved'] ?? ''; $saved = $_GET['oribi_sync_saved'] ?? '';
$done = $_GET['oribi_sync_done'] ?? ''; $done = $_GET['oribi_sync_done'] ?? '';
@@ -103,38 +332,55 @@ function oribi_sync_settings_page() {
<div class="wrap oribi-sync-wrap"> <div class="wrap oribi-sync-wrap">
<h1>Oribi Sync</h1> <h1>Oribi Sync</h1>
<?php // ── Flash notices ──────────────────────────────────────────── ?>
<?php if ( $saved === '1' ): ?> <?php if ( $saved === '1' ): ?>
<div class="notice notice-success is-dismissible"><p>Settings saved.</p></div> <div class="notice notice-success is-dismissible"><p>Settings saved.</p></div>
<?php elseif ( $saved === 'pat_cleared' ): ?> <?php elseif ( $saved === 'pat_cleared' ): ?>
<div class="notice notice-warning is-dismissible"><p>PAT has been cleared.</p></div> <div class="notice notice-warning is-dismissible"><p>PAT cleared.</p></div>
<?php endif; ?> <?php endif; ?>
<?php if ( $sync_result ): ?> <?php if ( $sync_result ): ?>
<div class="notice <?php echo $sync_result['ok'] ? 'notice-success' : 'notice-error'; ?>"> <div class="notice <?php echo $sync_result['ok'] ? 'notice-success' : 'notice-error'; ?> is-dismissible">
<p> <p><strong><?php
<strong><?php echo $done === 'dry' ? '🔍 Dry-run results' : '✅ Sync complete'; ?></strong> if ( $done === 'dry' ) echo 'Dry-run results';
</p> elseif ( $done === 'pull' ) echo 'Pull complete';
<ul style="list-style:disc; padding-left:1.5rem;"> elseif ( $done === 'force_pull' ) echo 'Force Pull complete';
<?php if ( ! empty( $sync_result['created'] ) ): ?> else echo 'Sync complete';
<li>Created: <?php echo esc_html( implode( ', ', $sync_result['created'] ) ); ?></li> ?></strong></p>
<?php endif; ?> <?php oribi_sync_render_result_list( $sync_result ); ?>
<?php if ( ! empty( $sync_result['updated'] ) ): ?>
<li>Updated: <?php echo esc_html( implode( ', ', $sync_result['updated'] ) ); ?></li>
<?php endif; ?>
<?php if ( ! empty( $sync_result['trashed'] ) ): ?>
<li>Trashed: <?php echo esc_html( implode( ', ', $sync_result['trashed'] ) ); ?></li>
<?php endif; ?>
<?php if ( ! empty( $sync_result['skipped'] ) ): ?>
<li>Skipped: <?php echo esc_html( implode( ', ', $sync_result['skipped'] ) ); ?></li>
<?php endif; ?>
<?php if ( ! empty( $sync_result['errors'] ) ): ?>
<li style="color:#d63638;">Errors: <?php echo esc_html( implode( '; ', $sync_result['errors'] ) ); ?></li>
<?php endif; ?>
</ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Settings form --> <?php if ( $push_result ): ?>
<?php if ( isset( $push_result['results'] ) ): ?>
<div class="notice <?php echo $push_result['ok'] ? 'notice-success' : 'notice-warning'; ?> is-dismissible">
<p><strong>Push results</strong></p>
<ul class="oribi-sync-result-list">
<?php foreach ( $push_result['results'] as $pr ): ?>
<li>
<strong><?php echo esc_html( $pr['slug'] ?? '' ); ?></strong> —
<?php echo esc_html( $pr['message'] ?? '' ); ?>
<?php if ( ! empty( $pr['pr_url'] ) ): ?>
<a href="<?php echo esc_url( $pr['pr_url'] ); ?>" target="_blank" rel="noopener">PR →</a>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php else: ?>
<div class="notice <?php echo $push_result['ok'] ? 'notice-success' : 'notice-error'; ?> is-dismissible">
<p>
<?php if ( ! empty( $push_result['pr_url'] ) ): ?>
Conflict — opened <a href="<?php echo esc_url( $push_result['pr_url'] ); ?>" target="_blank" rel="noopener">pull request</a> for review.
<?php else: ?>
<?php echo esc_html( $push_result['message'] ?? '' ); ?>
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<?php endif; ?>
<?php // ── Settings form ──────────────────────────────────────────── ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="oribi-sync-form"> <form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="oribi-sync-form">
<input type="hidden" name="action" value="oribi_sync_save_settings" /> <input type="hidden" name="action" value="oribi_sync_save_settings" />
<?php wp_nonce_field( 'oribi_sync_save_settings' ); ?> <?php wp_nonce_field( 'oribi_sync_save_settings' ); ?>
@@ -145,22 +391,20 @@ function oribi_sync_settings_page() {
<td> <td>
<input type="url" name="oribi_sync_repo" id="oribi_sync_repo" <input type="url" name="oribi_sync_repo" id="oribi_sync_repo"
value="<?php echo esc_attr( $repo ); ?>" value="<?php echo esc_attr( $repo ); ?>"
class="regular-text" placeholder="https://github.com/owner/repo" /> class="regular-text" placeholder="https://gitea.example.com/owner/repo" />
<p class="description">HTTPS URL to the Git repository (any provider).</p>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><label for="oribi_sync_provider">Provider</label></th> <th scope="row"><label for="oribi_sync_provider">Provider</label></th>
<td> <td>
<select name="oribi_sync_provider" id="oribi_sync_provider" class="regular-text"> <select name="oribi_sync_provider" id="oribi_sync_provider" class="regular-text">
<option value=""<?php selected( $provider, '' ); ?>>Auto-detect from URL</option> <option value=""<?php selected( $provider, '' ); ?>>Auto-detect</option>
<?php foreach ( oribi_sync_providers() as $key => $label ): ?> <?php foreach ( oribi_sync_providers() as $key => $label ): ?>
<option value="<?php echo esc_attr( $key ); ?>"<?php selected( $provider, $key ); ?>> <option value="<?php echo esc_attr( $key ); ?>"<?php selected( $provider, $key ); ?>>
<?php echo esc_html( $label ); ?> <?php echo esc_html( $label ); ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<p class="description">Select your Git hosting provider, or leave on auto-detect.</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -172,78 +416,215 @@ function oribi_sync_settings_page() {
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><label for="oribi_sync_pat">Personal Access Token</label></th> <th scope="row"><label for="oribi_sync_pat">Access Token</label></th>
<td> <td>
<input type="password" name="oribi_sync_pat" id="oribi_sync_pat" <input type="password" name="oribi_sync_pat" id="oribi_sync_pat"
value="" class="regular-text" value="" class="regular-text"
placeholder="<?php echo $has_pat ? '•••••••• (saved — leave blank to keep)' : 'ghp_…'; ?>" placeholder="<?php echo $has_pat ? '•••••••• (saved)' : 'token'; ?>"
autocomplete="off" /> autocomplete="off" />
<p class="description"> <p class="description">
Read token for your repo. Stored encrypted in the database. Needs <strong>write</strong> scope to push pages. Stored encrypted.
<?php if ( $has_pat ): ?> <?php if ( $has_pat ): ?>
&nbsp;
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_clear_pat' ), 'oribi_sync_clear_pat' ) ); ?>" <a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_clear_pat' ), 'oribi_sync_clear_pat' ) ); ?>"
onclick="return confirm('Clear the stored PAT?');" onclick="return confirm('Clear the stored PAT?');"
style="color:#d63638;">Clear PAT</a> class="oribi-sync-danger-link">Clear</a>
<?php endif; ?> <?php endif; ?>
</p> </p>
</td> </td>
</tr> </tr>
</table> </table>
<h2 style="margin-top:1.5em;">Posts Sync</h2>
<p class="description">Import WordPress posts from Markdown files with YAML front-matter stored in a repo subfolder.</p>
<table class="form-table" role="presentation">
<tr>
<th scope="row">Enable Posts Sync</th>
<td>
<label>
<input type="checkbox" name="oribi_sync_posts_enabled" value="1"
<?php checked( get_option( 'oribi_sync_posts_enabled', '' ), '1' ); ?> />
Sync posts from the repository
</label>
</td>
</tr>
<tr>
<th scope="row"><label for="oribi_sync_posts_folder">Posts Folder</label></th>
<td>
<input type="text" name="oribi_sync_posts_folder" id="oribi_sync_posts_folder"
value="<?php echo esc_attr( get_option( 'oribi_sync_posts_folder', 'posts' ) ); ?>"
class="regular-text" placeholder="posts" />
<p class="description">
Repo folder containing <code>.md</code> files (e.g. <code>posts</code> or <code>content/posts</code>).
Files may use <code>YYYY-MM-DD-slug.md</code> naming. Supported front-matter keys:
<code>title</code>, <code>slug</code>, <code>status</code>, <code>date</code>,
<code>author</code>, <code>categories</code>, <code>tags</code>,
<code>excerpt</code>, <code>featured_image</code>.
</p>
</td>
</tr>
</table>
<?php submit_button( 'Save Settings' ); ?> <?php submit_button( 'Save Settings' ); ?>
</form> </form>
<hr /> <hr />
<!-- Sync actions --> <?php // ── Actions ────────────────────────────────────────────────── ?>
<h2>Sync Actions</h2> <h2>Actions</h2>
<p class="description"> <p class="oribi-sync-actions">
The plugin reads two folders from the repo: <code>pages/</code> (PHP or HTML files → WP pages)
and <code>theme/</code> (theme style files → preview &amp; manual apply). Everything else is ignored.<br />
PHP files are executed using the <code>oribi_b()</code> block helpers (requires Oribi Tech Setup plugin).
HTML files are used as raw Gutenberg block markup.
</p>
<p>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_run' ), 'oribi_sync_run' ) ); ?>" <a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_run' ), 'oribi_sync_run' ) ); ?>"
class="button button-primary" class="button button-primary"
onclick="return confirm('This will overwrite pages with content from the repository. Continue?');"> onclick="return confirm('Pull from repo then push local changes. Continue?');">
🔄 Sync Now Sync (Pull &amp; Push)
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_pull' ), 'oribi_sync_pull' ) ); ?>"
class="button"
onclick="return confirm('Pull content from the repo (no push). Continue?');">
Pull Only
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_force_pull' ), 'oribi_sync_force_pull' ) ); ?>"
class="button"
onclick="return confirm('Force re-pull ALL content from the repo, bypassing change detection. Continue?');">
Force Pull
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_push_all' ), 'oribi_sync_push_all' ) ); ?>"
class="button"
onclick="return confirm('Push all synced pages to the repo (no pull). Continue?');">
Push Only
</a> </a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_dry_run' ), 'oribi_sync_dry_run' ) ); ?>" <a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_dry_run' ), 'oribi_sync_dry_run' ) ); ?>"
class="button"> class="button">
🔍 Dry Run Dry Run
</a> </a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_theme_preview' ), 'oribi_sync_theme_preview' ) ); ?>" <a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_theme_preview' ), 'oribi_sync_theme_preview' ) ); ?>"
class="button"> class="button">
🎨 Preview Theme Files Preview Theme
</a> </a>
</p> </p>
<?php if ( $last_run ): ?> <?php if ( $last_run ): ?>
<p><strong>Last sync:</strong> <?php echo esc_html( $last_run ); ?></p> <p class="oribi-sync-muted">Last sync: <?php echo esc_html( $last_run ); ?></p>
<?php endif; ?> <?php endif; ?>
<!-- Theme files preview --> <?php // ── Synced pages ───────────────────────────────────────────── ?>
<?php
$synced_pages = new WP_Query( [
'post_type' => 'page',
'post_status' => 'publish',
'meta_key' => '_oribi_sync_checksum',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
] );
if ( $synced_pages->have_posts() ): ?>
<table class="widefat fixed striped oribi-sync-pages">
<thead><tr>
<th>Page</th>
<th style="width:80px;">Push</th>
</tr></thead>
<tbody>
<?php while ( $synced_pages->have_posts() ): $synced_pages->the_post();
$pid = get_the_ID();
$last_push = get_post_meta( $pid, '_oribi_sync_last_push', true );
$pr_url = get_post_meta( $pid, '_oribi_sync_pr_url', true );
?>
<tr>
<td>
<strong><?php the_title(); ?></strong>
<span class="oribi-sync-muted">/<?php echo esc_html( get_post_field( 'post_name', $pid ) ); ?></span>
<?php if ( $pr_url ): ?>
<a href="<?php echo esc_url( $pr_url ); ?>" target="_blank" rel="noopener" class="oribi-sync-pr-badge">PR</a>
<?php endif; ?>
<?php if ( $last_push ): ?>
<br /><span class="oribi-sync-muted">pushed <?php echo esc_html( $last_push ); ?></span>
<?php endif; ?>
</td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin:0;">
<input type="hidden" name="action" value="oribi_sync_push" />
<input type="hidden" name="oribi_sync_push_post_id" value="<?php echo esc_attr( $pid ); ?>" />
<?php wp_nonce_field( 'oribi_sync_push' ); ?>
<button type="submit" class="button button-small">Push</button>
</form>
</td>
</tr>
<?php endwhile; wp_reset_postdata(); ?>
</tbody>
</table>
<?php endif; ?>
<?php
// ── Synced posts ──────────────────────────────────────────────────
if ( get_option( 'oribi_sync_posts_enabled', '' ) ):
$synced_posts = new WP_Query( [
'post_type' => 'post',
'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
'meta_key' => '_oribi_sync_checksum',
'posts_per_page' => -1,
'orderby' => 'date',
'order' => 'DESC',
] );
if ( $synced_posts->have_posts() ):
?>
<h2>Synced Posts</h2>
<table class="widefat fixed striped oribi-sync-pages">
<thead><tr>
<th>Post</th>
<th style="width:80px;">Push</th>
</tr></thead>
<tbody>
<?php while ( $synced_posts->have_posts() ): $synced_posts->the_post();
$pid = get_the_ID();
$last_push = get_post_meta( $pid, '_oribi_sync_last_push', true );
$pr_url = get_post_meta( $pid, '_oribi_sync_pr_url', true );
?>
<tr>
<td>
<strong><?php the_title(); ?></strong>
<span class="oribi-sync-muted"><?php echo esc_html( get_post_field( 'post_name', $pid ) ); ?></span>
<span class="oribi-sync-muted"> &mdash; <?php echo esc_html( get_post_status( $pid ) ); ?></span>
<?php if ( $pr_url ): ?>
<a href="<?php echo esc_url( $pr_url ); ?>" target="_blank" rel="noopener" class="oribi-sync-pr-badge">PR</a>
<?php endif; ?>
<?php if ( $last_push ): ?>
<br /><span class="oribi-sync-muted">pushed <?php echo esc_html( $last_push ); ?></span>
<?php endif; ?>
</td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin:0;">
<input type="hidden" name="action" value="oribi_sync_push" />
<input type="hidden" name="oribi_sync_push_post_id" value="<?php echo esc_attr( $pid ); ?>" />
<?php wp_nonce_field( 'oribi_sync_push' ); ?>
<button type="submit" class="button button-small">Push</button>
</form>
</td>
</tr>
<?php endwhile; wp_reset_postdata(); ?>
</tbody>
</table>
<?php
endif; // have_posts
endif; // posts_enabled
?>
<?php // ── Theme preview (conditional) ────────────────────────────── ?>
<?php if ( isset( $_GET['oribi_sync_tab'] ) && $_GET['oribi_sync_tab'] === 'theme' ): ?> <?php if ( isset( $_GET['oribi_sync_tab'] ) && $_GET['oribi_sync_tab'] === 'theme' ): ?>
<hr /> <hr />
<h2>Theme Files Preview</h2> <h2>Theme Files Preview</h2>
<?php oribi_sync_render_theme_preview(); ?> <?php oribi_sync_render_theme_preview(); ?>
<?php endif; ?> <?php endif; ?>
<!-- Sync log --> <?php // ── Log ────────────────────────────────────────────────────── ?>
<?php if ( ! empty( $log ) ): ?> <?php if ( ! empty( $log ) ): ?>
<hr /> <hr />
<h2>Sync Log</h2> <details>
<summary>Sync Log (<?php echo count( $log ); ?>)</summary>
<table class="widefat fixed striped oribi-sync-log"> <table class="widefat fixed striped oribi-sync-log">
<thead><tr> <thead><tr>
<th style="width:160px;">Time</th> <th style="width:150px;">Time</th>
<th>Created</th> <th>Created</th>
<th>Updated</th> <th>Updated</th>
<th>Trashed</th>
<th>Skipped</th>
<th>Errors</th> <th>Errors</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -251,15 +632,56 @@ function oribi_sync_settings_page() {
<tr> <tr>
<td><?php echo esc_html( $entry['time'] ?? '—' ); ?></td> <td><?php echo esc_html( $entry['time'] ?? '—' ); ?></td>
<td><?php echo esc_html( implode( ', ', $entry['created'] ?? [] ) ?: '—' ); ?></td> <td><?php echo esc_html( implode( ', ', $entry['created'] ?? [] ) ?: '—' ); ?></td>
<td><?php echo esc_html( implode( ', ', $entry['updated'] ?? [] ) ?: '—' ); ?></td> <td><?php echo esc_html( implode( ', ', array_merge( $entry['updated'] ?? [], $entry['theme_updated'] ?? [] ) ) ?: '—' ); ?></td>
<td><?php echo esc_html( implode( ', ', $entry['trashed'] ?? [] ) ?: '—' ); ?></td> <td class="oribi-sync-error-text"><?php echo esc_html( implode( '; ', $entry['errors'] ?? [] ) ?: '—' ); ?></td>
<td><?php echo esc_html( implode( ', ', $entry['skipped'] ?? [] ) ?: '—' ); ?></td>
<td style="color:#d63638;"><?php echo esc_html( implode( '; ', $entry['errors'] ?? [] ) ?: '—' ); ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</details>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php <?php
} }
/**
* Render a sync result as a compact <ul>.
*/
function oribi_sync_render_result_list( array $r ): void {
$items = [];
if ( ! empty( $r['created'] ) ) $items[] = 'Pages created: ' . implode( ', ', $r['created'] );
if ( ! empty( $r['updated'] ) ) $items[] = 'Pages updated: ' . implode( ', ', $r['updated'] );
if ( ! empty( $r['theme_updated'] ) ) $items[] = 'Theme: ' . implode( ', ', $r['theme_updated'] );
if ( ! empty( $r['trashed'] ) ) $items[] = 'Pages trashed: ' . implode( ', ', $r['trashed'] );
if ( ! empty( $r['posts_created'] ) ) $items[] = 'Posts created: ' . implode( ', ', $r['posts_created'] );
if ( ! empty( $r['posts_updated'] ) ) $items[] = 'Posts updated: ' . implode( ', ', $r['posts_updated'] );
if ( ! empty( $r['posts_trashed'] ) ) $items[] = 'Posts trashed: ' . implode( ', ', $r['posts_trashed'] );
if ( ! empty( $r['skipped'] ) ) $items[] = 'Skipped: ' . implode( ', ', $r['skipped'] );
if ( ! empty( $r['errors'] ) ) $items[] = 'Errors: ' . implode( '; ', $r['errors'] );
if ( empty( $items ) ) { echo '<p>No changes.</p>'; return; }
echo '<ul class="oribi-sync-result-list">';
foreach ( $items as $item ) {
$class = ( strpos( $item, 'Errors:' ) === 0 ) ? ' class="oribi-sync-error-text"' : '';
echo '<li' . $class . '>' . esc_html( $item ) . '</li>';
}
// Append push results if present
if ( ! empty( $r['push'] ) && is_array( $r['push'] ) ) {
foreach ( $r['push'] as $pr ) {
$slug = $pr['slug'] ?? '';
$msg = $pr['message'] ?? '';
$url = $pr['pr_url'] ?? '';
$text = "Pushed: {$slug} — {$msg}";
$cls = $pr['ok'] ? '' : ' class="oribi-sync-error-text"';
echo '<li' . $cls . '>' . esc_html( $text );
if ( $url ) {
echo ' <a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">PR →</a>';
}
echo '</li>';
}
}
echo '</ul>';
}

View File

@@ -341,14 +341,15 @@ function oribi_sync_fetch_file( string $api_base, string $branch, string $file_p
break; break;
case 'gitea': case 'gitea':
$url = $api_base . '/raw/' . $encoded_path . '?ref=' . rawurlencode( $branch ); // Use /contents/ endpoint which returns base64-encoded content (more reliable)
$accept = 'text/plain'; $url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
$accept = 'application/json';
break; break;
case 'github': case 'github':
default: default:
$url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch ); $url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
$accept = 'application/vnd.github.raw+json'; $accept = 'application/vnd.github.raw';
break; break;
} }
@@ -377,28 +378,53 @@ function oribi_sync_fetch_file( string $api_base, string $branch, string $file_p
); );
} }
return wp_remote_retrieve_body( $response ); $body = wp_remote_retrieve_body( $response );
// For Gitea, the /contents/ endpoint returns base64-encoded content in JSON.
// Gitea (like GitHub) inserts \n every 60 chars in the base64 — strip them before decoding.
if ( $provider === 'gitea' ) {
$decoded = json_decode( $body, true );
if ( isset( $decoded['content'] ) && is_string( $decoded['content'] ) ) {
$clean = str_replace( [ "\r", "\n", " " ], '', $decoded['content'] );
$body = base64_decode( $clean, true );
if ( $body === false ) {
return new WP_Error( 'oribi_sync_decode_error', 'Failed to decode base64 content from Gitea.' );
}
}
}
// Validate and fix encoding if necessary (handles non-UTF-8 sources)
if ( ! empty( $body ) ) {
if ( ! mb_check_encoding( $body, 'UTF-8' ) ) {
// Try to convert from common encodings to UTF-8
$body = mb_convert_encoding( $body, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252' );
}
}
return $body;
} }
// ─── Tree filtering ─────────────────────────────────────────────────────────── // ─── Tree filtering ───────────────────────────────────────────────────────────
/** /**
* Filter tree entries to only those under a given directory prefix. * Filter tree entries to only those under a given directory prefix.
* Matching is case-insensitive so Pages/, pages/, PAGES/ etc. all work.
* *
* @param array $tree Tree from oribi_sync_fetch_tree(). * @param array $tree Tree from oribi_sync_fetch_tree().
* @param string $prefix Directory prefix (e.g. 'pages/'). * @param string $prefix Directory prefix (e.g. 'Pages').
* @param bool $recursive Whether to include files in subdirectories (default: false). * @param bool $recursive Whether to include files in subdirectories (default: false).
* *
* @return array Filtered entries (blobs only). * @return array Filtered entries (blobs only).
*/ */
function oribi_sync_filter_tree( array $tree, string $prefix, bool $recursive = false ): array { function oribi_sync_filter_tree( array $tree, string $prefix, bool $recursive = false ): array {
$prefix = rtrim( $prefix, '/' ) . '/'; $prefix = rtrim( $prefix, '/' ) . '/';
$plen = strlen( $prefix );
$out = []; $out = [];
foreach ( $tree as $entry ) { foreach ( $tree as $entry ) {
if ( $entry['type'] !== 'blob' ) continue; if ( $entry['type'] !== 'blob' ) continue;
if ( strpos( $entry['path'], $prefix ) !== 0 ) continue; if ( strncasecmp( $entry['path'], $prefix, $plen ) !== 0 ) continue;
$relative = substr( $entry['path'], strlen( $prefix ) ); $relative = substr( $entry['path'], $plen );
// Skip sub-directory files unless recursive is enabled // Skip sub-directory files unless recursive is enabled
if ( ! $recursive && strpos( $relative, '/' ) !== false ) continue; if ( ! $recursive && strpos( $relative, '/' ) !== false ) continue;
$out[] = $entry; $out[] = $entry;

1076
includes/post-sync.php Normal file

File diff suppressed because it is too large Load Diff

596
includes/push-client.php Normal file
View File

@@ -0,0 +1,596 @@
<?php
/**
* Oribi Sync — Push client.
*
* Writes page content back to the Git repository.
* On SHA conflict (remote file changed since last sync), creates a branch
* named oribi-sync/{slug}-{timestamp} and opens a pull request.
*
* Currently supports Gitea / Forgejo.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Detect the actual casing of the Pages directory in the repo tree.
*
* Looks at existing synced pages for a stored repo path, extracts the
* directory prefix. Falls back to 'Pages/' if nothing found.
*/
function oribi_sync_detect_pages_prefix(): string {
// Check post meta of any previously-synced page for the real path
$existing = get_posts( [
'post_type' => 'page',
'meta_key' => '_oribi_sync_source',
'numberposts' => 1,
'fields' => 'ids',
] );
if ( ! empty( $existing ) ) {
$source = get_post_meta( $existing[0], '_oribi_sync_source', true );
// Extract the repo-path portion after the last colon (skip 'https:').
$colon = strrpos( $source, ':' );
if ( $colon !== false ) {
$path_part = substr( $source, $colon + 1 ); // e.g. 'pages/about.php'
// Validate it looks like a pages/ path before trusting it.
if ( strncasecmp( $path_part, 'pages/', 6 ) === 0 ) {
return substr( $path_part, 0, 6 ); // preserve original casing
}
}
}
return 'pages/';
}
// ─── Generic authenticated request helpers ────────────────────────────────────
/**
* Perform an authenticated POST request to the Git API.
*
* @param string $url Full API URL.
* @param array $body Body payload (will be JSON-encoded).
* @param string $provider Provider key.
* @param string $pat Personal access token.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_post( string $url, array $body, string $provider, string $pat ) {
return oribi_sync_api_request( 'POST', $url, $body, $provider, $pat );
}
/**
* Perform an authenticated PUT request to the Git API.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_put( string $url, array $body, string $provider, string $pat ) {
return oribi_sync_api_request( 'PUT', $url, $body, $provider, $pat );
}
/**
* Perform an authenticated DELETE request to the Git API.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_delete( string $url, array $body, string $provider, string $pat ) {
return oribi_sync_api_request( 'DELETE', $url, $body, $provider, $pat );
}
/**
* Internal: send an authenticated JSON request.
*
* @return array{code: int, body: array|string}|WP_Error
*/
function oribi_sync_api_request( string $method, string $url, array $body, string $provider, string $pat ) {
$headers = array_merge(
oribi_sync_auth_headers( $provider, $pat ),
[
'Content-Type' => 'application/json; charset=utf-8',
'Accept' => 'application/json',
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
]
);
// Ensure UTF-8 encoding of all body content
array_walk_recursive( $body, function ( &$item ) {
if ( is_string( $item ) && ! mb_check_encoding( $item, 'UTF-8' ) ) {
$item = mb_convert_encoding( $item, 'UTF-8' );
}
});
$args = [
'method' => $method,
'timeout' => 30,
'headers' => $headers,
'body' => wp_json_encode( $body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
];
$response = wp_remote_request( $url, $args );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$raw_body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw_body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
$decoded = $raw_body;
}
return [ 'code' => $code, 'body' => $decoded ];
}
// ─── Gitea file metadata ──────────────────────────────────────────────────────
/**
* Get file metadata (including current SHA) from the Gitea API.
*
* @param string $api_base Gitea API base (e.g. https://host/api/v1/repos/owner/repo).
* @param string $branch Branch name.
* @param string $filepath Repo-relative file path.
* @param string $pat Personal access token.
*
* @return array{sha: string, content: string}|null|WP_Error
* null if file does not exist (404), WP_Error on failure.
*/
function oribi_sync_gitea_get_file_meta( string $api_base, string $branch, string $filepath, string $pat ) {
$encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) );
$url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch );
$result = oribi_sync_api_get( $url, 'gitea', $pat );
if ( is_wp_error( $result ) ) {
$msg = $result->get_error_message();
// 404 means file doesn't exist yet — not an error
if ( strpos( $msg, 'HTTP 404' ) !== false ) {
return null;
}
return $result;
}
// Gitea inserts \n every 60 chars in base64 — strip before decoding.
$raw_b64 = $result['content'] ?? '';
$content = ! empty( $raw_b64 )
? base64_decode( str_replace( [ "\r", "\n", " " ], '', $raw_b64 ), true )
: '';
return [
'sha' => $result['sha'] ?? '',
'content' => ( $content !== false ) ? $content : '',
];
}
// ─── Gitea file create / update ───────────────────────────────────────────────
/**
* Create or update a file in a Gitea repository.
*
* @param string $api_base API base URL.
* @param string $branch Target branch.
* @param string $filepath Repo-relative path.
* @param string $content Raw file content (will be base64-encoded).
* @param string $pat Personal access token.
* @param string|null $sha Current file SHA (required for updates; null for creates).
* @param string $message Commit message.
*
* @return array{code: int, body: array}|WP_Error
*/
function oribi_sync_gitea_put_file(
string $api_base,
string $branch,
string $filepath,
string $content,
string $pat,
?string $sha = null,
string $message = ''
) {
// Validate and fix UTF-8 encoding before base64-encoding
if ( ! mb_check_encoding( $content, 'UTF-8' ) ) {
$content = mb_convert_encoding( $content, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252' );
}
$encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) );
$url = $api_base . '/contents/' . $encoded_path;
$body = [
'content' => base64_encode( $content ),
'branch' => $branch,
'message' => $message ?: 'Update ' . basename( $filepath ),
];
if ( $sha !== null ) {
$body['sha'] = $sha;
return oribi_sync_api_put( $url, $body, 'gitea', $pat );
}
return oribi_sync_api_post( $url, $body, 'gitea', $pat );
}
// ─── Gitea branch creation ───────────────────────────────────────────────────
/**
* Create a new branch in a Gitea repository.
*
* @return array{code: int, body: array}|WP_Error
*/
function oribi_sync_gitea_create_branch( string $api_base, string $new_branch, string $base_branch, string $pat ) {
$url = $api_base . '/branches';
return oribi_sync_api_post( $url, [
'new_branch_name' => $new_branch,
'old_branch_name' => $base_branch,
], 'gitea', $pat );
}
// ─── Gitea pull request ──────────────────────────────────────────────────────
/**
* Open a pull request in a Gitea repository.
*
* @return array{code: int, body: array}|WP_Error
*/
function oribi_sync_gitea_create_pr( string $api_base, string $head, string $base, string $title, string $body_text, string $pat ) {
$url = $api_base . '/pulls';
return oribi_sync_api_post( $url, [
'title' => $title,
'body' => $body_text,
'head' => $head,
'base' => $base,
], 'gitea', $pat );
}
// ─── PHP page-data wrapper generation ─────────────────────────────────────────
/**
* Generate a PHP page-data wrapper suitable for the repo's page-data convention.
*
* The wrapper uses a nowdoc return so the file can be `include`d by
* oribi_sync_execute_php() and produce the Gutenberg block HTML.
*
* @param string $content Gutenberg block HTML (from post_content).
* @param string $slug Page slug.
* @param string $title Page title.
*
* @return string PHP source code.
*/
function oribi_sync_generate_php_wrapper( string $content, string $slug, string $title = '' ): string {
if ( empty( $title ) ) {
$title = oribi_sync_slug_to_title( $slug );
}
// Use a nowdoc so the content is treated as a literal string (no interpolation).
$delimiter = 'ORIBI_SYNC_CONTENT';
$safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $content );
$php = "<?php\n";
$php .= "/*\n";
$php .= " * Title: {$title}\n";
$php .= " * Slug: {$slug}\n";
$php .= " * Post Type: page\n";
$php .= " */\n\n";
$php .= "return <<<'{$delimiter}'\n";
$php .= $safe_content . "\n";
$php .= $delimiter . ";\n";
return $php;
}
/**
* Replace the content body in an existing PHP page-data file.
*
* Preserves the original header (everything before the `return` statement)
* and only replaces the body between the heredoc / nowdoc delimiters.
* If the file format can't be parsed, falls back to generating a new wrapper.
*
* @param string $existing_source Current PHP source from the repo.
* @param string $new_content New Gutenberg block HTML.
* @param string $slug Page slug (used for fallback wrapper).
* @param string $title Page title (used for fallback wrapper).
*
* @return string Updated PHP source code.
*/
function oribi_sync_replace_php_body( string $existing_source, string $new_content, string $slug, string $title ): string {
// Match: return <<<'DELIMITER' or return <<<DELIMITER (heredoc / nowdoc)
if ( preg_match( '/^(.*?return\s+<<<\'?)(\w+)(\'?\s*\n)(.*)(\n\2;?\s*)$/s', $existing_source, $m ) ) {
$header = $m[1]; // everything up to and including "return <<<"
$delimiter = $m[2]; // e.g. ORIBI_SYNC_CONTENT
$quote_end = $m[3]; // closing quote + newline
$suffix = $m[5]; // closing delimiter + semicolon
// Escape content if it contains the delimiter string on its own line
$safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $new_content );
return $header . $delimiter . $quote_end . $safe_content . $suffix;
}
// Couldn't parse the existing file — fall back to a fresh wrapper.
return oribi_sync_generate_php_wrapper( $new_content, $slug, $title );
}
// ─── Push orchestrator ───────────────────────────────────────────────────────
/**
* Push a single WordPress page back to the Git repository.
*
* Flow:
* 1. Derive repo path from post meta (_oribi_sync_source) or settings.
* 2. Generate PHP wrapper from page content.
* 3. Fetch remote file SHA from the Gitea contents API.
* 4. If remote SHA matches stored SHA → direct update (no conflict).
* 5. If remote SHA differs (file changed externally) → create branch + PR.
* 6. If file doesn't exist → create it on the target branch.
* 7. Update post meta on success.
*
* @param int $post_id WP post ID.
* @param array $opts Optional overrides: 'message' (commit message).
*
* @return array{ok: bool, action: string, message: string, pr_url?: string}
*/
function oribi_sync_push_page( int $post_id, array $opts = [] ): array {
$post = get_post( $post_id );
if ( ! $post || $post->post_type !== 'page' ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Post not found or not a page.' ];
}
// ── Settings ──────────────────────────────────────────────────────────
$repo_url = get_option( 'oribi_sync_repo', '' );
$branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main';
$pat = oribi_sync_get_pat();
$provider = oribi_sync_get_provider();
if ( empty( $repo_url ) || empty( $pat ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Repository URL or PAT not configured.' ];
}
if ( $provider !== 'gitea' ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Push is currently supported for Gitea / Forgejo only.' ];
}
$parsed = oribi_sync_parse_repo_url( $repo_url );
if ( is_wp_error( $parsed ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => $parsed->get_error_message() ];
}
$api_base = oribi_sync_api_base( $provider, $parsed );
// ── Determine repo path ───────────────────────────────────────────────
$source_meta = get_post_meta( $post_id, '_oribi_sync_source', true );
$repo_path = '';
if ( ! empty( $source_meta ) ) {
// Format: {repo_url}@{branch}:{path}
// Use strrpos to find the LAST colon (skips the one in 'https:').
$colon_pos = strrpos( $source_meta, ':' );
if ( $colon_pos !== false ) {
$at_pos = strrpos( substr( $source_meta, 0, $colon_pos ), '@' );
if ( $at_pos !== false ) {
$candidate = substr( $source_meta, $colon_pos + 1 );
// Validate: path must start with 'pages/' (case-insensitive).
// Discard corrupted values left by earlier bugs.
if ( strncasecmp( $candidate, 'pages/', 6 ) === 0 ) {
$repo_path = $candidate;
}
}
}
}
if ( empty( $repo_path ) ) {
// Derive from slug — files live under pages/
$repo_path = 'pages/' . $post->post_name . '.php';
}
// ── Generate content ──────────────────────────────────────────────────
$slug = $post->post_name;
$title = $post->post_title;
// Read post_content directly from the DB — bypassing every get_post()
// filter — so we get exactly what oribi_sync_save_post() wrote.
global $wpdb;
$wp_content = (string) $wpdb->get_var(
$wpdb->prepare( 'SELECT post_content FROM ' . $wpdb->posts . ' WHERE ID = %d', $post_id )
);
// Clean any corruption baked in by previous syncs (e.g. \u0026amp; artefacts)
$wp_content = oribi_sync_clean_block_content( $wp_content );
$commit_msg = $opts['message'] ?? "Sync: update {$slug} from WordPress";
// ── Fetch remote file metadata ────────────────────────────────────────
$remote = oribi_sync_gitea_get_file_meta( $api_base, $branch, $repo_path, $pat );
if ( is_wp_error( $remote ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Failed to check remote file: ' . $remote->get_error_message() ];
}
// Build PHP source: preserve original header for existing files, fresh wrapper for new ones.
if ( $remote !== null && ! empty( $remote['content'] ) ) {
$php_source = oribi_sync_replace_php_body( $remote['content'], $wp_content, $slug, $title );
} else {
$php_source = oribi_sync_generate_php_wrapper( $wp_content, $slug, $title );
}
$new_checksum = hash( 'sha256', $php_source );
$stored_sha = get_post_meta( $post_id, '_oribi_sync_git_sha', true );
// ── Decide strategy ───────────────────────────────────────────────────
if ( $remote === null ) {
// File doesn't exist in repo → create it directly
$result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, null, $commit_msg );
if ( is_wp_error( $result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Create failed: ' . $result->get_error_message() ];
}
if ( $result['code'] < 200 || $result['code'] >= 300 ) {
$err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Create failed (HTTP {$result['code']}): {$err}" ];
}
// Update post meta
$new_sha = $result['body']['content']['sha'] ?? '';
oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path );
oribi_sync_log_push( $slug, 'created', $branch );
return [ 'ok' => true, 'action' => 'created', 'message' => "Created {$repo_path} on branch {$branch}." ];
}
$remote_sha = $remote['sha'];
// Check for conflict: remote SHA differs from what we last synced
$has_conflict = ! empty( $stored_sha ) && $remote_sha !== $stored_sha;
if ( $has_conflict ) {
// ── Conflict path: branch + PR ────────────────────────────────────
$timestamp = gmdate( 'Ymd-His' );
$new_branch = 'oribi-sync/' . $slug . '-' . $timestamp;
// Create branch
$branch_result = oribi_sync_gitea_create_branch( $api_base, $new_branch, $branch, $pat );
if ( is_wp_error( $branch_result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Branch creation failed: ' . $branch_result->get_error_message() ];
}
if ( $branch_result['code'] < 200 || $branch_result['code'] >= 300 ) {
$err = is_array( $branch_result['body'] ) ? ( $branch_result['body']['message'] ?? '' ) : $branch_result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Branch creation failed (HTTP {$branch_result['code']}): {$err}" ];
}
// Fetch file meta on the new branch (same SHA as base branch initially)
$branch_remote = oribi_sync_gitea_get_file_meta( $api_base, $new_branch, $repo_path, $pat );
$branch_sha = null;
if ( ! is_wp_error( $branch_remote ) && $branch_remote !== null ) {
$branch_sha = $branch_remote['sha'];
}
// Commit to the new branch
$put_result = oribi_sync_gitea_put_file( $api_base, $new_branch, $repo_path, $php_source, $pat, $branch_sha, $commit_msg );
if ( is_wp_error( $put_result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Commit to branch failed: ' . $put_result->get_error_message() ];
}
if ( $put_result['code'] < 200 || $put_result['code'] >= 300 ) {
$err = is_array( $put_result['body'] ) ? ( $put_result['body']['message'] ?? '' ) : $put_result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Commit to branch failed (HTTP {$put_result['code']}): {$err}" ];
}
// Open PR
$pr_title = "Sync: {$slug}";
$pr_body = "Automatic push from WordPress (Oribi Tech Sync).\n\n";
$pr_body .= "**Page:** {$title} (`{$slug}`)\n";
$pr_body .= "**Commit:** {$commit_msg}\n\n";
$pr_body .= "The target branch `{$branch}` has been modified since the last sync, ";
$pr_body .= "so this change was pushed to `{$new_branch}` for review.\n\n";
$pr_body .= "Stored SHA: `{$stored_sha}`\n";
$pr_body .= "Remote SHA: `{$remote_sha}`\n";
$pr_result = oribi_sync_gitea_create_pr( $api_base, $new_branch, $branch, $pr_title, $pr_body, $pat );
if ( is_wp_error( $pr_result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'PR creation failed: ' . $pr_result->get_error_message() ];
}
$pr_url = '';
if ( $pr_result['code'] >= 200 && $pr_result['code'] < 300 && is_array( $pr_result['body'] ) ) {
$pr_url = $pr_result['body']['html_url'] ?? '';
}
// Save PR URL on the post
if ( ! empty( $pr_url ) ) {
update_post_meta( $post_id, '_oribi_sync_pr_url', $pr_url );
}
oribi_sync_log_push( $slug, 'pr_created', $new_branch, $pr_url );
return [
'ok' => true,
'action' => 'pr_created',
'message' => "Conflict detected — created PR on branch {$new_branch}.",
'pr_url' => $pr_url,
];
}
// ── No conflict: direct update ────────────────────────────────────────
$result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, $remote_sha, $commit_msg );
if ( is_wp_error( $result ) ) {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Update failed: ' . $result->get_error_message() ];
}
if ( $result['code'] < 200 || $result['code'] >= 300 ) {
$err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body'];
return [ 'ok' => false, 'action' => 'error', 'message' => "Update failed (HTTP {$result['code']}): {$err}" ];
}
// Update post meta
$new_sha = $result['body']['content']['sha'] ?? '';
oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path );
oribi_sync_log_push( $slug, 'updated', $branch );
return [ 'ok' => true, 'action' => 'updated', 'message' => "Updated {$repo_path} on branch {$branch}." ];
}
/**
* Bulk-push all synced pages.
*
* @return array{ok: bool, results: array}
*/
function oribi_sync_push_all(): array {
$query = new WP_Query( [
'post_type' => 'page',
'post_status' => 'publish',
'meta_key' => '_oribi_sync_checksum',
'posts_per_page' => -1,
'fields' => 'ids',
] );
$results = [];
foreach ( $query->posts as $post_id ) {
$page = get_post( $post_id );
$slug = $page ? $page->post_name : "#{$post_id}";
$result = oribi_sync_push_page( (int) $post_id );
$results[] = array_merge( $result, [ 'slug' => $slug, 'post_id' => $post_id ] );
}
$all_ok = ! empty( $results ) && count( array_filter( $results, fn( $r ) => ! $r['ok'] ) ) === 0;
return [ 'ok' => $all_ok, 'results' => $results ];
}
// ─── Post meta helpers ────────────────────────────────────────────────────────
/**
* Update post meta after a successful push.
*/
function oribi_sync_update_push_meta( int $post_id, string $sha, string $checksum, string $repo_url, string $branch, string $path ): void {
if ( ! empty( $sha ) ) {
update_post_meta( $post_id, '_oribi_sync_git_sha', $sha );
}
update_post_meta( $post_id, '_oribi_sync_checksum', $checksum );
update_post_meta( $post_id, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $path );
update_post_meta( $post_id, '_oribi_sync_last_push', current_time( 'mysql' ) );
// Clear any stale PR URL on successful direct push
delete_post_meta( $post_id, '_oribi_sync_pr_url' );
}
// ─── Push log ─────────────────────────────────────────────────────────────────
/**
* Append an entry to the push log.
*/
function oribi_sync_log_push( string $slug, string $action, string $branch, string $pr_url = '' ): void {
$log = get_option( 'oribi_sync_push_log', [] );
if ( ! is_array( $log ) ) $log = [];
array_unshift( $log, [
'time' => current_time( 'mysql' ),
'slug' => $slug,
'action' => $action,
'branch' => $branch,
'pr_url' => $pr_url,
] );
// Keep last 50 entries
$log = array_slice( $log, 0, 50 );
update_option( 'oribi_sync_push_log', $log, 'no' );
}

View File

@@ -1,105 +0,0 @@
<?php
/**
* Oribi Sync — REST API endpoints.
*
* POST /wp-json/oribi-sync/v1/sync — Trigger a sync
* POST /wp-json/oribi-sync/v1/sync — With ?dry_run=1 for preview
* GET /wp-json/oribi-sync/v1/status — Get last sync status
* POST /wp-json/oribi-sync/v1/webhook — Webhook trigger (secret-based auth)
*/
if ( ! defined( 'ABSPATH' ) ) exit;
add_action( 'rest_api_init', function () {
// ── Trigger sync ──────────────────────────────────────────────────────
register_rest_route( 'oribi-sync/v1', '/sync', [
'methods' => 'POST',
'callback' => 'oribi_sync_rest_sync',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
] );
// ── Sync status ───────────────────────────────────────────────────────
register_rest_route( 'oribi-sync/v1', '/status', [
'methods' => 'GET',
'callback' => 'oribi_sync_rest_status',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
] );
// ── Webhook (secret-based auth, no WP login required) ─────────────────
register_rest_route( 'oribi-sync/v1', '/webhook', [
'methods' => 'POST',
'callback' => 'oribi_sync_rest_webhook',
'permission_callback' => '__return_true', // Auth handled in callback
] );
} );
/**
* REST: Trigger sync.
*/
function oribi_sync_rest_sync( WP_REST_Request $request ): WP_REST_Response {
$dry_run = (bool) $request->get_param( 'dry_run' );
$result = oribi_sync_run( $dry_run );
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
}
/**
* REST: Get last sync status.
*/
function oribi_sync_rest_status(): WP_REST_Response {
return new WP_REST_Response( [
'last_run' => get_option( 'oribi_sync_last_run', null ),
'log' => array_slice( get_option( 'oribi_sync_log', [] ), 0, 5 ),
'repo' => get_option( 'oribi_sync_repo', '' ),
'branch' => get_option( 'oribi_sync_branch', 'main' ),
'provider' => oribi_sync_get_provider(),
'has_pat' => ! empty( get_option( 'oribi_sync_pat', '' ) ),
] );
}
/**
* REST: Webhook trigger.
*
* Validates using a shared secret stored in the WP option oribi_sync_webhook_secret
* or the constant ORIBI_SYNC_WEBHOOK_SECRET.
*
* Accepts GitHub-style X-Hub-Signature-256 header, or a simple
* Authorization: Bearer <secret> header.
*/
function oribi_sync_rest_webhook( WP_REST_Request $request ): WP_REST_Response {
$secret = defined( 'ORIBI_SYNC_WEBHOOK_SECRET' )
? ORIBI_SYNC_WEBHOOK_SECRET
: get_option( 'oribi_sync_webhook_secret', '' );
if ( empty( $secret ) ) {
return new WP_REST_Response( [ 'error' => 'Webhook secret not configured.' ], 403 );
}
// Check Authorization: Bearer <secret>
$auth = $request->get_header( 'Authorization' );
if ( $auth && preg_match( '/^Bearer\s+(.+)$/i', $auth, $m ) ) {
if ( ! hash_equals( $secret, $m[1] ) ) {
return new WP_REST_Response( [ 'error' => 'Invalid secret.' ], 403 );
}
}
// Check GitHub X-Hub-Signature-256
elseif ( $sig = $request->get_header( 'X-Hub-Signature-256' ) ) {
$body = $request->get_body();
$expected = 'sha256=' . hash_hmac( 'sha256', $body, $secret );
if ( ! hash_equals( $expected, $sig ) ) {
return new WP_REST_Response( [ 'error' => 'Invalid signature.' ], 403 );
}
} else {
return new WP_REST_Response( [ 'error' => 'Missing authentication.' ], 403 );
}
// Run sync
$result = oribi_sync_run();
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
}

View File

@@ -9,19 +9,133 @@
if ( ! defined( 'ABSPATH' ) ) exit; if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Insert or update a post while writing post_content DIRECTLY to the DB.
*
* Every code-path in wp_insert_post / wp_update_post runs the content through
* sanitize_post_field() → apply_filters('pre_post_content') and
* apply_filters('content_save_pre'), both of which have wp_kses_post
* callbacks that turn & (inside Gutenberg block JSON) into &amp;.
* kses_remove_filters() only unhooks content_save_pre, NOT pre_post_content,
* so the ampersand corruption survived even with those wrappers.
*
* This helper lets WP create/update every other field normally (title, slug,
* status, dates, author …) with an empty content placeholder, then immediately
* overwrites post_content in the DB directly — no filters, no escaping beyond
* the $wpdb placeholder.
*
* @param array $post_arr Same shape as wp_insert_post / wp_update_post.
* @return int|WP_Error Post ID on success, WP_Error on failure.
*/
function oribi_sync_save_post( array $post_arr ) {
global $wpdb;
$content = $post_arr['post_content'] ?? '';
$post_arr['post_content'] = ''; // let WP handle everything else
if ( ! empty( $post_arr['ID'] ) ) {
$post_id = wp_update_post( $post_arr, true );
} else {
$post_id = wp_insert_post( $post_arr, true );
}
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
$wpdb->update(
$wpdb->posts,
[ 'post_content' => $content ],
[ 'ID' => (int) $post_id ],
[ '%s' ],
[ '%d' ]
);
clean_post_cache( (int) $post_id );
return $post_id;
}
/**
* Clean previously-corrupted Gutenberg block content.
*
* Old syncs ran content through wp_kses_post which HTML-entity-encoded `&`
* inside JSON attributes to `&amp;`. php's json_encode then re-encoded that
* `&` to `\u0026`, producing `\u0026amp;` instead of just `\u0026`.
*
* This function corrects those artefacts so block JSON attributes contain
* the right unicode escape sequences.
*
* Also normalises plain `&amp;` → `&` inside JSON block comments so the
* next round of json_encode produces a single clean `\u0026`.
*
* @param string $content Gutenberg block HTML.
* @return string Cleaned block HTML.
*/
function oribi_sync_clean_block_content( string $content ): string {
// json_encode always hex-escapes & as \u0026 (even with JSON_UNESCAPED_UNICODE,
// which only affects codepoints > U+007F). Previous syncs also ran content
// through wp_kses_post which turned & into &amp;, so json_encode then produced
// \u0026amp; instead of just \u0026.
//
// Fix the double-encoded forms first, then unescape the remaining \u0026 back
// to literal & — Gutenberg's block JSON parser treats both identically.
// These sequences are unambiguous in Gutenberg block comment JSON.
$content = str_replace( '\u0026amp;', '&', $content );
$content = str_replace( '\u0026lt;', '<', $content );
$content = str_replace( '\u0026gt;', '>', $content );
$content = str_replace( '\u0026quot;', '"', $content );
$content = str_replace( '\u0026#039;', "'", $content );
// Clean any remaining plain hex-escapes of ASCII punctuation
$content = str_replace( '\u0026', '&', $content );
$content = str_replace( '\u003C', '<', $content );
$content = str_replace( '\u003E', '>', $content );
$content = str_replace( '\u0022', '"', $content );
$content = str_replace( '\u0027', "'", $content );
return $content;
}
/**
* Strip a case-insensitive directory prefix from a file path.
*
* Example: oribi_sync_strip_prefix( 'Theme/header.php', 'theme' ) → 'header.php'
*/
function oribi_sync_strip_prefix( string $path, string $prefix ): string {
$prefix = rtrim( $prefix, '/' ) . '/';
if ( strncasecmp( $path, $prefix, strlen( $prefix ) ) === 0 ) {
return substr( $path, strlen( $prefix ) );
}
return $path;
}
// ─── Gutenberg block helpers ────────────────────────────────────────────────── // ─── Gutenberg block helpers ──────────────────────────────────────────────────
/** Generate a self-closing block comment (standalone or child blocks). */ /** Generate a self-closing block comment (standalone or child blocks). */
if ( ! function_exists( 'oribi_b' ) ) { if ( ! function_exists( 'oribi_b' ) ) {
function oribi_b( $name, $attrs = [] ) { function oribi_b( $name, $attrs = [] ) {
return '<!-- wp:oribi/' . $name . ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . ' /-->'; $json = wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
// json_encode always hex-escapes & < > ' for XSS safety, but these are
// inside HTML comments so they are safe as literals in Gutenberg block JSON.
$json = str_replace( [ '\u0026', '\u003C', '\u003E', '\u0022', '\u0027' ],
[ '&', '<', '>', '"', "'" ], $json );
return '<!-- wp:oribi/' . $name . ' ' . $json . ' /-->';
} }
} }
/** Generate an opening tag for a parent block comment. */ /** Generate an opening tag for a parent block comment. */
if ( ! function_exists( 'oribi_b_open' ) ) { if ( ! function_exists( 'oribi_b_open' ) ) {
function oribi_b_open( $name, $attrs = [] ) { function oribi_b_open( $name, $attrs = [] ) {
$json = ! empty( $attrs ) ? ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) : ''; if ( ! empty( $attrs ) ) {
$json = wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
$json = str_replace( [ '\u0026', '\u003C', '\u003E', '\u0022', '\u0027' ],
[ '&', '<', '>', '"', "'" ], $json );
$json = ' ' . $json;
} else {
$json = '';
}
return '<!-- wp:oribi/' . $name . $json . ' -->'; return '<!-- wp:oribi/' . $name . $json . ' -->';
} }
} }
@@ -85,10 +199,11 @@ function oribi_sync_execute_php( string $php_source, string $slug ) {
* Run the full page sync. * Run the full page sync.
* *
* @param bool $dry_run If true, returns what would happen without making changes. * @param bool $dry_run If true, returns what would happen without making changes.
* @param bool $force If true, bypasses SHA-based change detection and re-pulls all files.
* *
* @return array{ok: bool, created: string[], updated: string[], trashed: string[], skipped: string[], errors: string[]} * @return array{ok: bool, created: string[], updated: string[], trashed: string[], skipped: string[], errors: string[]}
*/ */
function oribi_sync_run( bool $dry_run = false ): array { function oribi_sync_run( bool $dry_run = false, bool $force = false ): array {
$result = [ $result = [
'ok' => true, 'ok' => true,
'created' => [], 'created' => [],
@@ -96,6 +211,10 @@ function oribi_sync_run( bool $dry_run = false ): array {
'trashed' => [], 'trashed' => [],
'skipped' => [], 'skipped' => [],
'errors' => [], 'errors' => [],
'theme_updated' => [],
'posts_created' => [],
'posts_updated' => [],
'posts_trashed' => [],
]; ];
// ── Gather settings ──────────────────────────────────────────────────── // ── Gather settings ────────────────────────────────────────────────────
@@ -128,18 +247,15 @@ function oribi_sync_run( bool $dry_run = false ): array {
return $result; return $result;
} }
// ── Filter to pages/ directory ───────────────────────────────────────── // ── Filter to Pages/ directory ─────────────────────────────────────────
$page_files = oribi_sync_filter_tree( $tree, 'pages' ); $synced_slugs = [];
$page_files = oribi_sync_filter_tree( $tree, 'Pages' );
if ( empty( $page_files ) ) { if ( empty( $page_files ) ) {
$result['errors'][] = 'No files found under pages/ in the repository.'; $result['skipped'][] = 'No files found under Pages/ in the repository.';
$result['ok'] = false;
return $result;
} }
// ── Process each page file ───────────────────────────────────────────── // ── Process each page file ─────────────────────────────────────────────
$synced_slugs = [];
foreach ( $page_files as $entry ) { foreach ( $page_files as $entry ) {
$filename = basename( $entry['path'] ); $filename = basename( $entry['path'] );
$slug = oribi_sync_filename_to_slug( $filename ); $slug = oribi_sync_filename_to_slug( $filename );
@@ -161,7 +277,7 @@ function oribi_sync_run( bool $dry_run = false ): array {
$git_sha = $entry['sha'] ?? ''; $git_sha = $entry['sha'] ?? '';
$stored_git_sha = $existing ? get_post_meta( $existing->ID, '_oribi_sync_git_sha', true ) : ''; $stored_git_sha = $existing ? get_post_meta( $existing->ID, '_oribi_sync_git_sha', true ) : '';
if ( $existing && ! empty( $git_sha ) && $git_sha === $stored_git_sha ) { if ( ! $force && $existing && ! empty( $git_sha ) && $git_sha === $stored_git_sha ) {
$result['skipped'][] = $slug . ' (unchanged)'; $result['skipped'][] = $slug . ' (unchanged)';
if ( ! $dry_run ) { if ( ! $dry_run ) {
update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) );
@@ -193,6 +309,9 @@ function oribi_sync_run( bool $dry_run = false ): array {
$content = $raw_content; $content = $raw_content;
} }
// Clean any corruption from previous syncs (e.g. \u0026amp; artefacts)
$content = oribi_sync_clean_block_content( $content );
// Checksum based on raw source — used as fallback for providers without tree SHA // Checksum based on raw source — used as fallback for providers without tree SHA
$checksum = hash( 'sha256', $raw_content ); $checksum = hash( 'sha256', $raw_content );
@@ -222,11 +341,11 @@ function oribi_sync_run( bool $dry_run = false ): array {
} }
} }
$update_result = wp_update_post( [ $update_result = oribi_sync_save_post( [
'ID' => $existing->ID, 'ID' => $existing->ID,
'post_content' => $content, 'post_content' => $content,
'post_status' => 'publish', 'post_status' => 'publish',
], true ); ] );
if ( is_wp_error( $update_result ) ) { if ( is_wp_error( $update_result ) ) {
$result['errors'][] = $slug . ': ' . $update_result->get_error_message(); $result['errors'][] = $slug . ': ' . $update_result->get_error_message();
@@ -239,18 +358,19 @@ function oribi_sync_run( bool $dry_run = false ): array {
update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) );
update_post_meta( $existing->ID, '_wp_page_template', 'default' ); update_post_meta( $existing->ID, '_wp_page_template', 'default' );
$result['updated'][] = $slug; $content_size = strlen( $content );
$result['updated'][] = $slug . ' (' . $content_size . ' bytes)';
} else { } else {
// Create new page // Create new page
$title = oribi_sync_slug_to_title( $slug ); $title = oribi_sync_slug_to_title( $slug );
$post_id = wp_insert_post( [ $post_id = oribi_sync_save_post( [
'post_title' => $title, 'post_title' => $title,
'post_name' => $slug, 'post_name' => $slug,
'post_status' => 'publish', 'post_status' => 'publish',
'post_type' => 'page', 'post_type' => 'page',
'post_content' => $content, 'post_content' => $content,
], true ); ] );
if ( is_wp_error( $post_id ) ) { if ( is_wp_error( $post_id ) ) {
$result['errors'][] = $slug . ': ' . $post_id->get_error_message(); $result['errors'][] = $slug . ': ' . $post_id->get_error_message();
@@ -263,7 +383,8 @@ function oribi_sync_run( bool $dry_run = false ): array {
update_post_meta( $post_id, '_oribi_sync_last_run', current_time( 'mysql' ) ); update_post_meta( $post_id, '_oribi_sync_last_run', current_time( 'mysql' ) );
update_post_meta( $post_id, '_wp_page_template', 'default' ); update_post_meta( $post_id, '_wp_page_template', 'default' );
$result['created'][] = $slug; $content_size = strlen( $content );
$result['created'][] = $slug . ' (' . $content_size . ' bytes)';
} }
} }
@@ -273,6 +394,26 @@ function oribi_sync_run( bool $dry_run = false ): array {
$result['trashed'] = $trashed; $result['trashed'] = $trashed;
} }
// ── Auto-sync theme files ──────────────────────────────────────────────
// Reuse the already-fetched $tree so we don't make a second API call.
$theme_sync = oribi_sync_apply_theme_files( $api_base, $branch, $provider, $pat, $dry_run, $tree );
$result['theme_updated'] = $theme_sync['updated'];
foreach ( $theme_sync['errors'] as $err ) {
$result['errors'][] = '[theme] ' . $err;
}
// ── Sync posts from repo posts folder ─────────────────────────────────
$posts_sync = oribi_sync_run_posts( $api_base, $branch, $provider, $pat, $tree, $dry_run );
$result['posts_created'] = $posts_sync['created'];
$result['posts_updated'] = $posts_sync['updated'];
$result['posts_trashed'] = $posts_sync['trashed'];
foreach ( $posts_sync['skipped'] as $sk ) {
$result['skipped'][] = '[post] ' . $sk;
}
foreach ( $posts_sync['errors'] as $err ) {
$result['errors'][] = '[post] ' . $err;
}
// ── Record run ───────────────────────────────────────────────────────── // ── Record run ─────────────────────────────────────────────────────────
if ( ! $dry_run ) { if ( ! $dry_run ) {
oribi_sync_record_run( $result ); oribi_sync_record_run( $result );
@@ -363,6 +504,10 @@ function oribi_sync_record_run( array $result ): void {
'trashed' => $result['trashed'], 'trashed' => $result['trashed'],
'skipped' => $result['skipped'], 'skipped' => $result['skipped'],
'errors' => $result['errors'], 'errors' => $result['errors'],
'theme_updated' => $result['theme_updated'] ?? [],
'posts_created' => $result['posts_created'] ?? [],
'posts_updated' => $result['posts_updated'] ?? [],
'posts_trashed' => $result['posts_trashed'] ?? [],
] ); ] );
// Keep last 20 entries // Keep last 20 entries
@@ -370,6 +515,254 @@ function oribi_sync_record_run( array $result ): void {
update_option( 'oribi_sync_log', $log, 'no' ); update_option( 'oribi_sync_log', $log, 'no' );
} }
/**
* Ensure the ots-theme directory exists with a minimal style.css header.
*
* WordPress requires style.css with a "Theme Name:" header to recognise a theme.
* If the repo's theme/ folder already contains a style.css it will overwrite
* this stub during sync, so the stub is only a bootstrap.
*/
function oribi_sync_ensure_ots_theme( string $theme_dir ): void {
if ( is_dir( $theme_dir ) && file_exists( $theme_dir . '/style.css' ) ) {
return; // Already exists.
}
if ( ! is_dir( $theme_dir ) ) {
wp_mkdir_p( $theme_dir );
}
// Only write the stub if style.css doesn't exist yet.
if ( ! file_exists( $theme_dir . '/style.css' ) ) {
$header = <<<'CSS'
/*
Theme Name: OTS Theme
Description: Auto-created by Oribi Tech Sync. Theme files are managed via Git.
Version: 1.0.0
Author: Oribi Technology Services
*/
CSS;
file_put_contents( $theme_dir . '/style.css', $header );
}
// Create a minimal index.php (required by WP theme standards).
if ( ! file_exists( $theme_dir . '/index.php' ) ) {
file_put_contents( $theme_dir . '/index.php', "<?php\n// Silence is golden.\n" );
}
}
/**
* Auto-apply changed theme files from the repo's theme/ directory to ots-theme.
*
* Called as part of every sync run.
*
* @param string $api_base Provider API base URL.
* @param string $branch Branch name.
* @param string $provider Provider key.
* @param string $pat Personal access token.
* @param bool $dry_run If true, report changes without writing files.
*
* @return array{updated: string[], errors: string[]}
*/
function oribi_sync_apply_theme_files( string $api_base, string $branch, string $provider, string $pat, bool $dry_run = false, ?array $tree = null ): array {
$out = [ 'updated' => [], 'errors' => [] ];
$allowed = [ 'css', 'js', 'json', 'php', 'html', 'htm', 'svg', 'txt' ];
$theme_dir = get_theme_root() . '/ots-theme';
// Create the ots-theme if it does not exist yet.
if ( ! $dry_run ) {
oribi_sync_ensure_ots_theme( $theme_dir );
}
// Fetch the tree only if one wasn't passed in (avoids a redundant API call during sync).
if ( $tree === null ) {
$tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat );
if ( is_wp_error( $tree ) ) {
$out['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message();
return $out;
}
}
$theme_entries = oribi_sync_filter_tree( $tree, 'theme', true );
foreach ( $theme_entries as $entry ) {
$relative = oribi_sync_strip_prefix( $entry['path'], 'theme' );
$ext = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, $allowed, true ) ) {
continue;
}
// Fetch content from repo
$content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat );
if ( is_wp_error( $content ) ) {
$out['errors'][] = $entry['path'] . ': ' . $content->get_error_message();
continue;
}
$dest = $theme_dir . '/' . $relative;
$local_exists = file_exists( $dest );
$local_content = $local_exists ? file_get_contents( $dest ) : null;
// Skip unchanged files
if ( $local_exists && $local_content === $content ) {
continue;
}
if ( $dry_run ) {
$out['updated'][] = $relative;
continue;
}
// Create subdirectory if needed
$dir = dirname( $dest );
if ( ! is_dir( $dir ) ) {
if ( ! wp_mkdir_p( $dir ) ) {
$out['errors'][] = $relative . ' — could not create directory.';
continue;
}
}
$written = file_put_contents( $dest, $content );
if ( $written === false ) {
$out['errors'][] = $relative . ' — write failed (check permissions).';
} else {
$out['updated'][] = $relative;
}
}
return $out;
}
/**
* Pull a single page (and theme files) from the repo.
*
* Used by the admin-bar "Pull Page" button to re-sync only the page currently
* being viewed plus all theme files, then returns a result array.
*
* @param int $post_id WordPress post ID.
*
* @return array{ok: bool, created: string[], updated: string[], skipped: string[], errors: string[], theme_updated: string[]}
*/
function oribi_sync_pull_page_from_repo( int $post_id ): array {
$result = [
'ok' => true,
'created' => [],
'updated' => [],
'skipped' => [],
'errors' => [],
'theme_updated' => [],
];
$post = get_post( $post_id );
if ( ! $post ) {
$result['ok'] = false;
$result['errors'][] = 'Post not found.';
return $result;
}
$slug = $post->post_name;
// ── Gather settings ────────────────────────────────────────────────────
$repo_url = get_option( 'oribi_sync_repo', '' );
$branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main';
$pat = oribi_sync_get_pat();
if ( empty( $repo_url ) || empty( $pat ) ) {
$result['ok'] = false;
$result['errors'][] = 'Repository URL or PAT is not configured.';
return $result;
}
$parsed = oribi_sync_parse_repo_url( $repo_url );
if ( is_wp_error( $parsed ) ) {
$result['ok'] = false;
$result['errors'][] = $parsed->get_error_message();
return $result;
}
$provider = oribi_sync_get_provider();
$api_base = oribi_sync_api_base( $provider, $parsed );
// ── Fetch tree ─────────────────────────────────────────────────────────
$tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat );
if ( is_wp_error( $tree ) ) {
$result['ok'] = false;
$result['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message();
return $result;
}
// ── Find the matching page file ────────────────────────────────────────
$page_files = oribi_sync_filter_tree( $tree, 'Pages' );
$target_entry = null;
foreach ( $page_files as $entry ) {
$file_slug = oribi_sync_filename_to_slug( basename( $entry['path'] ) );
if ( $file_slug === $slug ) {
$target_entry = $entry;
break;
}
}
if ( ! $target_entry ) {
$result['skipped'][] = $slug . ' (not found in Pages/ directory)';
} else {
$raw_content = oribi_sync_fetch_file( $api_base, $branch, $target_entry['path'], $provider, $pat );
if ( is_wp_error( $raw_content ) ) {
$result['errors'][] = $target_entry['path'] . ': ' . $raw_content->get_error_message();
$result['ok'] = false;
} else {
$raw_content = trim( $raw_content );
$ext = strtolower( pathinfo( basename( $target_entry['path'] ), PATHINFO_EXTENSION ) );
if ( $ext === 'php' ) {
$content = oribi_sync_execute_php( $raw_content, $slug );
if ( is_wp_error( $content ) ) {
$result['errors'][] = $target_entry['path'] . ': ' . $content->get_error_message();
$result['ok'] = false;
$content = null;
}
} else {
$content = $raw_content;
}
if ( $content !== null ) {
// Clean any corruption from previous syncs
$content = oribi_sync_clean_block_content( $content );
$checksum = hash( 'sha256', $raw_content );
$git_sha = $target_entry['sha'] ?? '';
$update = oribi_sync_save_post( [
'ID' => $post->ID,
'post_content' => $content,
'post_status' => 'publish',
] );
if ( is_wp_error( $update ) ) {
$result['errors'][] = $slug . ': ' . $update->get_error_message();
$result['ok'] = false;
} else {
update_post_meta( $post->ID, '_oribi_sync_checksum', $checksum );
update_post_meta( $post->ID, '_oribi_sync_git_sha', $git_sha );
update_post_meta( $post->ID, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $target_entry['path'] );
update_post_meta( $post->ID, '_oribi_sync_last_run', current_time( 'mysql' ) );
update_post_meta( $post->ID, '_wp_page_template', 'default' );
$result['updated'][] = $slug;
}
}
}
}
// ── Sync theme files ───────────────────────────────────────────────────
$theme_sync = oribi_sync_apply_theme_files( $api_base, $branch, $provider, $pat, false, $tree );
$result['theme_updated'] = $theme_sync['updated'];
foreach ( $theme_sync['errors'] as $err ) {
$result['errors'][] = '[theme] ' . $err;
}
return $result;
}
/** /**
* Fetch theme files from the repo (for preview / apply). * Fetch theme files from the repo (for preview / apply).
* *
@@ -406,7 +799,7 @@ function oribi_sync_fetch_theme_files(): array {
foreach ( $theme_entries as $entry ) { foreach ( $theme_entries as $entry ) {
// Derive relative path by stripping the 'theme/' prefix // Derive relative path by stripping the 'theme/' prefix
$relative = substr( $entry['path'], strlen( 'theme/' ) ); $relative = oribi_sync_strip_prefix( $entry['path'], 'theme' );
$content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat ); $content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat );
if ( is_wp_error( $content ) ) { if ( is_wp_error( $content ) ) {
@@ -414,8 +807,9 @@ function oribi_sync_fetch_theme_files(): array {
continue; continue;
} }
// Check if a matching file exists in the active theme // Check if a matching file exists in the ots-theme
$theme_file = get_template_directory() . '/' . $relative; $theme_dir = get_theme_root() . '/ots-theme';
$theme_file = $theme_dir . '/' . $relative;
$local_exists = file_exists( $theme_file ); $local_exists = file_exists( $theme_file );
$local_content = $local_exists ? file_get_contents( $theme_file ) : null; $local_content = $local_exists ? file_get_contents( $theme_file ) : null;

View File

@@ -19,8 +19,9 @@ define( 'ORIBI_SYNC_BASENAME', plugin_basename( __FILE__ ) );
require_once ORIBI_SYNC_DIR . 'includes/crypto.php'; require_once ORIBI_SYNC_DIR . 'includes/crypto.php';
require_once ORIBI_SYNC_DIR . 'includes/api-client.php'; require_once ORIBI_SYNC_DIR . 'includes/api-client.php';
require_once ORIBI_SYNC_DIR . 'includes/sync-engine.php'; require_once ORIBI_SYNC_DIR . 'includes/sync-engine.php';
require_once ORIBI_SYNC_DIR . 'includes/push-client.php';
require_once ORIBI_SYNC_DIR . 'includes/post-sync.php';
require_once ORIBI_SYNC_DIR . 'includes/admin.php'; require_once ORIBI_SYNC_DIR . 'includes/admin.php';
require_once ORIBI_SYNC_DIR . 'includes/rest.php';
require_once ORIBI_SYNC_DIR . 'includes/theme-preview.php'; require_once ORIBI_SYNC_DIR . 'includes/theme-preview.php';
// ─── Activation / Deactivation ──────────────────────────────────────────────── // ─── Activation / Deactivation ────────────────────────────────────────────────
@@ -35,6 +36,8 @@ function oribi_sync_activate() {
add_option( 'oribi_sync_pat', '', '', 'no' ); add_option( 'oribi_sync_pat', '', '', 'no' );
add_option( 'oribi_sync_last_run', '', '', 'no' ); add_option( 'oribi_sync_last_run', '', '', 'no' );
add_option( 'oribi_sync_log', [], '', 'no' ); add_option( 'oribi_sync_log', [], '', 'no' );
add_option( 'oribi_sync_posts_enabled', '', '', 'no' );
add_option( 'oribi_sync_posts_folder', 'posts', '', 'no' );
} }
function oribi_sync_deactivate() { function oribi_sync_deactivate() {

View File

@@ -16,10 +16,13 @@ delete_option( 'oribi_sync_last_run' );
delete_option( 'oribi_sync_log' ); delete_option( 'oribi_sync_log' );
delete_option( 'oribi_sync_webhook_secret' ); delete_option( 'oribi_sync_webhook_secret' );
delete_option( 'oribi_sync_theme_applied' ); delete_option( 'oribi_sync_theme_applied' );
delete_option( 'oribi_sync_push_log' );
delete_option( 'oribi_sync_posts_enabled' );
delete_option( 'oribi_sync_posts_folder' );
// Remove sync metadata from posts // Remove sync metadata from pages and posts
$posts = get_posts( [ $posts = get_posts( [
'post_type' => 'page', 'post_type' => [ 'page', 'post' ],
'post_status' => 'any', 'post_status' => 'any',
'posts_per_page' => -1, 'posts_per_page' => -1,
'meta_key' => '_oribi_sync_checksum', 'meta_key' => '_oribi_sync_checksum',
@@ -28,8 +31,23 @@ $posts = get_posts( [
foreach ( $posts as $post_id ) { foreach ( $posts as $post_id ) {
delete_post_meta( $post_id, '_oribi_sync_checksum' ); delete_post_meta( $post_id, '_oribi_sync_checksum' );
delete_post_meta( $post_id, '_oribi_sync_git_sha' );
delete_post_meta( $post_id, '_oribi_sync_source' ); delete_post_meta( $post_id, '_oribi_sync_source' );
delete_post_meta( $post_id, '_oribi_sync_last_run' ); delete_post_meta( $post_id, '_oribi_sync_last_run' );
delete_post_meta( $post_id, '_oribi_sync_last_push' );
delete_post_meta( $post_id, '_oribi_sync_pr_url' );
}
// Remove origin src meta from media attachments
$attachments = get_posts( [
'post_type' => 'attachment',
'post_status' => 'any',
'posts_per_page' => -1,
'meta_key' => '_oribi_sync_origin_src',
'fields' => 'ids',
] );
foreach ( $attachments as $att_id ) {
delete_post_meta( $att_id, '_oribi_sync_origin_src' );
} }
// Clear any scheduled cron // Clear any scheduled cron