diff --git a/includes/admin.php b/includes/admin.php
index c9eee8e..fe3f63b 100644
--- a/includes/admin.php
+++ b/includes/admin.php
@@ -8,6 +8,82 @@
if ( ! defined( 'ABSPATH' ) ) exit;
+// ─── Admin bar pull button (front-end) ──────────────────────────────────────
+add_action( 'admin_bar_menu', function ( WP_Admin_Bar $wp_admin_bar ) {
+ // Front-end only, logged-in admins, singular pages/posts
+ if ( is_admin() ) return;
+ if ( ! is_user_logged_in() ) return;
+ if ( ! current_user_can( 'manage_options' ) ) return;
+ if ( ! is_singular() ) return;
+
+ $post = get_queried_object();
+ if ( ! $post instanceof WP_Post ) return;
+
+ $wp_admin_bar->add_node( [
+ 'id' => 'oribi-sync-pull',
+ 'title' => 'Pull Page',
+ 'href' => '#',
+ 'meta' => [
+ 'title' => 'Pull this page and theme from Git',
+ ],
+ ] );
+}, 100 );
+
+// 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;
+
+ $api_url = rest_url( 'oribi-sync/v1/pull-page' );
+ $nonce = wp_create_nonce( 'wp_rest' );
+ $post_id = (int) $post->ID;
+ ?>
+
+ 'POST',
+ 'callback' => 'oribi_sync_rest_pull_page',
+ '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',
@@ -86,6 +95,20 @@ function oribi_sync_rest_status(): WP_REST_Response {
] );
}
+/**
+ * REST: Pull a single page and theme from the repo.
+ */
+function oribi_sync_rest_pull_page( 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 );
+ }
+
+ $result = oribi_sync_pull_page_from_repo( $post_id );
+
+ return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 );
+}
+
/**
* REST: Webhook trigger.
*
diff --git a/includes/sync-engine.php b/includes/sync-engine.php
index 9d995c4..7ab39d4 100644
--- a/includes/sync-engine.php
+++ b/includes/sync-engine.php
@@ -531,6 +531,133 @@ function oribi_sync_apply_theme_files( string $api_base, string $branch, string
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 ) {
+ $checksum = hash( 'sha256', $raw_content );
+ $git_sha = $target_entry['sha'] ?? '';
+
+ $update = wp_update_post( [
+ 'ID' => $post->ID,
+ 'post_content' => $content,
+ 'post_status' => 'publish',
+ ], true );
+
+ 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).
*