diff --git a/README.md b/README.md index 7ab30c0..74c1b00 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ WordPress plugin that syncs pages and theme files from a remote Git repository. ## Features -- **Page sync** — Reads Gutenberg HTML files from the repo's `pages/` directory and creates/updates WordPress pages automatically. +- **Page sync (pull)** — Reads Gutenberg HTML files from the repo's `pages/` directory and creates/updates WordPress pages automatically. +- **Page push** — Push WordPress page content back to the repo as PHP page-data files. On conflict (remote file changed since last sync), automatically creates a branch and opens a pull request for review. - **Theme file preview & apply** — Fetches files from the repo's `theme/` directory, shows a side-by-side preview against the active theme, and lets an admin selectively apply changes. - **Encrypted PAT storage** — Personal Access Tokens are stored encrypted (AES-256-CBC) in the database with `autoload=false`. - **Dry-run mode** — Preview what a sync would do without making any changes. - **Sync log** — Keeps a history of the last 20 syncs with details on created, updated, trashed, and skipped pages. -- **REST API & webhook** — Trigger syncs programmatically or via Git host webhooks. +- **REST API & webhook** — Trigger syncs and pushes programmatically or via Git host webhooks. - **Trash policy** — Pages removed from the repo are moved to Trash for manual review. ## Repository Layout @@ -56,7 +57,7 @@ repo/ | **GitHub** (github.com + GHE) | `Bearer` token | Fine-grained PAT with `Contents: Read` | | **GitLab** (gitlab.com + self-hosted) | `PRIVATE-TOKEN` header | Project/personal access token with `read_repository` | | **Bitbucket Cloud** | Basic or Bearer | App password (`username:app_password`) or repository token | -| **Gitea / Forgejo** | `token` header | Application token with repo read access | +| **Gitea / Forgejo** | `token` header | Application token with repo **read + write** access | | **Azure DevOps** | Basic (`:PAT`) | Personal access token with `Code: Read` scope | Select your provider on the settings page, or leave it on "Auto-detect" to infer from the URL. @@ -78,6 +79,11 @@ Select your provider on the settings page, or leave it on "Auto-detect" to infer - Click **Dry Run** to preview changes without modifying anything. - Click **Preview Theme Files** to fetch and review theme files from the repo. +### Push Pages to Repo +- The **Push Pages to Repo** section lists all synced pages with individual **Push** buttons and a **Push All Pages** button. +- Pushing converts the page's Gutenberg content into a PHP page-data file and commits it to the configured branch. +- **Conflict handling:** If the remote file has changed since the last sync (SHA mismatch), the plugin creates a branch named `oribi-sync/{slug}-{timestamp}` and opens a **pull request** for manual review. A link to the PR is shown in the admin UI. + ### REST API All REST endpoints require `manage_options` capability (authenticated admin). @@ -93,6 +99,18 @@ curl -X POST "https://yoursite.com/wp-json/oribi-sync/v1/sync?dry_run=1" \ -H "X-WP-Nonce: " \ --cookie "wordpress_logged_in_...=..." +# Push a single page +curl -X POST https://yoursite.com/wp-json/oribi-sync/v1/push \ + -H "X-WP-Nonce: " \ + -H "Content-Type: application/json" \ + -d '{"post_id": 123}' \ + --cookie "wordpress_logged_in_...=..." + +# Push all synced pages +curl -X POST https://yoursite.com/wp-json/oribi-sync/v1/push-all \ + -H "X-WP-Nonce: " \ + --cookie "wordpress_logged_in_...=..." + # Get status curl https://yoursite.com/wp-json/oribi-sync/v1/status \ -H "X-WP-Nonce: " \ @@ -131,6 +149,9 @@ Set up a webhook on your Git host to trigger syncs on push: | File removed from `pages/` | Move corresponding WP page to Trash | | New file in `theme/` | Available for preview & manual apply | | Changed file in `theme/` | Available for preview & manual apply | +| **Push:** page not in repo | Create `.php` file on target branch | +| **Push:** page in repo, no conflict | Update `.php` file on target branch | +| **Push:** page in repo, SHA conflict | Create branch `oribi-sync/{slug}-{timestamp}`, commit, open PR | ## Requirements diff --git a/assets/admin.css b/assets/admin.css index 3396c5c..cf5158c 100644 --- a/assets/admin.css +++ b/assets/admin.css @@ -35,3 +35,55 @@ .oribi-sync-wrap .description code { font-size: 12px; } + +/* ── Simplified layout ───────────────────────────────────── */ + +.oribi-sync-muted { + color: #646970; + font-size: 13px; +} + +.oribi-sync-error-text { + color: #d63638; +} + +.oribi-sync-danger-link { + color: #d63638; + margin-left: 4px; +} + +.oribi-sync-actions .button { + margin-right: 8px; +} + +.oribi-sync-result-list { + list-style: disc; + padding-left: 1.5rem; + margin: 4px 0 8px; +} + +.oribi-sync-pages { + max-width: 700px; + margin-top: 12px; +} + +.oribi-sync-pages td form { + margin: 0; +} + +.oribi-sync-pr-badge { + display: inline-block; + background: #ddf4ff; + color: #0969da; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 10px; + margin-left: 6px; + text-decoration: none; + vertical-align: middle; +} + +.oribi-sync-pr-badge:hover { + background: #b6e3ff; +} diff --git a/dist/oribi-tech-sync.zip b/dist/oribi-tech-sync.zip index 54d6f44..4818d87 100644 Binary files a/dist/oribi-tech-sync.zip and b/dist/oribi-tech-sync.zip differ diff --git a/includes/admin.php b/includes/admin.php index 8a6e0f1..4bb0b80 100644 --- a/includes/admin.php +++ b/includes/admin.php @@ -35,9 +35,12 @@ 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'] ?? '' ); - update_option( 'oribi_sync_repo', $repo, 'no' ); - update_option( 'oribi_sync_branch', $branch, 'no' ); - update_option( 'oribi_sync_provider', $provider, 'no' ); + $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 ) ) { @@ -54,6 +57,10 @@ add_action( 'admin_post_oribi_sync_run', function () { $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' ) ) ); @@ -82,59 +89,101 @@ add_action( 'admin_post_oribi_sync_clear_pat', function () { 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', '' ); - $has_pat = ! empty( get_option( 'oribi_sync_pat', '' ) ); - $last_run = get_option( 'oribi_sync_last_run', '' ); - $log = get_option( 'oribi_sync_log', [] ); + $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 result (after sync / dry-run) + // 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'] ?? ''; + $saved = $_GET['oribi_sync_saved'] ?? ''; + $done = $_GET['oribi_sync_done'] ?? ''; ?>

