Files
WordpressGitSync/includes/admin.php
Matt Batchelder d2228ed0fb Add REST API endpoints for repo folder listing and page pushing
- Implemented `GET /repo-folders` to list available sub-folders in the configured repository.
- Added `POST /push` to push a single page to the repository.
- Introduced `POST /push-all` to push all synced pages back to the repository.
- Enhanced `oribi_sync_rest_sync` to push local changes after pulling, except during dry runs.
- Created `oribi_sync_push_page` and `oribi_sync_push_all` functions to handle page pushing logic.
- Updated post meta on successful pushes to track last push time and SHA.
- Added logging for push actions and errors.

Enhance sync engine to support theme file synchronization

- Added functionality to auto-apply changed theme files from the repository's theme directory.
- Created `oribi_sync_apply_theme_files` to handle theme file updates during sync.
- Ensured the existence of a minimal theme structure in the `ots-theme` directory.

Refactor uninstall process to clean up additional post meta

- Updated `uninstall.php` to remove new post meta related to push operations.
- Ensured comprehensive cleanup of options and metadata upon plugin uninstallation.

Introduce push client for handling page pushes to Gitea

- Created `push-client.php` to encapsulate logic for pushing pages back to the Git repository.
- Implemented conflict resolution by creating branches and opening pull requests when necessary.
- Added helper functions for authenticated API requests to Gitea.
2026-02-20 21:03:48 -05:00

445 lines
22 KiB
PHP

