update( $wpdb->posts, [ 'post_content' => $content ], [ 'ID' => (int) $post_id ], [ '%s' ], [ '%d' ] ); clean_post_cache( (int) $post_id ); return $post_id; } /** * Clean previously-corrupted Gutenberg block content. * * Old syncs ran content through wp_kses_post which HTML-entity-encoded `&` * inside JSON attributes to `&`. php's json_encode then re-encoded that * `&` to `\u0026`, producing `\u0026amp;` instead of just `\u0026`. * * This function corrects those artefacts so block JSON attributes contain * the right unicode escape sequences. * * Also normalises plain `&` → `&` inside JSON block comments so the * next round of json_encode produces a single clean `\u0026`. * * @param string $content Gutenberg block HTML. * @return string Cleaned block HTML. */ function oribi_sync_clean_block_content( string $content ): string { // json_encode always hex-escapes & as \u0026 (even with JSON_UNESCAPED_UNICODE, // which only affects codepoints > U+007F). Previous syncs also ran content // through wp_kses_post which turned & into &, so json_encode then produced // \u0026amp; instead of just \u0026. // // Fix the double-encoded forms first, then unescape the remaining \u0026 back // to literal & — Gutenberg's block JSON parser treats both identically. // These sequences are unambiguous in Gutenberg block comment JSON. $content = str_replace( '\u0026amp;', '&', $content ); $content = str_replace( '\u0026lt;', '<', $content ); $content = str_replace( '\u0026gt;', '>', $content ); $content = str_replace( '\u0026quot;', '"', $content ); $content = str_replace( '\u0026#039;', "'", $content ); // Clean any remaining plain hex-escapes of ASCII punctuation $content = str_replace( '\u0026', '&', $content ); $content = str_replace( '\u003C', '<', $content ); $content = str_replace( '\u003E', '>', $content ); $content = str_replace( '\u0022', '"', $content ); $content = str_replace( '\u0027', "'", $content ); return $content; } /** * Strip a case-insensitive directory prefix from a file path. * * Example: oribi_sync_strip_prefix( 'Theme/header.php', 'theme' ) → 'header.php' */ function oribi_sync_strip_prefix( string $path, string $prefix ): string { $prefix = rtrim( $prefix, '/' ) . '/'; if ( strncasecmp( $path, $prefix, strlen( $prefix ) ) === 0 ) { return substr( $path, strlen( $prefix ) ); } return $path; } // ─── Gutenberg block helpers ────────────────────────────────────────────────── /** Generate a self-closing block comment (standalone or child blocks). */ if ( ! function_exists( 'oribi_b' ) ) { function oribi_b( $name, $attrs = [] ) { $json = wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); // json_encode always hex-escapes & < > ' for XSS safety, but these are // inside HTML comments so they are safe as literals in Gutenberg block JSON. $json = str_replace( [ '\u0026', '\u003C', '\u003E', '\u0022', '\u0027' ], [ '&', '<', '>', '"', "'" ], $json ); return ''; } } /** Generate an opening tag for a parent block comment. */ if ( ! function_exists( 'oribi_b_open' ) ) { function oribi_b_open( $name, $attrs = [] ) { if ( ! empty( $attrs ) ) { $json = wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $json = str_replace( [ '\u0026', '\u003C', '\u003E', '\u0022', '\u0027' ], [ '&', '<', '>', '"', "'" ], $json ); $json = ' ' . $json; } else { $json = ''; } return ''; } } /** Generate a closing tag for a parent block comment. */ if ( ! function_exists( 'oribi_b_close' ) ) { function oribi_b_close( $name ) { return ''; } } /** * 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. * @param bool $force If true, bypasses SHA-based change detection and re-pulls all files. * * @return array{ok: bool, created: string[], updated: string[], trashed: string[], skipped: string[], errors: string[]} */ function oribi_sync_run( bool $dry_run = false, bool $force = 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 ( ! $force && $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; } // Clean any corruption from previous syncs (e.g. \u0026amp; artefacts) $content = oribi_sync_clean_block_content( $content ); // Checksum based on raw source — used as fallback for providers without tree SHA $checksum = 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 = oribi_sync_save_post( [ 'ID' => $existing->ID, 'post_content' => $content, 'post_status' => 'publish', ] ); 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 = oribi_sync_save_post( [ 'post_title' => $title, 'post_name' => $slug, 'post_status' => 'publish', 'post_type' => 'page', 'post_content' => $content, ] ); 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', " [], 'errors' => [] ]; $allowed = [ 'css', 'js', 'json', 'php', 'html', 'htm', 'svg', 'txt' ]; $theme_dir = get_theme_root() . '/ots-theme'; // Create the ots-theme if it does not exist yet. if ( ! $dry_run ) { oribi_sync_ensure_ots_theme( $theme_dir ); } // Fetch the tree only if one wasn't passed in (avoids a redundant API call during sync). if ( $tree === null ) { $tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat ); if ( is_wp_error( $tree ) ) { $out['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message(); return $out; } } $theme_entries = oribi_sync_filter_tree( $tree, 'theme', true ); foreach ( $theme_entries as $entry ) { $relative = oribi_sync_strip_prefix( $entry['path'], 'theme' ); $ext = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) ); if ( ! in_array( $ext, $allowed, true ) ) { continue; } // Fetch content from repo $content = oribi_sync_fetch_file( $api_base, $branch, $entry['path'], $provider, $pat ); if ( is_wp_error( $content ) ) { $out['errors'][] = $entry['path'] . ': ' . $content->get_error_message(); continue; } $dest = $theme_dir . '/' . $relative; $local_exists = file_exists( $dest ); $local_content = $local_exists ? file_get_contents( $dest ) : null; // Skip unchanged files if ( $local_exists && $local_content === $content ) { continue; } if ( $dry_run ) { $out['updated'][] = $relative; continue; } // Create subdirectory if needed $dir = dirname( $dest ); if ( ! is_dir( $dir ) ) { if ( ! wp_mkdir_p( $dir ) ) { $out['errors'][] = $relative . ' — could not create directory.'; continue; } } $written = file_put_contents( $dest, $content ); if ( $written === false ) { $out['errors'][] = $relative . ' — write failed (check permissions).'; } else { $out['updated'][] = $relative; } } return $out; } /** * Pull a single page (and theme files) from the repo. * * Used by the admin-bar "Pull Page" button to re-sync only the page currently * being viewed plus all theme files, then returns a result array. * * @param int $post_id WordPress post ID. * * @return array{ok: bool, created: string[], updated: string[], skipped: string[], errors: string[], theme_updated: string[]} */ function oribi_sync_pull_page_from_repo( int $post_id ): array { $result = [ 'ok' => true, 'created' => [], 'updated' => [], 'skipped' => [], 'errors' => [], 'theme_updated' => [], ]; $post = get_post( $post_id ); if ( ! $post ) { $result['ok'] = false; $result['errors'][] = 'Post not found.'; return $result; } $slug = $post->post_name; // ── Gather settings ──────────────────────────────────────────────────── $repo_url = get_option( 'oribi_sync_repo', '' ); $branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main'; $pat = oribi_sync_get_pat(); if ( empty( $repo_url ) || empty( $pat ) ) { $result['ok'] = false; $result['errors'][] = 'Repository URL or PAT is not configured.'; return $result; } $parsed = oribi_sync_parse_repo_url( $repo_url ); if ( is_wp_error( $parsed ) ) { $result['ok'] = false; $result['errors'][] = $parsed->get_error_message(); return $result; } $provider = oribi_sync_get_provider(); $api_base = oribi_sync_api_base( $provider, $parsed ); // ── Fetch tree ───────────────────────────────────────────────────────── $tree = oribi_sync_fetch_tree( $api_base, $branch, $provider, $pat ); if ( is_wp_error( $tree ) ) { $result['ok'] = false; $result['errors'][] = 'Tree fetch failed: ' . $tree->get_error_message(); return $result; } // ── Find the matching page file ──────────────────────────────────────── $page_files = oribi_sync_filter_tree( $tree, 'Pages' ); $target_entry = null; foreach ( $page_files as $entry ) { $file_slug = oribi_sync_filename_to_slug( basename( $entry['path'] ) ); if ( $file_slug === $slug ) { $target_entry = $entry; break; } } if ( ! $target_entry ) { $result['skipped'][] = $slug . ' (not found in Pages/ directory)'; } else { $raw_content = oribi_sync_fetch_file( $api_base, $branch, $target_entry['path'], $provider, $pat ); if ( is_wp_error( $raw_content ) ) { $result['errors'][] = $target_entry['path'] . ': ' . $raw_content->get_error_message(); $result['ok'] = false; } else { $raw_content = trim( $raw_content ); $ext = strtolower( pathinfo( basename( $target_entry['path'] ), PATHINFO_EXTENSION ) ); if ( $ext === 'php' ) { $content = oribi_sync_execute_php( $raw_content, $slug ); if ( is_wp_error( $content ) ) { $result['errors'][] = $target_entry['path'] . ': ' . $content->get_error_message(); $result['ok'] = false; $content = null; } } else { $content = $raw_content; } if ( $content !== null ) { // Clean any corruption from previous syncs $content = oribi_sync_clean_block_content( $content ); $checksum = hash( 'sha256', $raw_content ); $git_sha = $target_entry['sha'] ?? ''; $update = oribi_sync_save_post( [ 'ID' => $post->ID, 'post_content' => $content, 'post_status' => 'publish', ] ); if ( is_wp_error( $update ) ) { $result['errors'][] = $slug . ': ' . $update->get_error_message(); $result['ok'] = false; } else { update_post_meta( $post->ID, '_oribi_sync_checksum', $checksum ); update_post_meta( $post->ID, '_oribi_sync_git_sha', $git_sha ); update_post_meta( $post->ID, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $target_entry['path'] ); update_post_meta( $post->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); update_post_meta( $post->ID, '_wp_page_template', 'default' ); $result['updated'][] = $slug; } } } } // ── Sync theme files ─────────────────────────────────────────────────── $theme_sync = oribi_sync_apply_theme_files( $api_base, $branch, $provider, $pat, false, $tree ); $result['theme_updated'] = $theme_sync['updated']; foreach ( $theme_sync['errors'] as $err ) { $result['errors'][] = '[theme] ' . $err; } return $result; } /** * Fetch theme files from the repo (for preview / apply). * * @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; }