Oribi Sync

+

Settings saved.

-

PAT has been cleared.

+

PAT cleared.

-
-

- -

-
    - -
  • Created:
  • - - -
  • Updated:
  • - - -
  • Trashed:
  • - - -
  • Skipped:
  • - - -
  • Errors:
  • - -
+
+

+
- + + +
+

Push results

+
    + +
  • + — + + + PR → + +
  • + +
+
+ +
+

+ + Conflict — opened pull request for review. + + + +

+
+ + + +
@@ -145,22 +194,20 @@ function oribi_sync_settings_page() { -

HTTPS URL to the Git repository (any provider).

+ class="regular-text" placeholder="https://gitea.example.com/owner/repo" /> -

Select your Git hosting provider, or leave on auto-detect.

@@ -172,23 +219,38 @@ function oribi_sync_settings_page() { - +

- Read token for your repo. Stored encrypted in the database. + Needs write scope to push pages. Stored encrypted. -   Clear PAT + class="oribi-sync-danger-link">Clear

+ + + + + + +

Sub-folder inside Pages/ to sync from.

+ + @@ -196,70 +258,187 @@ function oribi_sync_settings_page() {
- -

Sync Actions

-

- The plugin reads two folders from the repo: pages/ (PHP or HTML files → WP pages) - and theme/ (theme style files → preview & manual apply). Everything else is ignored.
- PHP files are executed using the oribi_b() block helpers (requires Oribi Tech Setup plugin). - HTML files are used as raw Gutenberg block markup. -

-

+ +

Actions

+

- 🔄 Sync Now + onclick="return confirm('Pull from repo then push local changes. Continue?');"> + Sync (Pull & Push) - - 🔍 Dry Run + Dry Run - - 🎨 Preview Theme Files + Preview Theme + + + Push All

- -

Last sync:

+

Last sync:

- + + 'page', + 'post_status' => 'publish', + 'meta_key' => '_oribi_sync_checksum', + 'posts_per_page' => -1, + 'orderby' => 'title', + 'order' => 'ASC', + ] ); + + if ( $synced_pages->have_posts() ): ?> + + + + + + + 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 ); + ?> + + + + + + +
PagePush
+ + / + + PR + + +
pushed + +
+ + + + + + +
+ + +

