Refactor Oribi Sync settings: remove pages folder option and enhance case-insensitive directory handling

This commit is contained in:
Matt Batchelder
2026-02-20 22:15:46 -05:00
parent 9e93ca27b4
commit 3c8c38acde
6 changed files with 113 additions and 148 deletions

Binary file not shown.

View File

@@ -35,12 +35,9 @@ add_action( 'admin_post_oribi_sync_save_settings', function () {
$provider = sanitize_text_field( wp_unslash( $_POST['oribi_sync_provider'] ?? '' ) );
$pat = wp_unslash( $_POST['oribi_sync_pat'] ?? '' );
$pages_folder = sanitize_text_field( wp_unslash( $_POST['oribi_sync_pages_folder'] ?? '' ) );
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)
if ( ! empty( $pat ) ) {
@@ -135,7 +132,6 @@ function oribi_sync_settings_page() {
$repo = get_option( 'oribi_sync_repo', '' );
$branch = get_option( 'oribi_sync_branch', 'main' );
$provider = get_option( 'oribi_sync_provider', '' );
$pages_folder = get_option( 'oribi_sync_pages_folder', '' );
$has_pat = ! empty( get_option( 'oribi_sync_pat', '' ) );
$last_run = get_option( 'oribi_sync_last_run', '' );
$log = get_option( 'oribi_sync_log', [] );
@@ -251,22 +247,6 @@ function oribi_sync_settings_page() {
</p>
</td>
</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>
<?php submit_button( 'Save Settings' ); ?>
@@ -386,42 +366,6 @@ function oribi_sync_settings_page() {
</details>
<?php endif; ?>
</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
}

View File

@@ -384,21 +384,23 @@ function oribi_sync_fetch_file( string $api_base, string $branch, string $file_p
/**
* Filter tree entries to only those under a given directory prefix.
* Matching is case-insensitive so Pages/, pages/, PAGES/ etc. all work.
*
* @param array $tree Tree from oribi_sync_fetch_tree().
* @param string $prefix Directory prefix (e.g. 'pages/').
* @param string $prefix Directory prefix (e.g. 'Pages').
* @param bool $recursive Whether to include files in subdirectories (default: false).
*
* @return array Filtered entries (blobs only).
*/
function oribi_sync_filter_tree( array $tree, string $prefix, bool $recursive = false ): array {
$prefix = rtrim( $prefix, '/' ) . '/';
$plen = strlen( $prefix );
$out = [];
foreach ( $tree as $entry ) {
if ( $entry['type'] !== 'blob' ) continue;
if ( strpos( $entry['path'], $prefix ) !== 0 ) continue;
$relative = substr( $entry['path'], strlen( $prefix ) );
if ( strncasecmp( $entry['path'], $prefix, $plen ) !== 0 ) continue;
$relative = substr( $entry['path'], $plen );
// Skip sub-directory files unless recursive is enabled
if ( ! $recursive && strpos( $relative, '/' ) !== false ) continue;
$out[] = $entry;

View File

@@ -11,6 +11,39 @@
if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Detect the actual casing of the Pages directory in the repo tree.
*
* Looks at existing synced pages for a stored repo path, extracts the
* directory prefix. Falls back to 'Pages/' if nothing found.
*/
function oribi_sync_detect_pages_prefix(): string {
// Check post meta of any previously-synced page for the real path
$existing = get_posts( [
'post_type' => 'page',
'meta_key' => '_oribi_sync_source',
'numberposts' => 1,
'fields' => 'ids',
] );
if ( ! empty( $existing ) ) {
$source = get_post_meta( $existing[0], '_oribi_sync_source', true );
// Extract the repo-path portion after the last colon (skip 'https:').
$colon = strrpos( $source, ':' );
if ( $colon !== false ) {
$path_part = substr( $source, $colon + 1 ); // e.g. 'pages/about.php'
// Validate it looks like a pages/ path before trusting it.
if ( strncasecmp( $path_part, 'pages/', 6 ) === 0 ) {
return substr( $path_part, 0, 6 ); // preserve original casing
}
}
}
return 'pages/';
}
// ─── Auto-push on page save ──────────────────────────────────────────────────
add_action( 'save_post_page', 'oribi_sync_maybe_push_on_save', 20, 3 );
@@ -242,18 +275,15 @@ function oribi_sync_generate_php_wrapper( string $content, string $slug, string
$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 .= "/*\n";
$php .= " * Title: {$title}\n";
$php .= " * Slug: {$slug}\n";
$php .= " * Pushed by Oribi Tech Sync on {$date}.\n";
$php .= " * Post Type: page\n";
$php .= " */\n\n";
$php .= "return <<<'{$delimiter}'\n";
$php .= $safe_content . "\n";
@@ -262,6 +292,38 @@ function oribi_sync_generate_php_wrapper( string $content, string $slug, string
return $php;
}
/**
* Replace the content body in an existing PHP page-data file.
*
* Preserves the original header (everything before the `return` statement)
* and only replaces the body between the heredoc / nowdoc delimiters.
* If the file format can't be parsed, falls back to generating a new wrapper.
*
* @param string $existing_source Current PHP source from the repo.
* @param string $new_content New Gutenberg block HTML.
* @param string $slug Page slug (used for fallback wrapper).
* @param string $title Page title (used for fallback wrapper).
*
* @return string Updated PHP source code.
*/
function oribi_sync_replace_php_body( string $existing_source, string $new_content, string $slug, string $title ): string {
// Match: return <<<'DELIMITER' or return <<<DELIMITER (heredoc / nowdoc)
if ( preg_match( '/^(.*?return\s+<<<\'?)(\w+)(\'?\s*\n)(.*)(\n\2;?\s*)$/s', $existing_source, $m ) ) {
$header = $m[1]; // everything up to and including "return <<<"
$delimiter = $m[2]; // e.g. ORIBI_SYNC_CONTENT
$quote_end = $m[3]; // closing quote + newline
$suffix = $m[5]; // closing delimiter + semicolon
// Escape content if it contains the delimiter string on its own line
$safe_content = str_replace( "\n" . $delimiter, "\n " . $delimiter, $new_content );
return $header . $delimiter . $quote_end . $safe_content . $suffix;
}
// Couldn't parse the existing file — fall back to a fresh wrapper.
return oribi_sync_generate_php_wrapper( $new_content, $slug, $title );
}
// ─── Push orchestrator ───────────────────────────────────────────────────────
/**
@@ -315,31 +377,30 @@ function oribi_sync_push_page( int $post_id, array $opts = [] ): array {
if ( ! empty( $source_meta ) ) {
// Format: {repo_url}@{branch}:{path}
$colon_pos = strpos( $source_meta, ':' );
// Use strrpos to find the LAST colon (skips the one in 'https:').
$colon_pos = strrpos( $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 );
$candidate = substr( $source_meta, $colon_pos + 1 );
// Validate: path must start with 'pages/' (case-insensitive).
// Discard corrupted values left by earlier bugs.
if ( strncasecmp( $candidate, 'pages/', 6 ) === 0 ) {
$repo_path = $candidate;
}
}
}
}
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';
// Derive from slug — files live under pages/
$repo_path = 'pages/' . $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";
@@ -350,6 +411,14 @@ function oribi_sync_push_page( int $post_id, array $opts = [] ): array {
return [ 'ok' => false, 'action' => 'error', 'message' => 'Failed to check remote file: ' . $remote->get_error_message() ];
}
// Build PHP source: preserve original header for existing files, fresh wrapper for new ones.
if ( $remote !== null && ! empty( $remote['content'] ) ) {
$php_source = oribi_sync_replace_php_body( $remote['content'], $wp_content, $slug, $title );
} else {
$php_source = oribi_sync_generate_php_wrapper( $wp_content, $slug, $title );
}
$new_checksum = hash( 'sha256', $php_source );
$stored_sha = get_post_meta( $post_id, '_oribi_sync_git_sha', true );
// ── Decide strategy ───────────────────────────────────────────────────

View File

@@ -30,15 +30,6 @@ 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',
@@ -160,53 +151,3 @@ function oribi_sync_rest_push( WP_REST_Request $request ): WP_REST_Response {
/**
* 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 );
}

View File

@@ -9,6 +9,21 @@
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). */
@@ -129,18 +144,12 @@ function oribi_sync_run( bool $dry_run = false ): array {
return $result;
}
// ── Filter to selected Pages sub-folder ────────────────────────────────
$pages_folder = get_option( 'oribi_sync_pages_folder', '' );
// ── Filter to Pages/ directory ─────────────────────────────────────────
$synced_slugs = [];
$page_files = oribi_sync_filter_tree( $tree, 'Pages' );
if ( empty( $pages_folder ) ) {
$result['skipped'][] = 'Pages sync skipped — no folder selected in settings.';
$page_files = [];
} 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.';
}
if ( empty( $page_files ) ) {
$result['skipped'][] = 'No files found under Pages/ in the repository.';
}
// ── Process each page file ─────────────────────────────────────────────
@@ -274,7 +283,7 @@ function oribi_sync_run( bool $dry_run = false ): array {
}
// ── Trash pages removed from repo ──────────────────────────────────────
if ( ! $dry_run && ! empty( $pages_folder ) ) {
if ( ! $dry_run ) {
$trashed = oribi_sync_trash_removed_pages( $synced_slugs );
$result['trashed'] = $trashed;
}
@@ -456,7 +465,7 @@ function oribi_sync_apply_theme_files( string $api_base, string $branch, string
$theme_entries = oribi_sync_filter_tree( $tree, 'theme', true );
foreach ( $theme_entries as $entry ) {
$relative = substr( $entry['path'], strlen( 'theme/' ) );
$relative = oribi_sync_strip_prefix( $entry['path'], 'theme' );
$ext = strtolower( pathinfo( $relative, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, $allowed, true ) ) {
@@ -540,7 +549,7 @@ function oribi_sync_fetch_theme_files(): array {
foreach ( $theme_entries as $entry ) {
// Derive relative path by stripping the 'theme/' prefix
$relative = substr( $entry['path'], strlen( 'theme/' ) );
$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 ) ) {