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.
This commit is contained in:
27
README.md
27
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
BIN
dist/oribi-tech-sync.zip
vendored
BIN
dist/oribi-tech-sync.zip
vendored
Binary file not shown.
@@ -35,9 +35,12 @@ add_action( 'admin_post_oribi_sync_save_settings', function () {
|
|||||||
$provider = sanitize_text_field( wp_unslash( $_POST['oribi_sync_provider'] ?? '' ) );
|
$provider = sanitize_text_field( wp_unslash( $_POST['oribi_sync_provider'] ?? '' ) );
|
||||||
$pat = wp_unslash( $_POST['oribi_sync_pat'] ?? '' );
|
$pat = wp_unslash( $_POST['oribi_sync_pat'] ?? '' );
|
||||||
|
|
||||||
update_option( 'oribi_sync_repo', $repo, 'no' );
|
$pages_folder = sanitize_text_field( wp_unslash( $_POST['oribi_sync_pages_folder'] ?? '' ) );
|
||||||
update_option( 'oribi_sync_branch', $branch, 'no' );
|
|
||||||
update_option( 'oribi_sync_provider', $provider, 'no' );
|
update_option( 'oribi_sync_repo', $repo, 'no' );
|
||||||
|
update_option( 'oribi_sync_branch', $branch, 'no' );
|
||||||
|
update_option( 'oribi_sync_provider', $provider, 'no' );
|
||||||
|
update_option( 'oribi_sync_pages_folder', $pages_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 ) ) {
|
||||||
@@ -54,6 +57,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' ) ) );
|
||||||
@@ -82,59 +89,101 @@ 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 ) {
|
||||||
|
$result = 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;
|
||||||
|
|
||||||
$repo = get_option( 'oribi_sync_repo', '' );
|
$repo = get_option( 'oribi_sync_repo', '' );
|
||||||
$branch = get_option( 'oribi_sync_branch', 'main' );
|
$branch = get_option( 'oribi_sync_branch', 'main' );
|
||||||
$provider = get_option( 'oribi_sync_provider', '' );
|
$provider = get_option( 'oribi_sync_provider', '' );
|
||||||
$has_pat = ! empty( get_option( 'oribi_sync_pat', '' ) );
|
$pages_folder = get_option( 'oribi_sync_pages_folder', '' );
|
||||||
$last_run = get_option( 'oribi_sync_last_run', '' );
|
$has_pat = ! empty( get_option( 'oribi_sync_pat', '' ) );
|
||||||
$log = get_option( 'oribi_sync_log', [] );
|
$last_run = get_option( 'oribi_sync_last_run', '' );
|
||||||
|
$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'] ?? '';
|
||||||
?>
|
?>
|
||||||
<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 echo $done === 'dry' ? 'Dry-run results' : 'Sync complete'; ?></strong></p>
|
||||||
<strong><?php echo $done === 'dry' ? '🔍 Dry-run results' : '✅ Sync complete'; ?></strong>
|
<?php oribi_sync_render_result_list( $sync_result ); ?>
|
||||||
</p>
|
|
||||||
<ul style="list-style:disc; padding-left:1.5rem;">
|
|
||||||
<?php if ( ! empty( $sync_result['created'] ) ): ?>
|
|
||||||
<li>Created: <?php echo esc_html( implode( ', ', $sync_result['created'] ) ); ?></li>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?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 +194,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,23 +219,38 @@ 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 ): ?>
|
||||||
|
|
||||||
<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>
|
||||||
|
<tr>
|
||||||
|
<th scope="row"><label for="oribi_sync_pages_folder">Pages Folder</label></th>
|
||||||
|
<td>
|
||||||
|
<select name="oribi_sync_pages_folder" id="oribi_sync_pages_folder" class="regular-text">
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<?php if ( ! empty( $pages_folder ) ): ?>
|
||||||
|
<option value="<?php echo esc_attr( $pages_folder ); ?>" selected>
|
||||||
|
<?php echo esc_html( $pages_folder ); ?>
|
||||||
|
</option>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="oribi-load-folders" class="button">Load</button>
|
||||||
|
<span id="oribi-folders-status" class="oribi-sync-muted"></span>
|
||||||
|
<p class="description">Sub-folder inside <code>Pages/</code> to sync from.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<?php submit_button( 'Save Settings' ); ?>
|
<?php submit_button( 'Save Settings' ); ?>
|
||||||
@@ -196,70 +258,187 @@ function oribi_sync_settings_page() {
|
|||||||
|
|
||||||
<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 & 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 & Push)
|
||||||
</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 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?');">
|
||||||
|
Push All
|
||||||
</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 // ── 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>
|
||||||
<table class="widefat fixed striped oribi-sync-log">
|
<summary>Sync Log (<?php echo count( $log ); ?>)</summary>
|
||||||
<thead><tr>
|
<table class="widefat fixed striped oribi-sync-log">
|
||||||
<th style="width:160px;">Time</th>
|
<thead><tr>
|
||||||
<th>Created</th>
|
<th style="width:150px;">Time</th>
|
||||||
<th>Updated</th>
|
<th>Created</th>
|
||||||
<th>Trashed</th>
|
<th>Updated</th>
|
||||||
<th>Skipped</th>
|
<th>Errors</th>
|
||||||
<th>Errors</th>
|
</tr></thead>
|
||||||
</tr></thead>
|
<tbody>
|
||||||
<tbody>
|
<?php foreach ( array_slice( $log, 0, 10 ) as $entry ): ?>
|
||||||
<?php foreach ( array_slice( $log, 0, 10 ) as $entry ): ?>
|
<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( ', ', array_merge( $entry['updated'] ?? [], $entry['theme_updated'] ?? [] ) ) ?: '—' ); ?></td>
|
||||||
<td><?php echo esc_html( implode( ', ', $entry['updated'] ?? [] ) ?: '—' ); ?></td>
|
<td class="oribi-sync-error-text"><?php echo esc_html( implode( '; ', $entry['errors'] ?? [] ) ?: '—' ); ?></td>
|
||||||
<td><?php echo esc_html( implode( ', ', $entry['trashed'] ?? [] ) ?: '—' ); ?></td>
|
</tr>
|
||||||
<td><?php echo esc_html( implode( ', ', $entry['skipped'] ?? [] ) ?: '—' ); ?></td>
|
<?php endforeach; ?>
|
||||||
<td style="color:#d63638;"><?php echo esc_html( implode( '; ', $entry['errors'] ?? [] ) ?: '—' ); ?></td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
<?php endforeach; ?>
|
</details>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('oribi-load-folders');
|
||||||
|
var select = document.getElementById('oribi_sync_pages_folder');
|
||||||
|
var status = document.getElementById('oribi-folders-status');
|
||||||
|
if ( ! btn || ! select ) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
status.textContent = 'Loading…';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
fetch('<?php echo esc_js( rest_url( 'oribi-sync/v1/repo-folders' ) ); ?>', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-WP-Nonce': '<?php echo esc_js( wp_create_nonce( 'wp_rest' ) ); ?>' }
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if ( data.error ) { status.textContent = '✗ ' + data.error; btn.disabled = false; return; }
|
||||||
|
var currentVal = select.value;
|
||||||
|
while ( select.options.length > 1 ) select.remove(1);
|
||||||
|
if ( ! data.folders || ! data.folders.length ) { status.textContent = 'No folders found.'; btn.disabled = false; return; }
|
||||||
|
data.folders.forEach(function (f) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = f; o.textContent = f;
|
||||||
|
if ( f === currentVal ) o.selected = true;
|
||||||
|
select.appendChild(o);
|
||||||
|
});
|
||||||
|
if ( currentVal && select.querySelector('option[value="' + currentVal + '"]') ) select.value = currentVal;
|
||||||
|
status.textContent = data.folders.length + ' folder(s).';
|
||||||
|
btn.disabled = false;
|
||||||
|
})
|
||||||
|
.catch(function () { status.textContent = '✗ Request failed.'; btn.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<?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[] = 'Created: ' . implode( ', ', $r['created'] );
|
||||||
|
if ( ! empty( $r['updated'] ) ) $items[] = 'Updated: ' . implode( ', ', $r['updated'] );
|
||||||
|
if ( ! empty( $r['theme_updated'] ) ) $items[] = 'Theme: ' . implode( ', ', $r['theme_updated'] );
|
||||||
|
if ( ! empty( $r['trashed'] ) ) $items[] = 'Trashed: ' . implode( ', ', $r['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>';
|
||||||
|
}
|
||||||
|
|||||||
530
includes/push-client.php
Normal file
530
includes/push-client.php
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
// ─── Auto-push on page save ──────────────────────────────────────────────────
|
||||||
|
add_action( 'save_post_page', 'oribi_sync_maybe_push_on_save', 20, 3 );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a synced page to the repo whenever it is saved.
|
||||||
|
*
|
||||||
|
* Only fires for pages that were previously pulled (have _oribi_sync_checksum
|
||||||
|
* meta), skips autosaves, revisions, and non-publish statuses.
|
||||||
|
* Uses a static guard to prevent re-entry when we update post meta after push.
|
||||||
|
*/
|
||||||
|
function oribi_sync_maybe_push_on_save( int $post_id, WP_Post $post, bool $update ): void {
|
||||||
|
// Guard: only on genuine content saves
|
||||||
|
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
|
||||||
|
if ( wp_is_post_revision( $post_id ) ) return;
|
||||||
|
if ( $post->post_status !== 'publish' ) return;
|
||||||
|
|
||||||
|
// Guard: only pages that came from a sync (have checksum meta)
|
||||||
|
$checksum = get_post_meta( $post_id, '_oribi_sync_checksum', true );
|
||||||
|
if ( empty( $checksum ) ) return;
|
||||||
|
|
||||||
|
// Guard: prevent re-entry when push updates meta on the same post
|
||||||
|
static $pushing = [];
|
||||||
|
if ( isset( $pushing[ $post_id ] ) ) return;
|
||||||
|
$pushing[ $post_id ] = true;
|
||||||
|
|
||||||
|
oribi_sync_push_page( $post_id );
|
||||||
|
|
||||||
|
unset( $pushing[ $post_id ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'method' => $method,
|
||||||
|
'timeout' => 30,
|
||||||
|
'headers' => $headers,
|
||||||
|
'body' => wp_json_encode( $body ),
|
||||||
|
];
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'sha' => $result['sha'] ?? '',
|
||||||
|
'content' => isset( $result['content'] ) ? base64_decode( $result['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 = ''
|
||||||
|
) {
|
||||||
|
$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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = current_time( 'Y-m-d H:i:s' );
|
||||||
|
|
||||||
|
// Use a nowdoc so the content is treated as a literal string (no interpolation).
|
||||||
|
// Escape the content if it accidentally contains the heredoc delimiter on its own line.
|
||||||
|
$delimiter = 'ORIBI_SYNC_CONTENT';
|
||||||
|
$safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $content );
|
||||||
|
|
||||||
|
$php = "<?php\n";
|
||||||
|
$php .= "/**\n";
|
||||||
|
$php .= " * Page: {$title}\n";
|
||||||
|
$php .= " * Slug: {$slug}\n";
|
||||||
|
$php .= " * Pushed by Oribi Tech Sync on {$date}.\n";
|
||||||
|
$php .= " */\n\n";
|
||||||
|
$php .= "return <<<'{$delimiter}'\n";
|
||||||
|
$php .= $safe_content . "\n";
|
||||||
|
$php .= $delimiter . ";\n";
|
||||||
|
|
||||||
|
return $php;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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}
|
||||||
|
$colon_pos = strpos( $source_meta, ':' );
|
||||||
|
if ( $colon_pos !== false ) {
|
||||||
|
// Find the last occurrence of the pattern @branch:
|
||||||
|
$at_pos = strrpos( substr( $source_meta, 0, $colon_pos ), '@' );
|
||||||
|
if ( $at_pos !== false ) {
|
||||||
|
$repo_path = substr( $source_meta, $colon_pos + 1 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( empty( $repo_path ) ) {
|
||||||
|
// Derive from settings + slug
|
||||||
|
$pages_folder = get_option( 'oribi_sync_pages_folder', '' );
|
||||||
|
if ( empty( $pages_folder ) ) {
|
||||||
|
return [ 'ok' => false, 'action' => 'error', 'message' => 'Cannot determine repo path — no pages folder configured and no source meta on this page.' ];
|
||||||
|
}
|
||||||
|
$repo_path = 'Pages/' . $pages_folder . '/' . $post->post_name . '.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate content ──────────────────────────────────────────────────
|
||||||
|
$slug = $post->post_name;
|
||||||
|
$title = $post->post_title;
|
||||||
|
$wp_content = $post->post_content;
|
||||||
|
$php_source = oribi_sync_generate_php_wrapper( $wp_content, $slug, $title );
|
||||||
|
$new_checksum = hash( 'sha256', $php_source );
|
||||||
|
|
||||||
|
$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() ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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' );
|
||||||
|
}
|
||||||
@@ -30,6 +30,33 @@ add_action( 'rest_api_init', function () {
|
|||||||
},
|
},
|
||||||
] );
|
] );
|
||||||
|
|
||||||
|
// ── List Pages sub-folders from the repo ───────────────────────────
|
||||||
|
register_rest_route( 'oribi-sync/v1', '/repo-folders', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => 'oribi_sync_rest_repo_folders',
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can( 'manage_options' );
|
||||||
|
},
|
||||||
|
] );
|
||||||
|
|
||||||
|
// ── Push page to repo ──────────────────────────────────────────────────
|
||||||
|
register_rest_route( 'oribi-sync/v1', '/push', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => 'oribi_sync_rest_push',
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can( 'manage_options' );
|
||||||
|
},
|
||||||
|
] );
|
||||||
|
|
||||||
|
// ── Push all synced pages to repo ──────────────────────────────────────
|
||||||
|
register_rest_route( 'oribi-sync/v1', '/push-all', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => 'oribi_sync_rest_push_all',
|
||||||
|
'permission_callback' => function () {
|
||||||
|
return current_user_can( 'manage_options' );
|
||||||
|
},
|
||||||
|
] );
|
||||||
|
|
||||||
// ── Webhook (secret-based auth, no WP login required) ─────────────────
|
// ── Webhook (secret-based auth, no WP login required) ─────────────────
|
||||||
register_rest_route( 'oribi-sync/v1', '/webhook', [
|
register_rest_route( 'oribi-sync/v1', '/webhook', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -45,6 +72,12 @@ function oribi_sync_rest_sync( WP_REST_Request $request ): WP_REST_Response {
|
|||||||
$dry_run = (bool) $request->get_param( 'dry_run' );
|
$dry_run = (bool) $request->get_param( 'dry_run' );
|
||||||
$result = oribi_sync_run( $dry_run );
|
$result = oribi_sync_run( $dry_run );
|
||||||
|
|
||||||
|
// After pulling, push local changes back (skip during dry-run)
|
||||||
|
if ( ! $dry_run ) {
|
||||||
|
$push = oribi_sync_push_all();
|
||||||
|
$result['push'] = $push['results'];
|
||||||
|
}
|
||||||
|
|
||||||
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
|
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +136,77 @@ function oribi_sync_rest_webhook( WP_REST_Request $request ): WP_REST_Response {
|
|||||||
|
|
||||||
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
|
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST: Push a single page to the repo.
|
||||||
|
*/
|
||||||
|
function oribi_sync_rest_push( WP_REST_Request $request ): WP_REST_Response {
|
||||||
|
$post_id = (int) $request->get_param( 'post_id' );
|
||||||
|
if ( $post_id < 1 ) {
|
||||||
|
return new WP_REST_Response( [ 'ok' => false, 'message' => 'Missing or invalid post_id.' ], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$opts = [];
|
||||||
|
$message = $request->get_param( 'message' );
|
||||||
|
if ( ! empty( $message ) ) {
|
||||||
|
$opts['message'] = sanitize_text_field( $message );
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = oribi_sync_push_page( $post_id, $opts );
|
||||||
|
|
||||||
|
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST: Push all synced pages to the repo.
|
||||||
|
*/
|
||||||
|
function oribi_sync_rest_push_all( WP_REST_Request $request ): WP_REST_Response {
|
||||||
|
$result = oribi_sync_push_all();
|
||||||
|
|
||||||
|
return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST: List available sub-folders under Pages/ in the configured repository.
|
||||||
|
*
|
||||||
|
* Returns a JSON object: { folders: ["folder-a", "folder-b", …] }
|
||||||
|
*/
|
||||||
|
function oribi_sync_rest_repo_folders(): WP_REST_Response {
|
||||||
|
$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 ) ) {
|
||||||
|
return new WP_REST_Response( [ 'error' => 'Repository URL or PAT is not configured.' ], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = oribi_sync_parse_repo_url( $repo_url );
|
||||||
|
if ( is_wp_error( $parsed ) ) {
|
||||||
|
return new WP_REST_Response( [ 'error' => $parsed->get_error_message() ], 400 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = oribi_sync_get_provider();
|
||||||
|
$api_base = oribi_sync_api_base( $provider, $parsed );
|
||||||
|
|
||||||
|
$tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat );
|
||||||
|
if ( is_wp_error( $tree ) ) {
|
||||||
|
return new WP_REST_Response( [ 'error' => 'Tree fetch failed: ' . $tree->get_error_message() ], 500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find direct sub-directories of Pages/
|
||||||
|
$folders = [];
|
||||||
|
$prefix = 'Pages/';
|
||||||
|
foreach ( $tree as $entry ) {
|
||||||
|
if ( $entry['type'] !== 'tree' ) continue;
|
||||||
|
if ( strpos( $entry['path'], $prefix ) !== 0 ) continue;
|
||||||
|
$relative = substr( $entry['path'], strlen( $prefix ) );
|
||||||
|
// Only direct children (no nested slash)
|
||||||
|
if ( strpos( $relative, '/' ) !== false ) continue;
|
||||||
|
if ( $relative === '' ) continue;
|
||||||
|
$folders[] = $relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort( $folders );
|
||||||
|
|
||||||
|
return new WP_REST_Response( [ 'folders' => $folders ], 200 );
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,12 +90,13 @@ function oribi_sync_execute_php( string $php_source, string $slug ) {
|
|||||||
*/
|
*/
|
||||||
function oribi_sync_run( bool $dry_run = false ): array {
|
function oribi_sync_run( bool $dry_run = false ): array {
|
||||||
$result = [
|
$result = [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'created' => [],
|
'created' => [],
|
||||||
'updated' => [],
|
'updated' => [],
|
||||||
'trashed' => [],
|
'trashed' => [],
|
||||||
'skipped' => [],
|
'skipped' => [],
|
||||||
'errors' => [],
|
'errors' => [],
|
||||||
|
'theme_updated' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Gather settings ────────────────────────────────────────────────────
|
// ── Gather settings ────────────────────────────────────────────────────
|
||||||
@@ -128,18 +129,21 @@ function oribi_sync_run( bool $dry_run = false ): array {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter to pages/ directory ─────────────────────────────────────────
|
// ── Filter to selected Pages sub-folder ────────────────────────────────
|
||||||
$page_files = oribi_sync_filter_tree( $tree, 'pages' );
|
$pages_folder = get_option( 'oribi_sync_pages_folder', '' );
|
||||||
|
$synced_slugs = [];
|
||||||
|
|
||||||
if ( empty( $page_files ) ) {
|
if ( empty( $pages_folder ) ) {
|
||||||
$result['errors'][] = 'No files found under pages/ in the repository.';
|
$result['skipped'][] = 'Pages sync skipped — no folder selected in settings.';
|
||||||
$result['ok'] = false;
|
$page_files = [];
|
||||||
return $result;
|
} else {
|
||||||
|
$page_files = oribi_sync_filter_tree( $tree, 'Pages/' . $pages_folder );
|
||||||
|
if ( empty( $page_files ) ) {
|
||||||
|
$result['errors'][] = 'No files found under Pages/' . $pages_folder . '/ in the repository.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 );
|
||||||
@@ -239,7 +243,8 @@ 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 );
|
||||||
@@ -263,16 +268,25 @@ 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)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Trash pages removed from repo ──────────────────────────────────────
|
// ── Trash pages removed from repo ──────────────────────────────────────
|
||||||
if ( ! $dry_run ) {
|
if ( ! $dry_run && ! empty( $pages_folder ) ) {
|
||||||
$trashed = oribi_sync_trash_removed_pages( $synced_slugs );
|
$trashed = oribi_sync_trash_removed_pages( $synced_slugs );
|
||||||
$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;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Record run ─────────────────────────────────────────────────────────
|
// ── Record run ─────────────────────────────────────────────────────────
|
||||||
if ( ! $dry_run ) {
|
if ( ! $dry_run ) {
|
||||||
oribi_sync_record_run( $result );
|
oribi_sync_record_run( $result );
|
||||||
@@ -357,12 +371,13 @@ function oribi_sync_record_run( array $result ): void {
|
|||||||
if ( ! is_array( $log ) ) $log = [];
|
if ( ! is_array( $log ) ) $log = [];
|
||||||
|
|
||||||
array_unshift( $log, [
|
array_unshift( $log, [
|
||||||
'time' => current_time( 'mysql' ),
|
'time' => current_time( 'mysql' ),
|
||||||
'created' => $result['created'],
|
'created' => $result['created'],
|
||||||
'updated' => $result['updated'],
|
'updated' => $result['updated'],
|
||||||
'trashed' => $result['trashed'],
|
'trashed' => $result['trashed'],
|
||||||
'skipped' => $result['skipped'],
|
'skipped' => $result['skipped'],
|
||||||
'errors' => $result['errors'],
|
'errors' => $result['errors'],
|
||||||
|
'theme_updated' => $result['theme_updated'] ?? [],
|
||||||
] );
|
] );
|
||||||
|
|
||||||
// Keep last 20 entries
|
// Keep last 20 entries
|
||||||
@@ -370,6 +385,125 @@ 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 = substr( $entry['path'], strlen( '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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch theme files from the repo (for preview / apply).
|
* Fetch theme files from the repo (for preview / apply).
|
||||||
*
|
*
|
||||||
@@ -414,9 +548,10 @@ 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';
|
||||||
$local_exists = file_exists( $theme_file );
|
$theme_file = $theme_dir . '/' . $relative;
|
||||||
|
$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;
|
||||||
|
|
||||||
$out['files'][] = [
|
$out['files'][] = [
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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/admin.php';
|
require_once ORIBI_SYNC_DIR . 'includes/admin.php';
|
||||||
require_once ORIBI_SYNC_DIR . 'includes/rest.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';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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' );
|
||||||
|
|
||||||
// Remove sync metadata from posts
|
// Remove sync metadata from posts
|
||||||
$posts = get_posts( [
|
$posts = get_posts( [
|
||||||
@@ -28,8 +29,11 @@ $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' );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any scheduled cron
|
// Clear any scheduled cron
|
||||||
|
|||||||
Reference in New Issue
Block a user