Theme Files Preview

- +
-

Sync Log

- - - - - - - - - - - - - - - - - - - - - -
TimeCreatedUpdatedTrashedSkippedErrors
+
+ Sync Log () + + + + + + + + + + + + + + + + + +
TimeCreatedUpdatedErrors
+
+ + . + */ +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 '

No changes.

'; return; } + + echo '
    '; + foreach ( $items as $item ) { + $class = ( strpos( $item, 'Errors:' ) === 0 ) ? ' class="oribi-sync-error-text"' : ''; + echo '' . esc_html( $item ) . ''; + } + + // 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 '' . esc_html( $text ); + if ( $url ) { + echo ' PR →'; + } + echo ''; + } + } + + echo '
'; +} diff --git a/includes/push-client.php b/includes/push-client.php new file mode 100644 index 0000000..f0c50e4 --- /dev/null +++ b/includes/push-client.php @@ -0,0 +1,530 @@ +post_status !== 'publish' ) return; + + // Guard: only pages that came from a sync (have checksum meta) + $checksum = get_post_meta( $post_id, '_oribi_sync_checksum', true ); + if ( empty( $checksum ) ) return; + + // Guard: prevent re-entry when push updates meta on the same post + static $pushing = []; + if ( isset( $pushing[ $post_id ] ) ) return; + $pushing[ $post_id ] = true; + + oribi_sync_push_page( $post_id ); + + unset( $pushing[ $post_id ] ); +} + +// ─── Generic authenticated request helpers ──────────────────────────────────── + +/** + * Perform an authenticated POST request to the Git API. + * + * @param string $url Full API URL. + * @param array $body Body payload (will be JSON-encoded). + * @param string $provider Provider key. + * @param string $pat Personal access token. + * + * @return array{code: int, body: array|string}|WP_Error + */ +function oribi_sync_api_post( string $url, array $body, string $provider, string $pat ) { + return oribi_sync_api_request( 'POST', $url, $body, $provider, $pat ); +} + +/** + * Perform an authenticated PUT request to the Git API. + * + * @return array{code: int, body: array|string}|WP_Error + */ +function oribi_sync_api_put( string $url, array $body, string $provider, string $pat ) { + return oribi_sync_api_request( 'PUT', $url, $body, $provider, $pat ); +} + +/** + * Perform an authenticated DELETE request to the Git API. + * + * @return array{code: int, body: array|string}|WP_Error + */ +function oribi_sync_api_delete( string $url, array $body, string $provider, string $pat ) { + return oribi_sync_api_request( 'DELETE', $url, $body, $provider, $pat ); +} + +/** + * Internal: send an authenticated JSON request. + * + * @return array{code: int, body: array|string}|WP_Error + */ +function oribi_sync_api_request( string $method, string $url, array $body, string $provider, string $pat ) { + $headers = array_merge( + oribi_sync_auth_headers( $provider, $pat ), + [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'User-Agent' => 'Oribi-Sync-WP/' . ORIBI_SYNC_VERSION, + ] + ); + + $args = [ + 'method' => $method, + 'timeout' => 30, + 'headers' => $headers, + 'body' => wp_json_encode( $body ), + ]; + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = wp_remote_retrieve_response_code( $response ); + $raw_body = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $raw_body, true ); + + if ( json_last_error() !== JSON_ERROR_NONE ) { + $decoded = $raw_body; + } + + return [ 'code' => $code, 'body' => $decoded ]; +} + +// ─── Gitea file metadata ────────────────────────────────────────────────────── + +/** + * Get file metadata (including current SHA) from the Gitea API. + * + * @param string $api_base Gitea API base (e.g. https://host/api/v1/repos/owner/repo). + * @param string $branch Branch name. + * @param string $filepath Repo-relative file path. + * @param string $pat Personal access token. + * + * @return array{sha: string, content: string}|null|WP_Error + * null if file does not exist (404), WP_Error on failure. + */ +function oribi_sync_gitea_get_file_meta( string $api_base, string $branch, string $filepath, string $pat ) { + $encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) ); + $url = $api_base . '/contents/' . $encoded_path . '?ref=' . rawurlencode( $branch ); + + $result = oribi_sync_api_get( $url, 'gitea', $pat ); + + if ( is_wp_error( $result ) ) { + $msg = $result->get_error_message(); + // 404 means file doesn't exist yet — not an error + if ( strpos( $msg, 'HTTP 404' ) !== false ) { + return null; + } + return $result; + } + + return [ + 'sha' => $result['sha'] ?? '', + 'content' => isset( $result['content'] ) ? base64_decode( $result['content'] ) : '', + ]; +} + +// ─── Gitea file create / update ─────────────────────────────────────────────── + +/** + * Create or update a file in a Gitea repository. + * + * @param string $api_base API base URL. + * @param string $branch Target branch. + * @param string $filepath Repo-relative path. + * @param string $content Raw file content (will be base64-encoded). + * @param string $pat Personal access token. + * @param string|null $sha Current file SHA (required for updates; null for creates). + * @param string $message Commit message. + * + * @return array{code: int, body: array}|WP_Error + */ +function oribi_sync_gitea_put_file( + string $api_base, + string $branch, + string $filepath, + string $content, + string $pat, + ?string $sha = null, + string $message = '' +) { + $encoded_path = implode( '/', array_map( 'rawurlencode', explode( '/', $filepath ) ) ); + $url = $api_base . '/contents/' . $encoded_path; + + $body = [ + 'content' => base64_encode( $content ), + 'branch' => $branch, + 'message' => $message ?: 'Update ' . basename( $filepath ), + ]; + + if ( $sha !== null ) { + $body['sha'] = $sha; + return oribi_sync_api_put( $url, $body, 'gitea', $pat ); + } + + return oribi_sync_api_post( $url, $body, 'gitea', $pat ); +} + +// ─── Gitea branch creation ─────────────────────────────────────────────────── + +/** + * Create a new branch in a Gitea repository. + * + * @return array{code: int, body: array}|WP_Error + */ +function oribi_sync_gitea_create_branch( string $api_base, string $new_branch, string $base_branch, string $pat ) { + $url = $api_base . '/branches'; + + return oribi_sync_api_post( $url, [ + 'new_branch_name' => $new_branch, + 'old_branch_name' => $base_branch, + ], 'gitea', $pat ); +} + +// ─── Gitea pull request ────────────────────────────────────────────────────── + +/** + * Open a pull request in a Gitea repository. + * + * @return array{code: int, body: array}|WP_Error + */ +function oribi_sync_gitea_create_pr( string $api_base, string $head, string $base, string $title, string $body_text, string $pat ) { + $url = $api_base . '/pulls'; + + return oribi_sync_api_post( $url, [ + 'title' => $title, + 'body' => $body_text, + 'head' => $head, + 'base' => $base, + ], 'gitea', $pat ); +} + +// ─── PHP page-data wrapper generation ───────────────────────────────────────── + +/** + * Generate a PHP page-data wrapper suitable for the repo's page-data convention. + * + * The wrapper uses a nowdoc return so the file can be `include`d by + * oribi_sync_execute_php() and produce the Gutenberg block HTML. + * + * @param string $content Gutenberg block HTML (from post_content). + * @param string $slug Page slug. + * @param string $title Page title. + * + * @return string PHP source code. + */ +function oribi_sync_generate_php_wrapper( string $content, string $slug, string $title = '' ): string { + if ( empty( $title ) ) { + $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 = "post_type !== 'page' ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Post not found or not a page.' ]; + } + + // ── Settings ────────────────────────────────────────────────────────── + $repo_url = get_option( 'oribi_sync_repo', '' ); + $branch = get_option( 'oribi_sync_branch', 'main' ) ?: 'main'; + $pat = oribi_sync_get_pat(); + $provider = oribi_sync_get_provider(); + + if ( empty( $repo_url ) || empty( $pat ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Repository URL or PAT not configured.' ]; + } + + if ( $provider !== 'gitea' ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Push is currently supported for Gitea / Forgejo only.' ]; + } + + $parsed = oribi_sync_parse_repo_url( $repo_url ); + if ( is_wp_error( $parsed ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => $parsed->get_error_message() ]; + } + + $api_base = oribi_sync_api_base( $provider, $parsed ); + + // ── Determine repo path ─────────────────────────────────────────────── + $source_meta = get_post_meta( $post_id, '_oribi_sync_source', true ); + $repo_path = ''; + + if ( ! empty( $source_meta ) ) { + // Format: {repo_url}@{branch}:{path} + $colon_pos = strpos( $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 ); + } + } + } + + 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'; + } + + // ── 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"; + + // ── Fetch remote file metadata ──────────────────────────────────────── + $remote = oribi_sync_gitea_get_file_meta( $api_base, $branch, $repo_path, $pat ); + + if ( is_wp_error( $remote ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Failed to check remote file: ' . $remote->get_error_message() ]; + } + + $stored_sha = get_post_meta( $post_id, '_oribi_sync_git_sha', true ); + + // ── Decide strategy ─────────────────────────────────────────────────── + if ( $remote === null ) { + // File doesn't exist in repo → create it directly + $result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, null, $commit_msg ); + if ( is_wp_error( $result ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Create failed: ' . $result->get_error_message() ]; + } + if ( $result['code'] < 200 || $result['code'] >= 300 ) { + $err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body']; + return [ 'ok' => false, 'action' => 'error', 'message' => "Create failed (HTTP {$result['code']}): {$err}" ]; + } + + // Update post meta + $new_sha = $result['body']['content']['sha'] ?? ''; + oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path ); + oribi_sync_log_push( $slug, 'created', $branch ); + + return [ 'ok' => true, 'action' => 'created', 'message' => "Created {$repo_path} on branch {$branch}." ]; + } + + $remote_sha = $remote['sha']; + + // Check for conflict: remote SHA differs from what we last synced + $has_conflict = ! empty( $stored_sha ) && $remote_sha !== $stored_sha; + + if ( $has_conflict ) { + // ── Conflict path: branch + PR ──────────────────────────────────── + $timestamp = gmdate( 'Ymd-His' ); + $new_branch = 'oribi-sync/' . $slug . '-' . $timestamp; + + // Create branch + $branch_result = oribi_sync_gitea_create_branch( $api_base, $new_branch, $branch, $pat ); + if ( is_wp_error( $branch_result ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Branch creation failed: ' . $branch_result->get_error_message() ]; + } + if ( $branch_result['code'] < 200 || $branch_result['code'] >= 300 ) { + $err = is_array( $branch_result['body'] ) ? ( $branch_result['body']['message'] ?? '' ) : $branch_result['body']; + return [ 'ok' => false, 'action' => 'error', 'message' => "Branch creation failed (HTTP {$branch_result['code']}): {$err}" ]; + } + + // Fetch file meta on the new branch (same SHA as base branch initially) + $branch_remote = oribi_sync_gitea_get_file_meta( $api_base, $new_branch, $repo_path, $pat ); + $branch_sha = null; + if ( ! is_wp_error( $branch_remote ) && $branch_remote !== null ) { + $branch_sha = $branch_remote['sha']; + } + + // Commit to the new branch + $put_result = oribi_sync_gitea_put_file( $api_base, $new_branch, $repo_path, $php_source, $pat, $branch_sha, $commit_msg ); + if ( is_wp_error( $put_result ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Commit to branch failed: ' . $put_result->get_error_message() ]; + } + if ( $put_result['code'] < 200 || $put_result['code'] >= 300 ) { + $err = is_array( $put_result['body'] ) ? ( $put_result['body']['message'] ?? '' ) : $put_result['body']; + return [ 'ok' => false, 'action' => 'error', 'message' => "Commit to branch failed (HTTP {$put_result['code']}): {$err}" ]; + } + + // Open PR + $pr_title = "Sync: {$slug}"; + $pr_body = "Automatic push from WordPress (Oribi Tech Sync).\n\n"; + $pr_body .= "**Page:** {$title} (`{$slug}`)\n"; + $pr_body .= "**Commit:** {$commit_msg}\n\n"; + $pr_body .= "The target branch `{$branch}` has been modified since the last sync, "; + $pr_body .= "so this change was pushed to `{$new_branch}` for review.\n\n"; + $pr_body .= "Stored SHA: `{$stored_sha}`\n"; + $pr_body .= "Remote SHA: `{$remote_sha}`\n"; + + $pr_result = oribi_sync_gitea_create_pr( $api_base, $new_branch, $branch, $pr_title, $pr_body, $pat ); + if ( is_wp_error( $pr_result ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'PR creation failed: ' . $pr_result->get_error_message() ]; + } + + $pr_url = ''; + if ( $pr_result['code'] >= 200 && $pr_result['code'] < 300 && is_array( $pr_result['body'] ) ) { + $pr_url = $pr_result['body']['html_url'] ?? ''; + } + + // Save PR URL on the post + if ( ! empty( $pr_url ) ) { + update_post_meta( $post_id, '_oribi_sync_pr_url', $pr_url ); + } + + oribi_sync_log_push( $slug, 'pr_created', $new_branch, $pr_url ); + + return [ + 'ok' => true, + 'action' => 'pr_created', + 'message' => "Conflict detected — created PR on branch {$new_branch}.", + 'pr_url' => $pr_url, + ]; + } + + // ── No conflict: direct update ──────────────────────────────────────── + $result = oribi_sync_gitea_put_file( $api_base, $branch, $repo_path, $php_source, $pat, $remote_sha, $commit_msg ); + if ( is_wp_error( $result ) ) { + return [ 'ok' => false, 'action' => 'error', 'message' => 'Update failed: ' . $result->get_error_message() ]; + } + if ( $result['code'] < 200 || $result['code'] >= 300 ) { + $err = is_array( $result['body'] ) ? ( $result['body']['message'] ?? wp_json_encode( $result['body'] ) ) : $result['body']; + return [ 'ok' => false, 'action' => 'error', 'message' => "Update failed (HTTP {$result['code']}): {$err}" ]; + } + + // Update post meta + $new_sha = $result['body']['content']['sha'] ?? ''; + oribi_sync_update_push_meta( $post_id, $new_sha, $new_checksum, $repo_url, $branch, $repo_path ); + oribi_sync_log_push( $slug, 'updated', $branch ); + + return [ 'ok' => true, 'action' => 'updated', 'message' => "Updated {$repo_path} on branch {$branch}." ]; +} + +/** + * Bulk-push all synced pages. + * + * @return array{ok: bool, results: array} + */ +function oribi_sync_push_all(): array { + $query = new WP_Query( [ + 'post_type' => 'page', + 'post_status' => 'publish', + 'meta_key' => '_oribi_sync_checksum', + 'posts_per_page' => -1, + 'fields' => 'ids', + ] ); + + $results = []; + + foreach ( $query->posts as $post_id ) { + $page = get_post( $post_id ); + $slug = $page ? $page->post_name : "#{$post_id}"; + $result = oribi_sync_push_page( (int) $post_id ); + $results[] = array_merge( $result, [ 'slug' => $slug, 'post_id' => $post_id ] ); + } + + $all_ok = ! empty( $results ) && count( array_filter( $results, fn( $r ) => ! $r['ok'] ) ) === 0; + + return [ 'ok' => $all_ok, 'results' => $results ]; +} + +// ─── Post meta helpers ──────────────────────────────────────────────────────── + +/** + * Update post meta after a successful push. + */ +function oribi_sync_update_push_meta( int $post_id, string $sha, string $checksum, string $repo_url, string $branch, string $path ): void { + if ( ! empty( $sha ) ) { + update_post_meta( $post_id, '_oribi_sync_git_sha', $sha ); + } + update_post_meta( $post_id, '_oribi_sync_checksum', $checksum ); + update_post_meta( $post_id, '_oribi_sync_source', $repo_url . '@' . $branch . ':' . $path ); + update_post_meta( $post_id, '_oribi_sync_last_push', current_time( 'mysql' ) ); + + // Clear any stale PR URL on successful direct push + delete_post_meta( $post_id, '_oribi_sync_pr_url' ); +} + +// ─── Push log ───────────────────────────────────────────────────────────────── + +/** + * Append an entry to the push log. + */ +function oribi_sync_log_push( string $slug, string $action, string $branch, string $pr_url = '' ): void { + $log = get_option( 'oribi_sync_push_log', [] ); + if ( ! is_array( $log ) ) $log = []; + + array_unshift( $log, [ + 'time' => current_time( 'mysql' ), + 'slug' => $slug, + 'action' => $action, + 'branch' => $branch, + 'pr_url' => $pr_url, + ] ); + + // Keep last 50 entries + $log = array_slice( $log, 0, 50 ); + update_option( 'oribi_sync_push_log', $log, 'no' ); +} diff --git a/includes/rest.php b/includes/rest.php index 3f249ac..9103d79 100644 --- a/includes/rest.php +++ b/includes/rest.php @@ -30,6 +30,33 @@ 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', + 'callback' => 'oribi_sync_rest_push', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] ); + + // ── Push all synced pages to repo ────────────────────────────────────── + register_rest_route( 'oribi-sync/v1', '/push-all', [ + 'methods' => 'POST', + 'callback' => 'oribi_sync_rest_push_all', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ] ); + // ── Webhook (secret-based auth, no WP login required) ───────────────── register_rest_route( 'oribi-sync/v1', '/webhook', [ 'methods' => 'POST', @@ -45,6 +72,12 @@ function oribi_sync_rest_sync( WP_REST_Request $request ): WP_REST_Response { $dry_run = (bool) $request->get_param( 'dry_run' ); $result = oribi_sync_run( $dry_run ); + // After pulling, push local changes back (skip during dry-run) + if ( ! $dry_run ) { + $push = oribi_sync_push_all(); + $result['push'] = $push['results']; + } + return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 ); } @@ -103,3 +136,77 @@ function oribi_sync_rest_webhook( WP_REST_Request $request ): WP_REST_Response { return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 ); } + +/** + * REST: Push a single page to the repo. + */ +function oribi_sync_rest_push( WP_REST_Request $request ): WP_REST_Response { + $post_id = (int) $request->get_param( 'post_id' ); + if ( $post_id < 1 ) { + return new WP_REST_Response( [ 'ok' => false, 'message' => 'Missing or invalid post_id.' ], 400 ); + } + + $opts = []; + $message = $request->get_param( 'message' ); + if ( ! empty( $message ) ) { + $opts['message'] = sanitize_text_field( $message ); + } + + $result = oribi_sync_push_page( $post_id, $opts ); + + return new WP_REST_Response( $result, $result['ok'] ? 200 : 500 ); +} + +/** + * 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 ); +} diff --git a/includes/sync-engine.php b/includes/sync-engine.php index d0f33cd..2859093 100644 --- a/includes/sync-engine.php +++ b/includes/sync-engine.php @@ -90,12 +90,13 @@ function oribi_sync_execute_php( string $php_source, string $slug ) { */ function oribi_sync_run( bool $dry_run = false ): array { $result = [ - 'ok' => true, - 'created' => [], - 'updated' => [], - 'trashed' => [], - 'skipped' => [], - 'errors' => [], + 'ok' => true, + 'created' => [], + 'updated' => [], + 'trashed' => [], + 'skipped' => [], + 'errors' => [], + 'theme_updated' => [], ]; // ── Gather settings ──────────────────────────────────────────────────── @@ -128,18 +129,21 @@ function oribi_sync_run( bool $dry_run = false ): array { return $result; } - // ── Filter to pages/ directory ───────────────────────────────────────── - $page_files = oribi_sync_filter_tree( $tree, 'pages' ); + // ── Filter to selected Pages sub-folder ──────────────────────────────── + $pages_folder = get_option( 'oribi_sync_pages_folder', '' ); + $synced_slugs = []; - if ( empty( $page_files ) ) { - $result['errors'][] = 'No files found under pages/ in the repository.'; - $result['ok'] = false; - return $result; + 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.'; + } } // ── Process each page file ───────────────────────────────────────────── - $synced_slugs = []; - foreach ( $page_files as $entry ) { $filename = basename( $entry['path'] ); $slug = oribi_sync_filename_to_slug( $filename ); @@ -239,7 +243,8 @@ function oribi_sync_run( bool $dry_run = false ): array { update_post_meta( $existing->ID, '_oribi_sync_last_run', current_time( 'mysql' ) ); update_post_meta( $existing->ID, '_wp_page_template', 'default' ); - $result['updated'][] = $slug; + $content_size = strlen( $content ); + $result['updated'][] = $slug . ' (' . $content_size . ' bytes)'; } else { // Create new page $title = oribi_sync_slug_to_title( $slug ); @@ -263,16 +268,25 @@ function oribi_sync_run( bool $dry_run = false ): array { update_post_meta( $post_id, '_oribi_sync_last_run', current_time( 'mysql' ) ); update_post_meta( $post_id, '_wp_page_template', 'default' ); - $result['created'][] = $slug; + $content_size = strlen( $content ); + $result['created'][] = $slug . ' (' . $content_size . ' bytes)'; } } // ── Trash pages removed from repo ────────────────────────────────────── - if ( ! $dry_run ) { + if ( ! $dry_run && ! empty( $pages_folder ) ) { $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; + } + // ── Record run ───────────────────────────────────────────────────────── if ( ! $dry_run ) { oribi_sync_record_run( $result ); @@ -357,12 +371,13 @@ function oribi_sync_record_run( array $result ): void { 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'], + 'time' => current_time( 'mysql' ), + 'created' => $result['created'], + 'updated' => $result['updated'], + 'trashed' => $result['trashed'], + 'skipped' => $result['skipped'], + 'errors' => $result['errors'], + 'theme_updated' => $result['theme_updated'] ?? [], ] ); // Keep last 20 entries @@ -370,6 +385,125 @@ function oribi_sync_record_run( array $result ): void { 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 = substr( $entry['path'], strlen( '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). * @@ -414,9 +548,10 @@ function oribi_sync_fetch_theme_files(): array { continue; } - // Check if a matching file exists in the active theme - $theme_file = get_template_directory() . '/' . $relative; - $local_exists = file_exists( $theme_file ); + // 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'][] = [ diff --git a/oribi-tech-sync.php b/oribi-tech-sync.php index 2962bb6..40db151 100644 --- a/oribi-tech-sync.php +++ b/oribi-tech-sync.php @@ -19,6 +19,7 @@ define( 'ORIBI_SYNC_BASENAME', plugin_basename( __FILE__ ) ); require_once ORIBI_SYNC_DIR . 'includes/crypto.php'; require_once ORIBI_SYNC_DIR . 'includes/api-client.php'; require_once ORIBI_SYNC_DIR . 'includes/sync-engine.php'; +require_once ORIBI_SYNC_DIR . 'includes/push-client.php'; require_once ORIBI_SYNC_DIR . 'includes/admin.php'; require_once ORIBI_SYNC_DIR . 'includes/rest.php'; require_once ORIBI_SYNC_DIR . 'includes/theme-preview.php'; diff --git a/uninstall.php b/uninstall.php index b7c7cef..f2fa7dd 100644 --- a/uninstall.php +++ b/uninstall.php @@ -16,6 +16,7 @@ delete_option( 'oribi_sync_last_run' ); delete_option( 'oribi_sync_log' ); delete_option( 'oribi_sync_webhook_secret' ); delete_option( 'oribi_sync_theme_applied' ); +delete_option( 'oribi_sync_push_log' ); // Remove sync metadata from posts $posts = get_posts( [ @@ -28,8 +29,11 @@ $posts = get_posts( [ foreach ( $posts as $post_id ) { delete_post_meta( $post_id, '_oribi_sync_checksum' ); + delete_post_meta( $post_id, '_oribi_sync_git_sha' ); delete_post_meta( $post_id, '_oribi_sync_source' ); delete_post_meta( $post_id, '_oribi_sync_last_run' ); + delete_post_meta( $post_id, '_oribi_sync_last_push' ); + delete_post_meta( $post_id, '_oribi_sync_pr_url' ); } // Clear any scheduled cron