<?php
/**
* Oribi Sync — Admin settings page.
*
* Registers the Settings → Oribi Sync page, handles saving,
* and provides the "Sync Now" action.
*/
if ( ! defined( 'ABSPATH' ) ) exit;
// ─── Register admin menu ──────────────────────────────────────────────────────
add_action( 'admin_menu', function () {
add_options_page(
'Oribi Sync',
'Oribi Sync',
'manage_options',
'oribi-sync',
'oribi_sync_settings_page'
);
} );
// ─── Enqueue admin CSS on our page only ───────────────────────────────────────
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( $hook !== 'settings_page_oribi-sync' ) return;
wp_enqueue_style( 'oribi-sync-admin', ORIBI_SYNC_URL . 'assets/admin.css', [], ORIBI_SYNC_VERSION );
} );
// ─── Handle form submissions ──────────────────────────────────────────────────
add_action( 'admin_post_oribi_sync_save_settings', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_save_settings' );
$repo = sanitize_text_field( wp_unslash( $_POST['oribi_sync_repo'] ?? '' ) );
$branch = sanitize_text_field( wp_unslash( $_POST['oribi_sync_branch'] ?? 'main' ) );
$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 ) ) {
oribi_sync_save_pat( $pat );
}
wp_redirect( add_query_arg( 'oribi_sync_saved', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_run', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_run' );
$result = oribi_sync_run();
// After pulling, push any local changes back to the repo
$push = oribi_sync_push_all();
$result['push'] = $push['results'];
set_transient( 'oribi_sync_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_done', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_dry_run', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_dry_run' );
$result = oribi_sync_run( true );
set_transient( 'oribi_sync_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_done', 'dry', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_clear_pat', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_clear_pat' );
delete_option( 'oribi_sync_pat' );
wp_redirect( add_query_arg( 'oribi_sync_saved', 'pat_cleared', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
// ─── Push page(s) to repo ─────────────────────────────────────────────────────
add_action( 'admin_post_oribi_sync_push', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_push' );
$post_id = (int) ( $_POST['oribi_sync_push_post_id'] ?? 0 );
if ( $post_id > 0 ) {
$result = oribi_sync_push_page( $post_id );
set_transient( 'oribi_sync_push_result', $result, 60 );
}
wp_redirect( add_query_arg( 'oribi_sync_pushed', '1', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
add_action( 'admin_post_oribi_sync_push_all', function () {
if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Permission denied.' );
check_admin_referer( 'oribi_sync_push_all' );
$result = oribi_sync_push_all();
set_transient( 'oribi_sync_push_result', $result, 60 );
wp_redirect( add_query_arg( 'oribi_sync_pushed', 'all', admin_url( 'options-general.php?page=oribi-sync' ) ) );
exit;
} );
// ─── Settings page renderer ──────────────────────────────────────────────────
function oribi_sync_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) return;
$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', [] );
// Transient results
$sync_result = get_transient( 'oribi_sync_result' );
if ( $sync_result ) delete_transient( 'oribi_sync_result' );
$push_result = get_transient( 'oribi_sync_push_result' );
if ( $push_result ) delete_transient( 'oribi_sync_push_result' );
$saved = $_GET['oribi_sync_saved'] ?? '';
$done = $_GET['oribi_sync_done'] ?? '';
?>
<div class="wrap oribi-sync-wrap">
<h1>Oribi Sync</h1>
<?php // ── Flash notices ──────────────────────────────────────────── ?>
<?php if ( $saved === '1' ): ?>
<div class="notice notice-success is-dismissible"><p>Settings saved.</p></div>
<?php elseif ( $saved === 'pat_cleared' ): ?>
<div class="notice notice-warning is-dismissible"><p>PAT cleared.</p></div>
<?php endif; ?>
<?php if ( $sync_result ): ?>
<div class="notice <?php echo $sync_result['ok'] ? 'notice-success' : 'notice-error'; ?> is-dismissible">
<p><strong><?php echo $done === 'dry' ? 'Dry-run results' : 'Sync complete'; ?></strong></p>
<?php oribi_sync_render_result_list( $sync_result ); ?>
</div>
<?php endif; ?>
<?php if ( $push_result ): ?>
<?php if ( isset( $push_result['results'] ) ): ?>
<div class="notice <?php echo $push_result['ok'] ? 'notice-success' : 'notice-warning'; ?> is-dismissible">
<p><strong>Push results</strong></p>
<ul class="oribi-sync-result-list">
<?php foreach ( $push_result['results'] as $pr ): ?>
<li>
<strong><?php echo esc_html( $pr['slug'] ?? '' ); ?></strong> —
<?php echo esc_html( $pr['message'] ?? '' ); ?>
<?php if ( ! empty( $pr['pr_url'] ) ): ?>
<a href="<?php echo esc_url( $pr['pr_url'] ); ?>" target="_blank" rel="noopener">PR →</a>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php else: ?>
<div class="notice <?php echo $push_result['ok'] ? 'notice-success' : 'notice-error'; ?> is-dismissible">
<p>
<?php if ( ! empty( $push_result['pr_url'] ) ): ?>
Conflict — opened <a href="<?php echo esc_url( $push_result['pr_url'] ); ?>" target="_blank" rel="noopener">pull request</a> for review.
<?php else: ?>
<?php echo esc_html( $push_result['message'] ?? '' ); ?>
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<?php endif; ?>
<?php // ── Settings form ──────────────────────────────────────────── ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="oribi-sync-form">
<input type="hidden" name="action" value="oribi_sync_save_settings" />
<?php wp_nonce_field( 'oribi_sync_save_settings' ); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="oribi_sync_repo">Repository URL</label></th>
<td>
<input type="url" name="oribi_sync_repo" id="oribi_sync_repo"
value="<?php echo esc_attr( $repo ); ?>"
class="regular-text" placeholder="https://gitea.example.com/owner/repo" />
</td>
</tr>
<tr>
<th scope="row"><label for="oribi_sync_provider">Provider</label></th>
<td>
<select name="oribi_sync_provider" id="oribi_sync_provider" class="regular-text">
<option value=""<?php selected( $provider, '' ); ?>>Auto-detect</option>
<?php foreach ( oribi_sync_providers() as $key => $label ): ?>
<option value="<?php echo esc_attr( $key ); ?>"<?php selected( $provider, $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row"><label for="oribi_sync_branch">Branch</label></th>
<td>
<input type="text" name="oribi_sync_branch" id="oribi_sync_branch"
value="<?php echo esc_attr( $branch ); ?>"
class="regular-text" placeholder="main" />
</td>
</tr>
<tr>
<th scope="row"><label for="oribi_sync_pat">Access Token</label></th>
<td>
<input type="password" name="oribi_sync_pat" id="oribi_sync_pat"
value="" class="regular-text"
placeholder="<?php echo $has_pat ? '•••••••• (saved)' : 'token'; ?>"
autocomplete="off" />
<p class="description">
Needs <strong>write</strong> scope to push pages. Stored encrypted.
<?php if ( $has_pat ): ?>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_clear_pat' ), 'oribi_sync_clear_pat' ) ); ?>"
onclick="return confirm('Clear the stored PAT?');"
class="oribi-sync-danger-link">Clear</a>
<?php endif; ?>
</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' ); ?>
</form>
<hr />
<?php // ── Actions ────────────────────────────────────────────────── ?>
<h2>Actions</h2>
<p class="oribi-sync-actions">
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_run' ), 'oribi_sync_run' ) ); ?>"
class="button button-primary"
onclick="return confirm('Pull from repo then push local changes. Continue?');">
Sync (Pull &amp; Push)
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_dry_run' ), 'oribi_sync_dry_run' ) ); ?>"
class="button">
Dry Run
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_theme_preview' ), 'oribi_sync_theme_preview' ) ); ?>"
class="button">
Preview Theme
</a>
<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=oribi_sync_push_all' ), 'oribi_sync_push_all' ) ); ?>"
class="button"
onclick="return confirm('Push ALL synced pages to the repo?');">
Push All
</a>
</p>
<?php if ( $last_run ): ?>
<p class="oribi-sync-muted">Last sync: <?php echo esc_html( $last_run ); ?></p>
<?php endif; ?>
<?php // ── Synced pages ───────────────────────────────────────────── ?>
<?php
$synced_pages = new WP_Query( [
'post_type' => 'page',
'post_status' => 'publish',
'meta_key' => '_oribi_sync_checksum',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
] );
if ( $synced_pages->have_posts() ): ?>
<table class="widefat fixed striped oribi-sync-pages">
<thead><tr>
<th>Page</th>
<th style="width:80px;">Push</th>
</tr></thead>
<tbody>
<?php while ( $synced_pages->have_posts() ): $synced_pages->the_post();
$pid = get_the_ID();
$last_push = get_post_meta( $pid, '_oribi_sync_last_push', true );
$pr_url = get_post_meta( $pid, '_oribi_sync_pr_url', true );
?>
<tr>
<td>
<strong><?php the_title(); ?></strong>
<span class="oribi-sync-muted">/<?php echo esc_html( get_post_field( 'post_name', $pid ) ); ?></span>
<?php if ( $pr_url ): ?>
<a href="<?php echo esc_url( $pr_url ); ?>" target="_blank" rel="noopener" class="oribi-sync-pr-badge">PR</a>
<?php endif; ?>
<?php if ( $last_push ): ?>
<br /><span class="oribi-sync-muted">pushed <?php echo esc_html( $last_push ); ?></span>
<?php endif; ?>
</td>
<td>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin:0;">
<input type="hidden" name="action" value="oribi_sync_push" />
<input type="hidden" name="oribi_sync_push_post_id" value="<?php echo esc_attr( $pid ); ?>" />
<?php wp_nonce_field( 'oribi_sync_push' ); ?>
<button type="submit" class="button button-small">Push</button>
</form>
</td>
</tr>
<?php endwhile; wp_reset_postdata(); ?>
</tbody>
</table>
<?php endif; ?>
<?php // ── Theme preview (conditional) ────────────────────────────── ?>
<?php if ( isset( $_GET['oribi_sync_tab'] ) && $_GET['oribi_sync_tab'] === 'theme' ): ?>
<hr />
<h2>Theme Files Preview</h2>
<?php oribi_sync_render_theme_preview(); ?>
<?php endif; ?>
<?php // ── Log ────────────────────────────────────────────────────── ?>
<?php if ( ! empty( $log ) ): ?>
<hr />
<details>
<summary>Sync Log (<?php echo count( $log ); ?>)</summary>
<table class="widefat fixed striped oribi-sync-log">
<thead><tr>
<th style="width:150px;">Time</th>
<th>Created</th>
<th>Updated</th>
<th>Errors</th>
</tr></thead>
<tbody>
<?php foreach ( array_slice( $log, 0, 10 ) as $entry ): ?>
<tr>
<td><?php echo esc_html( $entry['time'] ?? '—' ); ?></td>
<td><?php echo esc_html( implode( ', ', $entry['created'] ?? [] ) ?: '—' ); ?></td>
<td><?php echo esc_html( implode( ', ', array_merge( $entry['updated'] ?? [], $entry['theme_updated'] ?? [] ) ) ?: '—' ); ?></td>
<td class="oribi-sync-error-text"><?php echo esc_html( implode( '; ', $entry['errors'] ?? [] ) ?: '—' ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</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
}
/**
* Render a sync result as a compact <ul>.
*/
function oribi_sync_render_result_list( array $r ): void {
$items = [];
if ( ! empty( $r['created'] ) ) $items[] = 'Created: ' . implode( ', ', $r['created'] );
if ( ! empty( $r['updated'] ) ) $items[] = 'Updated: ' . implode( ', ', $r['updated'] );
if ( ! empty( $r['theme_updated'] ) ) $items[] = 'Theme: ' . implode( ', ', $r['theme_updated'] );
if ( ! empty( $r['trashed'] ) ) $items[] = 'Trashed: ' . implode( ', ', $r['trashed'] );
if ( ! empty( $r['skipped'] ) ) $items[] = 'Skipped: ' . implode( ', ', $r['skipped'] );
if ( ! empty( $r['errors'] ) ) $items[] = 'Errors: ' . implode( '; ', $r['errors'] );
if ( empty( $items ) ) { echo '<p>No changes.</p>'; return; }
echo '<ul class="oribi-sync-result-list">';
foreach ( $items as $item ) {
$class = ( strpos( $item, 'Errors:' ) === 0 ) ? ' class="oribi-sync-error-text"' : '';
echo '<li' . $class . '>' . esc_html( $item ) . '</li>';
}
// Append push results if present
if ( ! empty( $r['push'] ) && is_array( $r['push'] ) ) {
foreach ( $r['push'] as $pr ) {
$slug = $pr['slug'] ?? '';
$msg = $pr['message'] ?? '';
$url = $pr['pr_url'] ?? '';
$text = "Pushed: {$slug} — {$msg}";
$cls = $pr['ok'] ? '' : ' class="oribi-sync-error-text"';
echo '<li' . $cls . '>' . esc_html( $text );
if ( $url ) {
echo ' <a href="' . esc_url( $url ) . '" target="_blank" rel="noopener">PR →</a>';
}
echo '</li>';
}
}
echo '</ul>';
}