Files
WordpressGitSync/includes/sync-engine.php
Matt Batchelder d56d46490a Add post synchronization functionality for Markdown files
- Implemented a parser for YAML front-matter in Markdown files.
- Developed functions to convert Markdown content to HTML.
- Created a pipeline to sync WordPress posts from a specified folder in a Git repository.
- Added media import capabilities to handle images referenced in Markdown.
- Implemented author resolution and post slug generation.
- Included error handling and logging for sync operations.
- Enabled trashing of posts that are no longer present in the repository.
2026-02-21 10:44:34 -05:00

596 lines
22 KiB
PHP

<?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;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Strip a case-insensitive directory prefix from a file path.
*
* Example: oribi_sync_strip_prefix( 'Theme/header.php', 'theme' ) → 'header.php'
*/
function oribi_sync_strip_prefix( string $path, string $prefix ): string {
$prefix = rtrim( $prefix, '/' ) . '/';
if ( strncasecmp( $path, $prefix, strlen( $prefix ) ) === 0 ) {
return substr( $path, strlen( $prefix ) );
}
return $path;
}
// ─── Gutenberg block helpers ──────────────────────────────────────────────────
/** Generate a self-closing block comment (standalone or child blocks). */
if ( ! function_exists( 'oribi_b' ) ) {
function oribi_b( $name, $attrs = [] ) {
return '<!-- wp:oribi/' . $name . ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . ' /-->';
}
}
/** Generate an opening tag for a parent block comment. */
if ( ! function_exists( 'oribi_b_open' ) ) {
function oribi_b_open( $name, $attrs = [] ) {
$json = ! empty( $attrs ) ? ' ' . wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) : '';
return '<!-- wp:oribi/' . $name . $json . ' -->';
}
}
/** Generate a closing tag for a parent block comment. */
if ( ! function_exists( 'oribi_b_close' ) ) {
function oribi_b_close( $name ) {
return '<!-- /wp:oribi/' . $name . ' -->';
}
}
/**
* 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 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 ) {
// 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' => [],
'theme_updated' => [],
'posts_created' => [],
'posts_updated' => [],
'posts_trashed' => [],
];
// ── 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 ─────────────────────────────────────────
$synced_slugs = [];
$page_files = oribi_sync_filter_tree( $tree, 'Pages' );
if ( empty( $page_files ) ) {
$result['skipped'][] = 'No files found under Pages/ in the repository.';
}
// ── Process each page file ─────────────────────────────────────────────
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' );
$content_size = strlen( $content );
$result['updated'][] = $slug . ' (' . $content_size . ' bytes)';
} 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' );
$content_size = strlen( $content );
$result['created'][] = $slug . ' (' . $content_size . ' bytes)';
}
}
// ── Trash pages removed from repo ──────────────────────────────────────
if ( ! $dry_run ) {
$trashed = oribi_sync_trash_removed_pages( $synced_slugs );
$result['trashed'] = $trashed;
}
// ── Auto-sync theme files ──────────────────────────────────────────────
// Reuse the already-fetched $tree so we don't make a second API call.
$theme_sync = oribi_sync_apply_theme_files( $api_base, $branch, $provider, $pat, $dry_run, $tree );
$result['theme_updated'] = $theme_sync['updated'];
foreach ( $theme_sync['errors'] as $err ) {
$result['errors'][] = '[theme] ' . $err;
}
// ── Sync posts from repo posts folder ─────────────────────────────────
$posts_sync = oribi_sync_run_posts( $api_base, $branch, $provider, $pat, $tree, $dry_run );
$result['posts_created'] = $posts_sync['created'];
$result['posts_updated'] = $posts_sync['updated'];
$result['posts_trashed'] = $posts_sync['trashed'];
foreach ( $posts_sync['skipped'] as $sk ) {
$result['skipped'][] = '[post] ' . $sk;
}
foreach ( $posts_sync['errors'] as $err ) {
$result['errors'][] = '[post] ' . $err;
}
// ── Record run ─────────────────────────────────────────────────────────
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'],
'theme_updated' => $result['theme_updated'] ?? [],
'posts_created' => $result['posts_created'] ?? [],
'posts_updated' => $result['posts_updated'] ?? [],
'posts_trashed' => $result['posts_trashed'] ?? [],
] );
// Keep last 20 entries
$log = array_slice( $log, 0, 20 );
update_option( 'oribi_sync_log', $log, 'no' );
}
/**
* Ensure the ots-theme directory exists with a minimal style.css header.
*
* WordPress requires style.css with a "Theme Name:" header to recognise a theme.
* If the repo's theme/ folder already contains a style.css it will overwrite
* this stub during sync, so the stub is only a bootstrap.
*/
function oribi_sync_ensure_ots_theme( string $theme_dir ): void {
if ( is_dir( $theme_dir ) && file_exists( $theme_dir . '/style.css' ) ) {
return; // Already exists.
}
if ( ! is_dir( $theme_dir ) ) {
wp_mkdir_p( $theme_dir );
}
// Only write the stub if style.css doesn't exist yet.
if ( ! file_exists( $theme_dir . '/style.css' ) ) {
$header = <<<'CSS'
/*
Theme Name: OTS Theme
Description: Auto-created by Oribi Tech Sync. Theme files are managed via Git.
Version: 1.0.0
Author: Oribi Technology Services
*/
CSS;
file_put_contents( $theme_dir . '/style.css', $header );
}
// Create a minimal index.php (required by WP theme standards).
if ( ! file_exists( $theme_dir . '/index.php' ) ) {
file_put_contents( $theme_dir . '/index.php', "<?php\n// Silence is golden.\n" );
}
}
/**
* Auto-apply changed theme files from the repo's theme/ directory to ots-theme.
*
* Called as part of every sync run.
*
* @param string $api_base Provider API base URL.
* @param string $branch Branch name.
* @param string $provider Provider key.
* @param string $pat Personal access token.
* @param bool $dry_run If true, report changes without writing files.
*
* @return array{updated: string[], errors: string[]}
*/
function oribi_sync_apply_theme_files( string $api_base, string $branch, string $provider, string $pat, bool $dry_run = false, ?array $tree = null ): array {
$out = [ 'updated' => [], 'errors' => [] ];
$allowed = [ 'css', 'js', 'json', 'php', 'html', 'htm', 'svg', 'txt' ];
$theme_dir = get_theme_root() . '/ots-theme';
// Create the ots-theme if it does not exist yet.
if ( ! $dry_run ) {
oribi_sync_ensure_ots_theme( $theme_dir );
}
// Fetch the tree only if one wasn't passed in (avoids a redundant API call during sync).
if ( $tree === null ) {
$tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat );
if ( is_wp_error( $tree ) ) {
$out['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message();
return $out;
}
}
$theme_entries = oribi_sync_filter_tree( $tree, 'theme', true );
foreach ( $theme_entries as $entry ) {
$relative = oribi_sync_strip_prefix( $entry['path'], 'theme' );
$ext = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, $allowed, true ) ) {
continue;
}
// Fetch content from repo
$content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat );
if ( is_wp_error( $content ) ) {
$out['errors'][] = $entry['path'] . ': ' . $content->get_error_message();
continue;
}
$dest = $theme_dir . '/' . $relative;
$local_exists = file_exists( $dest );
$local_content = $local_exists ? file_get_contents( $dest ) : null;
// Skip unchanged files
if ( $local_exists && $local_content === $content ) {
continue;
}
if ( $dry_run ) {
$out['updated'][] = $relative;
continue;
}
// Create subdirectory if needed
$dir = dirname( $dest );
if ( ! is_dir( $dir ) ) {
if ( ! wp_mkdir_p( $dir ) ) {
$out['errors'][] = $relative . ' — could not create directory.';
continue;
}
}
$written = file_put_contents( $dest, $content );
if ( $written === false ) {
$out['errors'][] = $relative . ' — write failed (check permissions).';
} else {
$out['updated'][] = $relative;
}
}
return $out;
}
/**
* 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 = oribi_sync_strip_prefix( $entry['path'], '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 ots-theme
$theme_dir = get_theme_root() . '/ots-theme';
$theme_file = $theme_dir . '/' . $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;
}