Add Oribi Sync plugin for syncing WordPress pages and theme files from a Git repository
- Implement encryption helpers for storing and retrieving the Personal Access Token (PAT). - Create REST API endpoints for triggering sync, checking sync status, and handling webhooks. - Develop the sync engine to fetch pages from the Git repository, create/update WordPress pages, and trash removed pages. - Add functionality for previewing and applying theme files from the repository. - Set up plugin activation and deactivation hooks to manage default options and scheduled tasks. - Implement uninstall routine to clean up plugin options and metadata from posts.
This commit is contained in:
417
includes/sync-engine.php
Normal file
417
includes/sync-engine.php
Normal file
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
/**
|
||||
* Oribi Sync — Sync engine.
|
||||
*
|
||||
* Fetches pages/ directory from the configured Git repository,
|
||||
* creates or updates WordPress pages, and trashes pages whose
|
||||
* source files have been removed from the repo.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) exit;
|
||||
|
||||
/**
|
||||
* Execute a PHP page-data file fetched from the repo and return its block markup.
|
||||
*
|
||||
* The file is expected to use oribi_b() / oribi_b_open() / oribi_b_close()
|
||||
* helpers (from oribi-tech-setup) and return a string of Gutenberg block markup.
|
||||
*
|
||||
* @param string $php_source Raw PHP source code from the repo.
|
||||
* @param string $slug Page slug (used for error context).
|
||||
*
|
||||
* @return string|WP_Error The rendered block markup, or WP_Error on failure.
|
||||
*/
|
||||
function oribi_sync_execute_php( string $php_source, string $slug ) {
|
||||
// Ensure the block helpers are available
|
||||
if ( ! function_exists( 'oribi_b' ) ) {
|
||||
return new WP_Error(
|
||||
'oribi_sync_missing_helpers',
|
||||
'Block helper functions (oribi_b, oribi_b_open, oribi_b_close) are not available. '
|
||||
. 'Make sure the Oribi Tech Setup plugin is active.'
|
||||
);
|
||||
}
|
||||
|
||||
// Write to a temp file so we can include it
|
||||
$tmp = wp_tempnam( 'oribi-sync-' . $slug . '.php' );
|
||||
if ( ! $tmp ) {
|
||||
return new WP_Error( 'oribi_sync_tmp_error', 'Could not create temporary file for ' . $slug );
|
||||
}
|
||||
|
||||
file_put_contents( $tmp, $php_source );
|
||||
|
||||
// Provide $c (contact page URL) — same convention as oribi_page_content()
|
||||
$c = '/contact';
|
||||
|
||||
// Capture the return value (page-data files use `return implode(…)`)
|
||||
try {
|
||||
ob_start();
|
||||
$returned = include $tmp;
|
||||
$echoed = ob_get_clean();
|
||||
} catch ( \Throwable $e ) {
|
||||
ob_end_clean();
|
||||
@unlink( $tmp );
|
||||
return new WP_Error( 'oribi_sync_php_error', $slug . ': PHP execution failed — ' . $e->getMessage() );
|
||||
}
|
||||
|
||||
@unlink( $tmp );
|
||||
|
||||
// Prefer the return value; fall back to echoed output
|
||||
if ( is_string( $returned ) && ! empty( trim( $returned ) ) ) {
|
||||
return trim( $returned );
|
||||
}
|
||||
if ( ! empty( trim( $echoed ) ) ) {
|
||||
return trim( $echoed );
|
||||
}
|
||||
|
||||
return new WP_Error( 'oribi_sync_empty_output', $slug . ': PHP file produced no output.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full page sync.
|
||||
*
|
||||
* @param bool $dry_run If true, returns what would happen without making changes.
|
||||
*
|
||||
* @return array{ok: bool, created: string[], updated: string[], trashed: string[], skipped: string[], errors: string[]}
|
||||
*/
|
||||
function oribi_sync_run( bool $dry_run = false ): array {
|
||||
$result = [
|
||||
'ok' => true,
|
||||
'created' => [],
|
||||
'updated' => [],
|
||||
'trashed' => [],
|
||||
'skipped' => [],
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// ── Parse repo URL ─────────────────────────────────────────────────────
|
||||
$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;
|
||||
}
|
||||
|
||||
// ── Filter to pages/ directory ─────────────────────────────────────────
|
||||
$page_files = oribi_sync_filter_tree( $tree, 'pages' );
|
||||
|
||||
if ( empty( $page_files ) ) {
|
||||
$result['errors'][] = 'No files found under pages/ in the repository.';
|
||||
$result['ok'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ── Process each page file ─────────────────────────────────────────────
|
||||
$synced_slugs = [];
|
||||
|
||||
foreach ( $page_files as $entry ) {
|
||||
$filename = basename( $entry['path'] );
|
||||
$slug = oribi_sync_filename_to_slug( $filename );
|
||||
|
||||
if ( empty( $slug ) ) {
|
||||
$result['skipped'][] = $entry['path'] . ' (could not derive slug)';
|
||||
continue;
|
||||
}
|
||||
|
||||
$synced_slugs[] = $slug;
|
||||
|
||||
// Find existing page early so we can do change detection before fetching content
|
||||
$existing = get_page_by_path( $slug );
|
||||
|
||||
// ── Fast-path change detection via git blob SHA ────────────────────
|
||||
// The tree API returns the blob SHA for GitHub, GitLab and Gitea.
|
||||
// This SHA changes whenever the file content changes, so we can skip
|
||||
// the (potentially cached) file-content fetch entirely when it matches.
|
||||
$git_sha = $entry['sha'] ?? '';
|
||||
$stored_git_sha = $existing ? get_post_meta( $existing->ID, '_oribi_sync_git_sha', true ) : '';
|
||||
|
||||
if ( $existing && ! empty( $git_sha ) && $git_sha === $stored_git_sha ) {
|
||||
$result['skipped'][] = $slug . ' (unchanged)';
|
||||
if ( ! $dry_run ) {
|
||||
update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) );
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch raw file from repo
|
||||
$raw_content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat );
|
||||
if ( is_wp_error( $raw_content ) ) {
|
||||
$result['errors'][] = $entry['path'] . ': ' . $raw_content->get_error_message();
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw_content = trim( $raw_content );
|
||||
|
||||
// Determine file type and resolve to block markup
|
||||
$ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
|
||||
|
||||
if ( $ext === 'php' ) {
|
||||
// Execute the PHP file to produce Gutenberg block markup
|
||||
$content = oribi_sync_execute_php( $raw_content, $slug );
|
||||
if ( is_wp_error( $content ) ) {
|
||||
$result['errors'][] = $entry['path'] . ': ' . $content->get_error_message();
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// HTML or other — use raw content directly as block markup
|
||||
$content = $raw_content;
|
||||
}
|
||||
|
||||
// Checksum based on raw source — used as fallback for providers without tree SHA
|
||||
$checksum = hash( 'sha256', $raw_content );
|
||||
|
||||
if ( $dry_run ) {
|
||||
if ( $existing ) {
|
||||
// git SHA already differs (or wasn't available) — report as updated
|
||||
$old_checksum = get_post_meta( $existing->ID, '_oribi_sync_checksum', true );
|
||||
if ( empty( $git_sha ) && $old_checksum === $checksum ) {
|
||||
$result['skipped'][] = $slug . ' (unchanged)';
|
||||
} else {
|
||||
$result['updated'][] = $slug;
|
||||
}
|
||||
} else {
|
||||
$result['created'][] = $slug;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $existing ) {
|
||||
// For providers without a tree SHA, fall back to content checksum comparison
|
||||
if ( empty( $git_sha ) ) {
|
||||
$old_checksum = get_post_meta( $existing->ID, '_oribi_sync_checksum', true );
|
||||
if ( $old_checksum === $checksum ) {
|
||||
$result['skipped'][] = $slug . ' (unchanged)';
|
||||
update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) );
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$update_result = wp_update_post( [
|
||||
'ID' => $existing->ID,
|
||||
'post_content' => $content,
|
||||
'post_status' => 'publish',
|
||||
], true );
|
||||
|
||||
if ( is_wp_error( $update_result ) ) {
|
||||
$result['errors'][] = $slug . ': ' . $update_result->get_error_message();
|
||||
continue;
|
||||
}
|
||||
|
||||
update_post_meta( $existing->ID, '_oribi_sync_checksum', $checksum );
|
||||
update_post_meta( $existing->ID, '_oribi_sync_git_sha', $git_sha );
|
||||
update_post_meta( $existing->ID, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $entry['path'] );
|
||||
update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) );
|
||||
update_post_meta( $existing->ID, '_wp_page_template', 'default' );
|
||||
|
||||
$result['updated'][] = $slug;
|
||||
} else {
|
||||
// Create new page
|
||||
$title = oribi_sync_slug_to_title( $slug );
|
||||
|
||||
$post_id = wp_insert_post( [
|
||||
'post_title' => $title,
|
||||
'post_name' => $slug,
|
||||
'post_status' => 'publish',
|
||||
'post_type' => 'page',
|
||||
'post_content' => $content,
|
||||
], true );
|
||||
|
||||
if ( is_wp_error( $post_id ) ) {
|
||||
$result['errors'][] = $slug . ': ' . $post_id->get_error_message();
|
||||
continue;
|
||||
}
|
||||
|
||||
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 . ':' . $entry['path'] );
|
||||
update_post_meta( $post_id, '_oribi_sync_last_run', current_time( 'mysql' ) );
|
||||
update_post_meta( $post_id, '_wp_page_template', 'default' );
|
||||
|
||||
$result['created'][] = $slug;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trash pages removed from repo ──────────────────────────────────────
|
||||
if ( ! $dry_run ) {
|
||||
$trashed = oribi_sync_trash_removed_pages( $synced_slugs );
|
||||
$result['trashed'] = $trashed;
|
||||
}
|
||||
|
||||
// ── Record run ─────────────────────────────────────────────────────────
|
||||
if ( ! $dry_run ) {
|
||||
oribi_sync_record_run( $result );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trash WP pages that were previously synced but are no longer in the repo.
|
||||
*
|
||||
* A page is considered "synced" if it has the _oribi_sync_checksum meta key.
|
||||
*
|
||||
* @param string[] $current_slugs Slugs found in the current repo tree.
|
||||
*
|
||||
* @return string[] Slugs of pages moved to trash.
|
||||
*/
|
||||
function oribi_sync_trash_removed_pages( array $current_slugs ): array {
|
||||
$trashed = [];
|
||||
|
||||
$query = new WP_Query( [
|
||||
'post_type' => 'page',
|
||||
'post_status' => 'publish',
|
||||
'meta_key' => '_oribi_sync_checksum',
|
||||
'posts_per_page' => -1,
|
||||
'fields' => 'ids',
|
||||
] );
|
||||
|
||||
foreach ( $query->posts as $post_id ) {
|
||||
$page = get_post( $post_id );
|
||||
if ( ! $page ) continue;
|
||||
|
||||
$slug = $page->post_name;
|
||||
|
||||
if ( ! in_array( $slug, $current_slugs, true ) ) {
|
||||
wp_trash_post( $page->ID );
|
||||
$trashed[] = $slug;
|
||||
}
|
||||
}
|
||||
|
||||
return $trashed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a filename to a page slug.
|
||||
*
|
||||
* Examples:
|
||||
* home.php → home
|
||||
* managed-it.php → managed-it
|
||||
* home.html → home
|
||||
* My Page.html → my-page
|
||||
*
|
||||
* @return string Sanitized slug (empty on failure).
|
||||
*/
|
||||
function oribi_sync_filename_to_slug( string $filename ): string {
|
||||
// Strip extension
|
||||
$slug = pathinfo( $filename, PATHINFO_FILENAME );
|
||||
// Sanitize
|
||||
$slug = sanitize_title( $slug );
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a slug to a human-readable page title.
|
||||
*
|
||||
* Examples:
|
||||
* home → Home
|
||||
* managed-it → Managed It
|
||||
* 365care → 365care
|
||||
*/
|
||||
function oribi_sync_slug_to_title( string $slug ): string {
|
||||
return ucwords( str_replace( '-', ' ', $slug ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a sync run in the options table.
|
||||
*/
|
||||
function oribi_sync_record_run( array $result ): void {
|
||||
update_option( 'oribi_sync_last_run', current_time( 'mysql' ), 'no' );
|
||||
|
||||
$log = get_option( 'oribi_sync_log', [] );
|
||||
if ( ! is_array( $log ) ) $log = [];
|
||||
|
||||
array_unshift( $log, [
|
||||
'time' => current_time( 'mysql' ),
|
||||
'created' => $result['created'],
|
||||
'updated' => $result['updated'],
|
||||
'trashed' => $result['trashed'],
|
||||
'skipped' => $result['skipped'],
|
||||
'errors' => $result['errors'],
|
||||
] );
|
||||
|
||||
// Keep last 20 entries
|
||||
$log = array_slice( $log, 0, 20 );
|
||||
update_option( 'oribi_sync_log', $log, 'no' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch theme files from the repo (for preview / apply).
|
||||
*
|
||||
* @return array{files: array, errors: string[]}
|
||||
*/
|
||||
function oribi_sync_fetch_theme_files(): array {
|
||||
$out = [ 'files' => [], 'errors' => [] ];
|
||||
|
||||
$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 ) ) {
|
||||
$out['errors'][] = 'Repository URL or PAT is not configured.';
|
||||
return $out;
|
||||
}
|
||||
|
||||
$parsed = oribi_sync_parse_repo_url( $repo_url );
|
||||
if ( is_wp_error( $parsed ) ) {
|
||||
$out['errors'][] = $parsed->get_error_message();
|
||||
return $out;
|
||||
}
|
||||
|
||||
$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 ) ) {
|
||||
$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 ) {
|
||||
// Derive relative path by stripping the 'theme/' prefix
|
||||
$relative = substr( $entry['path'], strlen( 'theme/' ) );
|
||||
$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;
|
||||
}
|
||||
|
||||
// Check if a matching file exists in the active theme
|
||||
$theme_file = get_template_directory() . '/' . $relative;
|
||||
$local_exists = file_exists( $theme_file );
|
||||
$local_content = $local_exists ? file_get_contents( $theme_file ) : null;
|
||||
|
||||
$out['files'][] = [
|
||||
'repo_path' => $entry['path'],
|
||||
'relative' => $relative,
|
||||
'content' => $content,
|
||||
'local_exists' => $local_exists,
|
||||
'local_content' => $local_content,
|
||||
'changed' => $local_exists ? ( $local_content !== $content ) : true,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
Reference in New Issue
Block a user