From d2228ed0fb78562a95afcaed6259f9b764271590 Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Fri, 20 Feb 2026 21:03:48 -0500 Subject: [PATCH] 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. --- README.md | 27 +- assets/admin.css | 52 ++++ dist/oribi-tech-sync.zip | Bin 21650 -> 31092 bytes includes/admin.php | 351 +++++++++++++++++++------- includes/push-client.php | 530 +++++++++++++++++++++++++++++++++++++++ includes/rest.php | 107 ++++++++ includes/sync-engine.php | 187 ++++++++++++-- oribi-tech-sync.php | 1 + uninstall.php | 4 + 9 files changed, 1144 insertions(+), 115 deletions(-) create mode 100644 includes/push-client.php 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 54d6f4485048106e1ee1bfe20b45a39a5e360e19..4818d87ec2256d0167969558de3072b4b9faf783 100644 GIT binary patch delta 22547 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 S5C{MQB%%QTpIv@`*#7|#+22k8 delta 13040 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%0A^<6v=Jzo?Q35&n_BeQ<{I zrRuPB4-gBF-(TTP^nIXjRtY?a8N=v$qpicJ&FLsx&LD-lCFo2!d%Ir_9F>Ix<5tei zoSourVwx??`7rj}4s8N2THisGsJul!w6pl<8~9Iwyg@WAe@0Wqra#6se56AN2S(I_ zNo+~pRcHWhLcoFW>)|XnJZ6T?6;1%Q%Wug{$gy4GTEoxa#eO0XAIxm?lcOP1U$v8qDX^P}SP&f$` z+BNl@3lFTh$)+km(wa>01}`(kT@F`)D<;F$OCUug%!_ZukMM@l!Nbim`J`XL@a0&wQ%!%u3`_<_xo$5Kx8x zg8u60c-+Cms_WN}vjMy<*zF}i0!bG!6byKG$xJ_SxRnq6*-i5LNy5u;xwWrfd~a8} zfz#UdAv<}N)UXo)nT$ZyUPWkudK0m^)=(Hw`&@ zua@H2LQ^3)t{ov5plY4JG=-SNUUrDPOlja_d5vE<-N~ z7pw%?jS77Koadn5HLYId52(&K_8mu<0tI>67%H_yrA3gML{X2kEJY`^sy+!(&l_qX zw|o6tFDzt5}0^j0N@VD#~Ay zFz_o8>NZweGIss|rZk3;@Tc!Mi;#P}QGK#uue?x+gk~2;B-`xKCYjXXin@rCH8+T- zXj{m(23?^7>vvpI3$!`B@xBb?m`Hj{rA=PpL}NVSw;DNrGsGZ3ziVtoezZsZ8nj{e z#Jvn1ml-j>4!a$Jn%bEp?rJO49vMaSWZfx7r~Tc2I{X?END@tYNIiPqb@@x=$0tK^ zj|gK2D?`^63+3W@aF>zmjen$DRVIgoQ+s3x%V4(ihWGDsU&6Hft&Cs5KPb~6V^)sB z-=6#GZ-SOaMOkkNn^49>2VXe9vXJEMF}r^8Z0_PKzi3tU@@PzdS{v5MIT(PB;GDq{ z_myrgFoN}906MI*!ev(c{&;IanW~AJl{oNEX~UtTE7>>C8UK-?!w!xRLf-k6h5m=0 ztmo9;*ITtttC}PU?u`-!jqe-y0f;+CyOqIF{TYrS{)gx>oh4o&N%&YSVFL^@L69f+S!NTHhbP*&xRZ$g;!;2-<8G>$kq8}@c^mXXr{80V(LK$q2XW0T3y3dgY~{W?TV2Q3j+j7`pg5x>VDqVYJ~b9D^?n; z2Kq0~mZ$9hB*@NFJY9*qFd~|~Ws(Ud?~0RPybl566?#3W|Hfn97R1!m(uriZsxXJt zu*gLOvq^bNDS3YBDV!FbMK?mW$1T`-CD+wzj-p;mHol3QIa1SB^9cch6jl5e9Y8~L z7#aB=v_d1=qx~EX0N}y_(J4{>PcTJ|_NoKH7i|Blx^@Z(JXH%&1C2AF?=djBB@PFT zoX9s2Y-Q+@Tt~C1YJO2cH;6dcI|Bvx+4<+R5&O|n|4>l~E-Yz~Ig_W5XBCW_MnzOn zlKc^8H`5%gPg#|n@>jF+$?Qz}^1K*e7UE+)xy4)}Ej#0QSaHSpjj!$r6kO%L_;T{J zMqFJ9cjeNeJx?^TU;zi+DKzJI+R}jmuyXC-h=}`_HYGPEDU0Loif?R5i?Phobw>6e zXcoDfx^Q#`F_uDk4fb!)npw@@5Y;^2hVz?CLO|^m58?~-Sl-A<+L5!mVDt>Y4s{o) z?IhZX_a!-Z>NB@#@^hR)d}F;tu{Erj1s552aJ7+*-Yy^y`#>5frB=%)3vi zIg>3vZ{EM8W=)x6u^OG|P+{9XC)Qe3gbuyvOHcw=)ri=%=-q4Q0*ix&NFzqj(a%Z8 z_=jtjvo<#Aa2X|Md!weynZs+B*`(?QnI`e1dYxcZUm76IW2poey=%4Xtktv>#*=Wm zyLhA%ifF$kN8PdM02{vh%#d;=qMPH1F>7Q>00t?fOGiQB9~2))G? zSX+IscTK%CdhdPemz9$k9H+s9j>*CzEVVyFfBxvp%Q2UhI9X%5_^38SYF8GuSQHt}E@}MxWpubN7*O%}ke}j2-*>9d$5!zZ-Q?L9KH= z6|yyYkYa+jg}+m?iF>h zdgP13F~PwABz5H%WOPGXaJtagX`2_;z9esGJRUHC2qjbwZYFl+)_W$CsAa zDqgu00Eu8(naFlZFRV|nw-$$iBY8N=9R^op-Gd(F!3=JnVTJDsC*5gI9C9|1|Mq>} z%kAOzULm-dG>2IE1fuyWwU7tBmdB-$clemN@urj#dU~WXkMjg+$2a+mIt13!jWJfX z1Hp+O*ibl*ZelkHZ%@Mxb1N}a2!!m38{of%skP&Ul!JsSDIm^0v7r{RnxJlgX(u6r_Vq$RV=mX6*A8sKLQSd#!iHz ziQP}o3xk|ozor+)mQEkYX~Bsu!Z%t!m}srW5qqBKb09RQicwF8)RrP%EX>CJSt?N@ zt+|D=tZECUMQo0lwPL;Zx(YSts5_e@eDEO%eY&qq)JX78JSV{K=0- z2kgB4uAQ>6>Gu_Z+K^;wzmZ{O_Yzkojrh(?*_kI%6L_5bRH!B}$j{LIh>OU;z13es z;%V=$A4Q&fmnyFaW%E<6F-JCY1mvX48sa|>TkEsqzlw zfQNOn=0)uzlw3w)2;DQ8xdTmE9M5KEZM}Asxy8-S2&GY{?ouS2|7?pcKO31TVUu*z7#YtDnl|xh1r2KI~V~=C{yf@u4M&X*ARK z_Pj|N@I3}|_0YO6{};-JMUH8yYBDmfcN*c2nS7*``EVufcN*XD&k!rQ@=2tUE*B!d z@5VrwRJL+KU$<+^aj$srU^h*kVPMn~lj^E1FpKr9;&*PgE??F1Ad-KE*>a}4(2k4K zW$YlY3b#dkM-mAI;cz@)eP6F(M~7Ozyhn_rz*_& zhLH%020D!aiTKzXF3An*UW&iZZ_jvlCVPYK%lyHC7-8)7NnfimCj=3VCSd6rO&+VYgM%pZY5&5I zG_Ef~Y%X~eOE3VAJlSZXolCr}$Gf?q!u4zydOmWB$t@(_&5~B;)$t3?p2_FpXlq*z z*2Z13li3`JEPPHNx-ME1~0oNjF-*14n=Z=biUpvbQD9 zAT;@M^Qf%y+H}OA&M^?oEs&#T6Crvh9VK8NzG;ahm$!P8kArBULz~{fnMF{ZtzSdd z_3oQ}>*kB;C(>MFYJ?EUVVMAbgOZB6F^*!}L9qrmNdLxFef`mr2G}ZN{^nrRH>25; z+c3A~qgQOy!_%!&LbRM!l&G%`Rf(~TYzsY-b~bS-v49=4tp82Tps%QenO#}~75KsJ zu5`u<*)J|v=%QEGW3lq&IzpczC^fraIv7NcsNqK;R*rjVd)C*k-T*y^WgE zF3?yQ3~Y>mIa^#ZgLSH$6|F|opTB4UBN8y?zLwDx#GdJz~b2$C})M8q-XP$r8J%Kj@mWas7PQ^O8B*9c#A;cJCO2V;^>T4UoJSw)5L%`|dC@QoF`vb-J*)J*#6bxwqx zxP0N;7$t-q!yq!Hc`ok!h%j%|1>t$@jVyb)?D)lKJd~uKB-NF+r&4N(7NyYkmu~8? zv`!d@&~+9f!Xp!ou1pymm6l|&BwxeFc4f>t)9>B%$XYfJS%RD;7zhU9F%bU8S@=udN6z(9 z(+`s!2cZAmc5ZM`fdK>X)b9UD-;YaDP#j=I4SV7nlG&7MSAYOdspbVkP)s!6FsB?b zTgAuh=q`JG{H2R=|+iT~$-KW?zN=Dblk8=i7*C&dxu0rjT6i z?NN?OWOKy;b4K)L_*H`dX|B%~{04XqSHeL0i=8KJY-CPbD6(GYWDTT!S1*t!*>9jr-MB+gB^9%^^T!ZgcT1J++Rex!Uo z(-GSsOr})`bzqOnW~v_;(S8EiPZ?104PF7AD>QMg#fBsp~za;-7K=dDyR@49xnJB^EfDzKa zS4sqe_auhkVgIL?9;D$({;v-Hw^s@P{MV}gJyA(90Ek(M5bD1NB>iWq|80@}#@&hj zW55tO(4!~i|L?DY_`Hb0GZjEOUP7e*d8+>!wEqx2RRFbm(Lnq?=O9IITx28w5`Y>C L0MJzWXA}NEy}@67 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