Changeset 3494675
- Timestamp:
- 03/30/2026 02:15:02 PM (3 days ago)
- Location:
- praison-file-content-git
- Files:
-
- 12 added
- 18 edited
- 1 copied
-
tags/1.8.1 (copied) (copied from praison-file-content-git/trunk)
-
tags/1.8.1/AGENTS.md (modified) (3 diffs)
-
tags/1.8.1/docs/features/bidirectional-sync.md (added)
-
tags/1.8.1/docs/features/deletion-handling.md (added)
-
tags/1.8.1/docs/features/incremental-indexing.md (added)
-
tags/1.8.1/docs/features/virtual-post-meta.md (added)
-
tags/1.8.1/docs/index.md (modified) (4 diffs)
-
tags/1.8.1/mkdocs.yml (modified) (1 diff)
-
tags/1.8.1/praisonpressgit.php (modified) (2 diffs)
-
tags/1.8.1/readme.txt (modified) (3 diffs)
-
tags/1.8.1/src/Core/Bootstrap.php (modified) (5 diffs)
-
tags/1.8.1/src/Export/AutoExporter.php (modified) (6 diffs)
-
tags/1.8.1/src/GitHub/SyncManager.php (modified) (1 diff)
-
tags/1.8.1/src/Index (added)
-
tags/1.8.1/src/Index/IndexManager.php (added)
-
tags/1.8.1/src/Loaders/PostLoader.php (modified) (4 diffs)
-
trunk/AGENTS.md (modified) (3 diffs)
-
trunk/docs/features/bidirectional-sync.md (added)
-
trunk/docs/features/deletion-handling.md (added)
-
trunk/docs/features/incremental-indexing.md (added)
-
trunk/docs/features/virtual-post-meta.md (added)
-
trunk/docs/index.md (modified) (4 diffs)
-
trunk/mkdocs.yml (modified) (1 diff)
-
trunk/praisonpressgit.php (modified) (2 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/src/Core/Bootstrap.php (modified) (5 diffs)
-
trunk/src/Export/AutoExporter.php (modified) (6 diffs)
-
trunk/src/GitHub/SyncManager.php (modified) (1 diff)
-
trunk/src/Index (added)
-
trunk/src/Index/IndexManager.php (added)
-
trunk/src/Loaders/PostLoader.php (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
praison-file-content-git/tags/1.8.1/AGENTS.md
r3488026 r3494675 16 16 │ ├── Core/ # Bootstrap, Router 17 17 │ ├── Loaders/ # PostLoader (file → WP_Post) 18 │ ├── Index/ # IndexManager (incremental _index.json ops) 19 │ ├── Export/ # AutoExporter (dashboard → .md → Git) 20 │ ├── GitHub/ # SyncManager (Git pull/push/diff) 18 21 │ ├── Cache/ # CacheManager, SmartCacheInvalidator 19 22 │ ├── Parsers/ # FrontMatterParser, MarkdownParser … … 33 36 34 37 ```php 35 Version: 1. 0.938 Version: 1.8.0 36 39 ``` 37 40 … … 44 47 | Class | Purpose | 45 48 |-------|---------| 46 | `Bootstrap` | Plugin initialization, `posts_pre_query` injection | 47 | `PostLoader` | Load/cache/filter file-based posts | 49 | `Bootstrap` | Plugin initialization, `posts_pre_query` injection, virtual meta filter | 50 | `PostLoader` | Load/cache/filter file-based posts, register virtual meta | 51 | `IndexManager` | Incremental `_index.json` updates (add, update, remove) with flock() | 52 | `AutoExporter` | Dashboard → `.md` export, Git commit/push, deletion hooks | 53 | `SyncManager` | Git clone/pull/push, post-pull diff detection (A/M/D/R) | 48 54 | `CacheManager` | Transient caching (O(1) dir-mtime key) | 49 55 | `SmartCacheInvalidator` | Cache clear on PR merge | -
praison-file-content-git/tags/1.8.1/docs/index.md
r3488026 r3494675 1 1 # WP Git Posts 2 2 3 Load WordPress content from files without database writes. 3 Load WordPress content from files without database writes. Bidirectional Git sync keeps your content repository and WordPress site perfectly in sync. 4 4 5 5 ```mermaid 6 6 graph LR 7 A[📁 Git Repo] --> B[📄 Files] 8 B --> C[🔌 PraisonPressGit] 9 C --> D[🌐 WordPress] 7 A[📁 Git Repo] -->|push| B[🔌 Plugin] 8 B -->|pull| A 9 B --> C[🌐 WordPress] 10 C -->|auto-export| B 10 11 11 12 style A fill:#14B8A6,stroke:#7C90A0,color:#fff 12 style B fill:#F59E0B,stroke:#7C90A0,color:#fff 13 style C fill:#6366F1,stroke:#7C90A0,color:#fff 14 style D fill:#10B981,stroke:#7C90A0,color:#fff 13 style B fill:#6366F1,stroke:#7C90A0,color:#fff 14 style C fill:#10B981,stroke:#7C90A0,color:#fff 15 15 ``` 16 16 … … 18 18 19 19 1. **Install** → Upload plugin to WordPress 20 2. **Create** → Add Markdown /JSON/YAML files21 3. **Sync** → Git push to deploy content20 2. **Create** → Add Markdown files to `content/` directory 21 3. **Sync** → Changes flow both ways automatically 22 22 23 23 No database writes! 🎉 24 25 ## Supported Formats26 27 | Format | Use Case |28 |--------|----------|29 | 📝 Markdown | Blog posts, pages |30 | 📋 JSON | Structured data |31 | ⚙️ YAML | Configuration |32 24 33 25 ## Key Features … … 35 27 | Feature | Description | 36 28 |---------|-------------| 37 | 🔄 Git Sync | Version control for content | 38 | 👥 Collaborative | Multiple editors | 39 | ☁️ Cloud Native | Deploy anywhere | 40 | ⚡ No DB Writes | Fast and portable | 29 | 🔄 Bidirectional Sync | Dashboard ↔ Git automatic synchronization | 30 | ⚡ Incremental Indexing | O(1) updates (~10ms per post change) | 31 | 📋 Virtual Post Meta | `get_post_meta()` works for file-based posts | 32 | 🗑️ Deletion Handling | Trash/delete auto-manages `.md` files and index | 33 | 👥 Collaborative Editing | Submit edits via pull requests | 34 | ☁️ Cloud Native | Docker, Kubernetes, multi-pod ready | 35 | 🔒 Concurrency Safe | `flock()` locking for parallel operations | 36 | ⚡ No DB Writes | Fast, portable, version-controlled | 37 38 ## v1.8.0 Highlights 39 40 - **Bidirectional sync**: Edit in WordPress → auto-push to Git. Push to Git → auto-import to WordPress 41 - **Incremental indexing**: No more full rebuilds. Each post change updates `_index.json` in ~10ms 42 - **Virtual post meta**: `get_post_meta()`, `get_field()` work seamlessly with file-based posts 43 - **Deletion handling**: Trash/delete/restore automatically managed across Git and WordPress 41 44 42 45 ## Next Steps … … 44 47 - [Installation](getting-started/installation.md) 45 48 - [Configuration](getting-started/configuration.md) 46 - [File Content Guide](features/file-content.md) 49 - [Bidirectional Git Sync](features/bidirectional-sync.md) 50 - [Incremental Indexing](features/incremental-indexing.md) 51 - [Virtual Post Meta](features/virtual-post-meta.md) 52 - [Deletion Handling](features/deletion-handling.md) 53 -
praison-file-content-git/tags/1.8.1/mkdocs.yml
r3488026 r3494675 56 56 - File-Based Content: features/file-based-content.md 57 57 - Export to Markdown: features/export.md 58 - Bidirectional Git Sync: features/bidirectional-sync.md 59 - Incremental Indexing: features/incremental-indexing.md 60 - Virtual Post Meta: features/virtual-post-meta.md 61 - Deletion Handling: features/deletion-handling.md 58 62 - Collaborative Editing: features/collaborative-editing.md 59 63 - Performance & Caching: features/performance.md -
praison-file-content-git/tags/1.8.1/praisonpressgit.php
r3491385 r3494675 3 3 * Plugin Name: PraisonAI Git Posts 4 4 * Description: Load WordPress content from files (Markdown, JSON, YAML) without database writes, with Git-based version control 5 * Version: 1. 7.05 * Version: 1.8.1 6 6 * Author: MervinPraison 7 7 * Author URI: https://mer.vin … … 13 13 14 14 // Define constants 15 define('PRAISON_VERSION', '1. 7.0');15 define('PRAISON_VERSION', '1.8.1'); 16 16 define('PRAISON_PLUGIN_DIR', __DIR__); 17 17 define('PRAISON_PLUGIN_URL', trailingslashit(plugins_url('', __FILE__))); -
praison-file-content-git/tags/1.8.1/readme.txt
r3491385 r3494675 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1. 7.07 Stable tag: 1.8.1 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 228 228 * HOTFIX: Cache keys now include category_name and tag query vars to prevent taxonomy archive cache collisions 229 229 230 = 1.0.7 = 230 = 1.8.1 = 231 * CRITICAL FIX: loadSinglePost() — direct .md file lookup first, eliminates 49.5 MB peak memory spike per cache-miss request 232 * CRITICAL FIX: registerPostsMeta() — reads meta from post object properties instead of re-loading full _index.json (eliminates double-registration, saves 34 MB) 233 * FIX: Date-prefixed filename support in direct lookup (e.g. 2024-01-15-my-song.md via glob fallback) 234 * FIX: PRAISON_VERSION constant now matches plugin header version (was stuck at 1.7.0) 235 * PERF: Single-page cache-miss peak memory reduced from 187.5 MB to ~138 MB 236 * PERF: Archive page requests no longer load _index.json twice 237 238 = 1.8.0 = 239 * NEW: Bidirectional Git sync — dashboard edits auto-export to Git, Git pushes auto-import to WordPress 240 * NEW: Incremental index updates (O(1) per post, ~10ms) — no more full-rescan rebuilds 241 * NEW: IndexManager class with atomic file operations and flock() concurrency safety 242 * NEW: Deletion handling — trashing/deleting posts auto-removes .md files and updates _index.json 243 * NEW: Virtual post meta via get_post_meta() — headless posts serve custom fields from frontmatter 244 * NEW: registerPostsMeta() reads _index.json by slug for cache-safe meta registration 245 * FIX: WordPress absint() compatibility — negative virtual IDs stored under both negative and positive keys 246 * FIX: CacheManager serialization — meta registration survives Redis cache round-trips 247 * FIX: AutoExporter uses git add -A to stage deletions, not just additions 248 * FIX: SyncManager detects Added/Modified/Deleted/Renamed files via git diff --name-status after pull 249 250 = 1.0.9 = 231 251 * HOTFIX: CacheManager::getContentKey() - Replaced O(n) glob()+filemtime() with O(1) filemtime($dir) — critical for 100k+ file deployments 232 252 * HOTFIX: PostLoader - Added _index.json fast path for single-slug queries (O(1) lookup vs full directory scan) … … 286 306 == Upgrade Notice == 287 307 308 = 1.8.0 = 309 Major release: Bidirectional Git sync, incremental indexing, deletion handling, and get_post_meta() support for headless posts. Recommended update for all users. 310 288 311 = 1.0.9 = 289 312 Hotfix: Fixes invalid HTML from the Markdown fallback parser, YAML inline array and boolean parsing, permalink format, and SmartCacheInvalidator transient pattern. -
praison-file-content-git/tags/1.8.1/src/Core/Bootstrap.php
r3491360 r3494675 22 22 23 23 /** 24 * Virtual post meta registry — populated by PostLoader, consumed by get_post_metadata filter. 25 * Structure: [ post_id => [ 'key' => value, ... ] ] 26 */ 27 private static $virtualMeta = []; 28 29 /** 24 30 * Initialize the plugin 25 31 */ … … 78 84 add_filter('posts_pre_query', [$this, 'injectFilePosts'], 10, 2); 79 85 86 // Intercept get_post_meta() for virtual (negative-ID) headless posts 87 add_filter('get_post_metadata', [$this, 'interceptVirtualMeta'], 10, 4); 88 80 89 // Register Settings page in admin 81 90 if (is_admin()) { … … 151 160 $autoExporter->register(); 152 161 } 162 } 163 164 /** 165 * Register virtual meta for a headless post (called by PostLoader). 166 * 167 * WordPress's get_metadata_raw() calls absint($object_id), converting 168 * negative IDs to positive before both filter and cache lookups. 169 * We store under both negative (for direct property access) and 170 * positive/absint'd (for WordPress metadata system compatibility). 171 * 172 * @param int $post_id Negative virtual post ID. 173 * @param array $meta Associative array of meta key => value. 174 */ 175 public static function registerVirtualMeta( int $post_id, array $meta ): void { 176 // Store under original negative ID (for internal lookups). 177 self::$virtualMeta[ $post_id ] = $meta; 178 179 // Also store under absint'd ID (WordPress converts negative → positive). 180 $abs_id = abs( $post_id ); 181 self::$virtualMeta[ $abs_id ] = $meta; 182 183 // Pre-populate WordPress metadata cache under the absint'd ID. 184 $cache_data = []; 185 foreach ( $meta as $key => $value ) { 186 $cache_data[ $key ] = [ maybe_serialize( $value ) ]; 187 } 188 wp_cache_set( $abs_id, $cache_data, 'post_meta' ); 189 } 190 191 /** 192 * Intercept get_post_meta() for virtual headless posts. 193 * 194 * NOTE: WordPress calls absint() on the post_id before passing it to 195 * this filter, so we receive the POSITIVE version of the ID. 196 * 197 * @param mixed $value Existing value (null = not filtered yet). 198 * @param int $post_id Post ID (already absint'd by WordPress). 199 * @param string $meta_key Meta key being requested. 200 * @param bool $single Whether to return a single value. 201 * @return mixed 202 */ 203 public function interceptVirtualMeta( $value, $post_id, $meta_key, $single ) { 204 if ( ! isset( self::$virtualMeta[ $post_id ] ) ) { 205 return $value; 206 } 207 208 $meta = self::$virtualMeta[ $post_id ]; 209 210 // Return all meta if no specific key requested. 211 if ( empty( $meta_key ) ) { 212 $result = []; 213 foreach ( $meta as $k => $v ) { 214 $result[ $k ] = [ $v ]; 215 } 216 return $result; 217 } 218 219 // Return specific key if it exists. 220 if ( isset( $meta[ $meta_key ] ) ) { 221 return [ $meta[ $meta_key ] ]; 222 } 223 224 // Key not in our registry — fall through to DB. 225 return $value; 153 226 } 154 227 … … 421 494 )); 422 495 } 496 $this->registerPostsMeta($file_posts); 423 497 return $file_posts; 424 498 } … … 451 525 } 452 526 527 $this->registerPostsMeta($merged); 453 528 return $merged; 529 } 530 531 /** 532 * Register virtual meta for an array of file-based posts. 533 * 534 * Reads custom field data from _index.json by slug, since cached 535 * WP_Post objects lose extra properties during serialization. 536 * 537 * @param array $posts Array of WP_Post objects. 538 */ 539 private function registerPostsMeta( array $posts ): void { 540 // WP_Post standard properties — everything else is a custom field. 541 static $wpProps = null; 542 if ( $wpProps === null ) { 543 $wpProps = [ 544 'ID', 'post_author', 'post_date', 'post_date_gmt', 'post_content', 545 'post_title', 'post_excerpt', 'post_status', 'comment_status', 546 'ping_status', 'post_password', 'post_name', 'to_ping', 'pinged', 547 'post_modified', 'post_modified_gmt', 'post_content_filtered', 548 'post_parent', 'guid', 'menu_order', 'post_type', 'post_mime_type', 549 'comment_count', 'filter', 550 // Plugin internal properties 551 '_praison_file', '_praison_categories', '_praison_tags', 552 '_praison_featured_image', '_praison_custom_fields', 553 ]; 554 } 555 556 foreach ( $posts as $post ) { 557 if ( ! is_object( $post ) || $post->ID >= 0 ) { 558 continue; 559 } 560 561 // Skip if meta already registered (by loadFromIndex or createPostObject). 562 // This prevents the double-registration bug where _index.json was loaded 563 // TWICE per request (once in PostLoader, again here). 564 $abs_id = abs( $post->ID ); 565 if ( isset( self::$virtualMeta[ $abs_id ] ) ) { 566 continue; 567 } 568 569 // Collect meta from the post object's dynamic properties. 570 // PostLoader::loadFromIndex() and createPostObject() set custom fields 571 // directly on the WP_Post object (e.g. $post->ta_first_line, $post->artist). 572 // These survive serialization through the transient cache. 573 $meta = []; 574 575 // Start with _praison_custom_fields if available. 576 if ( ! empty( $post->_praison_custom_fields ) && is_array( $post->_praison_custom_fields ) ) { 577 $meta = $post->_praison_custom_fields; 578 } 579 580 // Collect any dynamic properties (ta_first_line, artist, en_first_line, etc.) 581 // that aren't standard WP_Post properties and don't start with underscore. 582 foreach ( get_object_vars( $post ) as $k => $v ) { 583 if ( ! in_array( $k, $wpProps, true ) && strpos( $k, '_' ) !== 0 ) { 584 $meta[ $k ] = $v; 585 } 586 } 587 588 if ( ! empty( $meta ) ) { 589 self::registerVirtualMeta( $post->ID, $meta ); 590 } 591 } 454 592 } 455 593 -
praison-file-content-git/tags/1.8.1/src/Export/AutoExporter.php
r3490358 r3494675 3 3 4 4 if ( ! defined( 'ABSPATH' ) ) exit; 5 6 use PraisonPress\Index\IndexManager; 5 7 6 8 /** … … 40 42 add_action( 'transition_post_status', [ $this, 'onStatusTransition' ], 20, 3 ); 41 43 44 // Deletion hooks — remove .md file and index entry 45 add_action( 'wp_trash_post', [ $this, 'onTrashPost' ], 20 ); 46 add_action( 'before_delete_post', [ $this, 'onTrashPost' ], 20 ); // permanent delete 47 add_action( 'untrash_post', [ $this, 'onUntrashPost' ], 20 ); 48 42 49 // Process the scheduled export 43 50 add_action( 'praison_auto_export_post', [ $this, 'exportAndSync' ] ); … … 138 145 } 139 146 147 // Incremental index update (~10ms instead of full rebuild). 148 $date_prefix = gmdate( 'Y-m-d', strtotime( $post->post_date ) ); 149 $md_file = $output_dir . '/' . $date_prefix . '-' . $post->post_name . '.md'; 150 IndexManager::addOrUpdate( $post->post_type, $post->post_name, $md_file ); 151 140 152 // Commit to Git if content directory is a git repo 141 153 if ( ! $this->isPushEnabled() ) { … … 147 159 148 160 /** 161 * Handle post trash / permanent delete: remove .md file + index entry. 162 */ 163 public function onTrashPost( int $post_id ): void { 164 $post = get_post( $post_id ); 165 if ( ! $post ) { 166 return; 167 } 168 169 // Check if this post type has an export directory. 170 $exportDir = $this->exportConfig->getExportDirectory( $post->post_type ); 171 if ( empty( $exportDir ) ) { 172 return; 173 } 174 175 $output_dir = PRAISON_CONTENT_DIR . '/' . $post->post_type; 176 $date_prefix = gmdate( 'Y-m-d', strtotime( $post->post_date ) ); 177 $md_file = $output_dir . '/' . $date_prefix . '-' . $post->post_name . '.md'; 178 179 // Delete the .md file. 180 if ( file_exists( $md_file ) ) { 181 unlink( $md_file ); 182 } 183 // Also try without date prefix (older export format). 184 $md_file_alt = $output_dir . '/' . $post->post_name . '.md'; 185 if ( file_exists( $md_file_alt ) ) { 186 unlink( $md_file_alt ); 187 } 188 189 // Remove from index. 190 IndexManager::remove( $post->post_type, $post->post_name ); 191 192 // Push deletion to Git. 193 if ( $this->isPushEnabled() ) { 194 $this->commitAndPush( $post, 'Deleted' ); 195 } 196 } 197 198 /** 199 * Handle untrash: re-export the post and add back to index. 200 */ 201 public function onUntrashPost( int $post_id ): void { 202 // Schedule re-export (reuses existing export logic). 203 wp_schedule_single_event( time(), 'praison_auto_export_post', [ $post_id ] ); 204 } 205 206 /** 149 207 * Commit the exported file and push to remote 150 208 */ 151 private function commitAndPush( \WP_Post $post ): void {209 private function commitAndPush( \WP_Post $post, string $action = 'Auto-export' ): void { 152 210 if ( ! is_dir( PRAISON_CONTENT_DIR . '/.git' ) ) { 153 211 return; … … 158 216 159 217 // Stage all changes in the post type directory 160 exec( 'git add ' . escapeshellarg( $post->post_type ) . '/ 2>&1', $addOutput, $addReturn );218 exec( 'git add -A ' . escapeshellarg( $post->post_type ) . '/ 2>&1', $addOutput, $addReturn ); 161 219 162 220 // Check if there are staged changes … … 170 228 // Commit 171 229 $message = sprintf( 172 'Auto-export: %s "%s" (%s)', 230 '%s: %s "%s" (%s)', 231 $action, 173 232 $post->post_type, 174 233 $post->post_title, -
praison-file-content-git/tags/1.8.1/src/GitHub/SyncManager.php
r3426687 r3494675 289 289 } 290 290 291 $oldDir = getcwd(); 292 chdir($this->contentDir); 293 294 // Record HEAD before pull for diff comparison. 295 exec('git rev-parse HEAD 2>&1', $beforeOutput); 296 $beforeRef = isset($beforeOutput[0]) ? trim($beforeOutput[0]) : ''; 297 298 chdir($oldDir); 299 291 300 // Pull changes 292 return $this->pullFromRemote(); 301 $result = $this->pullFromRemote(); 302 303 if ($result['success'] && !empty($beforeRef)) { 304 // Detect changed files and do incremental index updates. 305 $this->processChangedFiles($beforeRef); 306 } 307 308 return $result; 309 } 310 311 /** 312 * Process changed files after a pull — incremental index updates. 313 * 314 * @param string $beforeRef Git ref before the pull. 315 */ 316 private function processChangedFiles(string $beforeRef): void { 317 $oldDir = getcwd(); 318 chdir($this->contentDir); 319 320 // Get list of changed files with status: A(dded), M(odified), D(eleted), R(enamed). 321 exec('git diff --name-status ' . escapeshellarg($beforeRef) . '..HEAD 2>&1', $diffOutput, $diffReturn); 322 323 chdir($oldDir); 324 325 if ($diffReturn !== 0 || empty($diffOutput)) { 326 return; 327 } 328 329 // Process each changed file. 330 foreach ($diffOutput as $line) { 331 // Format: "A\tlyrics/my-song.md" or "R100\tlyrics/old.md\tlyrics/new.md" 332 $parts = preg_split('/\t+/', $line); 333 if (count($parts) < 2) { 334 continue; 335 } 336 337 $status = $parts[0]; 338 $file = $parts[1]; 339 340 // Only process .md files (skip _index.json, .gitignore, etc). 341 if (substr($file, -3) !== '.md' || strpos(basename($file), '_') === 0) { 342 continue; 343 } 344 345 // Extract post type from path (first directory component). 346 $pathParts = explode('/', $file); 347 if (count($pathParts) < 2) { 348 continue; 349 } 350 $type = $pathParts[0]; 351 352 $fullPath = $this->contentDir . '/' . $file; 353 $slug = pathinfo(basename($file), PATHINFO_FILENAME); 354 355 if ($status === 'D') { 356 // Deleted file — remove from index. 357 \PraisonPress\Index\IndexManager::remove($type, $slug); 358 } elseif (strpos($status, 'R') === 0 && isset($parts[2])) { 359 // Renamed: remove old, add new. 360 $oldSlug = pathinfo(basename($parts[1]), PATHINFO_FILENAME); 361 \PraisonPress\Index\IndexManager::remove($type, $oldSlug); 362 363 $newFile = $this->contentDir . '/' . $parts[2]; 364 $newSlug = pathinfo(basename($parts[2]), PATHINFO_FILENAME); 365 $newType = explode('/', $parts[2])[0] ?? $type; 366 \PraisonPress\Index\IndexManager::addOrUpdate($newType, $newSlug, $newFile); 367 } else { 368 // Added or Modified — upsert in index. 369 \PraisonPress\Index\IndexManager::addOrUpdate($type, $slug, $fullPath); 370 } 371 } 372 373 if (get_option('praisonpress_qm_logging')) { 374 do_action('qm/info', sprintf( 375 '[PraisonPress] Webhook: processed %d file change(s) from git pull', 376 count($diffOutput) 377 )); 378 } 293 379 } 294 380 } -
praison-file-content-git/tags/1.8.1/src/Loaders/PostLoader.php
r3491371 r3494675 227 227 $post->_praison_custom_fields = $entry['custom_fields'] ?? $entry['custom'] ?? []; 228 228 229 // Store custom fields as post properties for ACF compatibility 230 $custom = $entry['custom_fields'] ?? $entry['custom'] ?? []; 231 foreach ($custom as $key => $value) { 229 // Store custom fields as post properties for ACF compatibility. 230 // The FrontMatterParser flattens nested YAML blocks, so custom field keys 231 // appear as top-level entries in the index (e.g. 'artist', 'ta_first_line'). 232 // Collect them all: both $entry['custom_fields'] (if it's a dict) and 233 // any non-structural top-level keys. 234 $structural = [ 235 'file', 'title', 'slug', 'status', 'author', 'date', 'modified', 236 'excerpt', 'categories', 'tags', 'featured_image', 'custom_fields', 'custom', 237 ]; 238 $allMeta = []; 239 // First, merge custom_fields if it's a non-empty associative array. 240 $cf = $entry['custom_fields'] ?? $entry['custom'] ?? []; 241 if (is_array($cf) && !empty($cf) && !isset($cf[0])) { 242 $allMeta = $cf; 243 } 244 // Then, collect all top-level non-structural keys. 245 foreach ($entry as $k => $v) { 246 if (!in_array($k, $structural, true)) { 247 $allMeta[$k] = $v; 248 } 249 } 250 // Store on post object for direct property access and ACF. 251 foreach ($allMeta as $key => $value) { 232 252 $post->{$key} = $value; 253 } 254 // Register with Bootstrap's virtual meta registry for get_post_meta() interception. 255 if (!empty($allMeta)) { 256 \PraisonPress\Core\Bootstrap::registerVirtualMeta($post->ID, $allMeta); 233 257 } 234 258 … … 298 322 $post->_praison_custom_fields = $metadata['custom_fields'] ?? []; 299 323 300 // Store custom fields as post meta for ACF compatibility 301 // This allows get_field() and other ACF functions to work 302 if (!empty($metadata['custom_fields'])) { 303 foreach ($metadata['custom_fields'] as $key => $value) { 304 // Store in the post object so ACF can access it 305 $post->{$key} = $value; 306 } 324 // Store custom fields as post meta for ACF compatibility. 325 // The FrontMatterParser flattens nested YAML, so custom field keys 326 // appear as top-level metadata keys (e.g. 'artist', 'ta_first_line'). 327 $structural = [ 328 'title', 'slug', 'status', 'author', 'date', 'modified', 329 'excerpt', 'categories', 'tags', 'featured_image', 'custom_fields', 'custom', 'content', 330 ]; 331 $allMeta = []; 332 // Merge custom_fields if it's a non-empty associative array. 333 $cf = $metadata['custom_fields'] ?? []; 334 if (is_array($cf) && !empty($cf) && !isset($cf[0])) { 335 $allMeta = $cf; 336 } 337 // Collect all top-level non-structural keys. 338 foreach ($metadata as $k => $v) { 339 if (!in_array($k, $structural, true)) { 340 $allMeta[$k] = $v; 341 } 342 } 343 foreach ($allMeta as $key => $value) { 344 $post->{$key} = $value; 345 } 346 if (!empty($allMeta)) { 347 \PraisonPress\Core\Bootstrap::registerVirtualMeta($post->ID, $allMeta); 307 348 } 308 349 … … 430 471 */ 431 472 private function loadSinglePost(string $slug): array { 473 // 1. Fast path: direct file lookup by slug (O(1), ~0 MB overhead). 474 // This avoids loading the full _index.json (16-23 MB → 34-50 MB decoded) 475 // which would spike peak memory past the 150M PHP limit. 476 $file = $this->postsDir . '/' . $slug . '.md'; 477 if (file_exists($file)) { 478 $content = file_get_contents($file); 479 $parsed = $this->frontMatterParser->parse($content); 480 $post = $this->createPostObject($parsed, $file); 481 return $post ? [$post] : []; 482 } 483 484 // 2. Try date-prefixed files (e.g. 2024-01-15-my-song.md from AutoExporter). 485 $matches = glob($this->postsDir . '/*-' . $slug . '.md'); 486 if (!empty($matches)) { 487 $file = $matches[0]; 488 $content = file_get_contents($file); 489 $parsed = $this->frontMatterParser->parse($content); 490 $post = $this->createPostObject($parsed, $file); 491 return $post ? [$post] : []; 492 } 493 494 // 3. Last resort: check _index.json (slug may not match any filename pattern). 495 // This path loads the full index into memory — only reached when the slug 496 // doesn't directly correspond to a filename on disk. 432 497 $indexFile = $this->postsDir . '/_index.json'; 433 434 498 if (file_exists($indexFile)) { 435 499 $index = json_decode(file_get_contents($indexFile), true); … … 441 505 } 442 506 } 443 return []; // slug not in index → post doesn't exist 444 } 445 } 446 447 // Fallback: direct file lookup by slug (no full scan) 448 $file = $this->postsDir . '/' . $slug . '.md'; 449 if (file_exists($file)) { 450 $content = file_get_contents($file); 451 $parsed = $this->frontMatterParser->parse($content); 452 $post = $this->createPostObject($parsed, $file); 453 return $post ? [$post] : []; 507 } 454 508 } 455 509 -
praison-file-content-git/trunk/AGENTS.md
r3488026 r3494675 16 16 │ ├── Core/ # Bootstrap, Router 17 17 │ ├── Loaders/ # PostLoader (file → WP_Post) 18 │ ├── Index/ # IndexManager (incremental _index.json ops) 19 │ ├── Export/ # AutoExporter (dashboard → .md → Git) 20 │ ├── GitHub/ # SyncManager (Git pull/push/diff) 18 21 │ ├── Cache/ # CacheManager, SmartCacheInvalidator 19 22 │ ├── Parsers/ # FrontMatterParser, MarkdownParser … … 33 36 34 37 ```php 35 Version: 1. 0.938 Version: 1.8.0 36 39 ``` 37 40 … … 44 47 | Class | Purpose | 45 48 |-------|---------| 46 | `Bootstrap` | Plugin initialization, `posts_pre_query` injection | 47 | `PostLoader` | Load/cache/filter file-based posts | 49 | `Bootstrap` | Plugin initialization, `posts_pre_query` injection, virtual meta filter | 50 | `PostLoader` | Load/cache/filter file-based posts, register virtual meta | 51 | `IndexManager` | Incremental `_index.json` updates (add, update, remove) with flock() | 52 | `AutoExporter` | Dashboard → `.md` export, Git commit/push, deletion hooks | 53 | `SyncManager` | Git clone/pull/push, post-pull diff detection (A/M/D/R) | 48 54 | `CacheManager` | Transient caching (O(1) dir-mtime key) | 49 55 | `SmartCacheInvalidator` | Cache clear on PR merge | -
praison-file-content-git/trunk/docs/index.md
r3488026 r3494675 1 1 # WP Git Posts 2 2 3 Load WordPress content from files without database writes. 3 Load WordPress content from files without database writes. Bidirectional Git sync keeps your content repository and WordPress site perfectly in sync. 4 4 5 5 ```mermaid 6 6 graph LR 7 A[📁 Git Repo] --> B[📄 Files] 8 B --> C[🔌 PraisonPressGit] 9 C --> D[🌐 WordPress] 7 A[📁 Git Repo] -->|push| B[🔌 Plugin] 8 B -->|pull| A 9 B --> C[🌐 WordPress] 10 C -->|auto-export| B 10 11 11 12 style A fill:#14B8A6,stroke:#7C90A0,color:#fff 12 style B fill:#F59E0B,stroke:#7C90A0,color:#fff 13 style C fill:#6366F1,stroke:#7C90A0,color:#fff 14 style D fill:#10B981,stroke:#7C90A0,color:#fff 13 style B fill:#6366F1,stroke:#7C90A0,color:#fff 14 style C fill:#10B981,stroke:#7C90A0,color:#fff 15 15 ``` 16 16 … … 18 18 19 19 1. **Install** → Upload plugin to WordPress 20 2. **Create** → Add Markdown /JSON/YAML files21 3. **Sync** → Git push to deploy content20 2. **Create** → Add Markdown files to `content/` directory 21 3. **Sync** → Changes flow both ways automatically 22 22 23 23 No database writes! 🎉 24 25 ## Supported Formats26 27 | Format | Use Case |28 |--------|----------|29 | 📝 Markdown | Blog posts, pages |30 | 📋 JSON | Structured data |31 | ⚙️ YAML | Configuration |32 24 33 25 ## Key Features … … 35 27 | Feature | Description | 36 28 |---------|-------------| 37 | 🔄 Git Sync | Version control for content | 38 | 👥 Collaborative | Multiple editors | 39 | ☁️ Cloud Native | Deploy anywhere | 40 | ⚡ No DB Writes | Fast and portable | 29 | 🔄 Bidirectional Sync | Dashboard ↔ Git automatic synchronization | 30 | ⚡ Incremental Indexing | O(1) updates (~10ms per post change) | 31 | 📋 Virtual Post Meta | `get_post_meta()` works for file-based posts | 32 | 🗑️ Deletion Handling | Trash/delete auto-manages `.md` files and index | 33 | 👥 Collaborative Editing | Submit edits via pull requests | 34 | ☁️ Cloud Native | Docker, Kubernetes, multi-pod ready | 35 | 🔒 Concurrency Safe | `flock()` locking for parallel operations | 36 | ⚡ No DB Writes | Fast, portable, version-controlled | 37 38 ## v1.8.0 Highlights 39 40 - **Bidirectional sync**: Edit in WordPress → auto-push to Git. Push to Git → auto-import to WordPress 41 - **Incremental indexing**: No more full rebuilds. Each post change updates `_index.json` in ~10ms 42 - **Virtual post meta**: `get_post_meta()`, `get_field()` work seamlessly with file-based posts 43 - **Deletion handling**: Trash/delete/restore automatically managed across Git and WordPress 41 44 42 45 ## Next Steps … … 44 47 - [Installation](getting-started/installation.md) 45 48 - [Configuration](getting-started/configuration.md) 46 - [File Content Guide](features/file-content.md) 49 - [Bidirectional Git Sync](features/bidirectional-sync.md) 50 - [Incremental Indexing](features/incremental-indexing.md) 51 - [Virtual Post Meta](features/virtual-post-meta.md) 52 - [Deletion Handling](features/deletion-handling.md) 53 -
praison-file-content-git/trunk/mkdocs.yml
r3488026 r3494675 56 56 - File-Based Content: features/file-based-content.md 57 57 - Export to Markdown: features/export.md 58 - Bidirectional Git Sync: features/bidirectional-sync.md 59 - Incremental Indexing: features/incremental-indexing.md 60 - Virtual Post Meta: features/virtual-post-meta.md 61 - Deletion Handling: features/deletion-handling.md 58 62 - Collaborative Editing: features/collaborative-editing.md 59 63 - Performance & Caching: features/performance.md -
praison-file-content-git/trunk/praisonpressgit.php
r3491385 r3494675 3 3 * Plugin Name: PraisonAI Git Posts 4 4 * Description: Load WordPress content from files (Markdown, JSON, YAML) without database writes, with Git-based version control 5 * Version: 1. 7.05 * Version: 1.8.1 6 6 * Author: MervinPraison 7 7 * Author URI: https://mer.vin … … 13 13 14 14 // Define constants 15 define('PRAISON_VERSION', '1. 7.0');15 define('PRAISON_VERSION', '1.8.1'); 16 16 define('PRAISON_PLUGIN_DIR', __DIR__); 17 17 define('PRAISON_PLUGIN_URL', trailingslashit(plugins_url('', __FILE__))); -
praison-file-content-git/trunk/readme.txt
r3491385 r3494675 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 1. 7.07 Stable tag: 1.8.1 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 228 228 * HOTFIX: Cache keys now include category_name and tag query vars to prevent taxonomy archive cache collisions 229 229 230 = 1.0.7 = 230 = 1.8.1 = 231 * CRITICAL FIX: loadSinglePost() — direct .md file lookup first, eliminates 49.5 MB peak memory spike per cache-miss request 232 * CRITICAL FIX: registerPostsMeta() — reads meta from post object properties instead of re-loading full _index.json (eliminates double-registration, saves 34 MB) 233 * FIX: Date-prefixed filename support in direct lookup (e.g. 2024-01-15-my-song.md via glob fallback) 234 * FIX: PRAISON_VERSION constant now matches plugin header version (was stuck at 1.7.0) 235 * PERF: Single-page cache-miss peak memory reduced from 187.5 MB to ~138 MB 236 * PERF: Archive page requests no longer load _index.json twice 237 238 = 1.8.0 = 239 * NEW: Bidirectional Git sync — dashboard edits auto-export to Git, Git pushes auto-import to WordPress 240 * NEW: Incremental index updates (O(1) per post, ~10ms) — no more full-rescan rebuilds 241 * NEW: IndexManager class with atomic file operations and flock() concurrency safety 242 * NEW: Deletion handling — trashing/deleting posts auto-removes .md files and updates _index.json 243 * NEW: Virtual post meta via get_post_meta() — headless posts serve custom fields from frontmatter 244 * NEW: registerPostsMeta() reads _index.json by slug for cache-safe meta registration 245 * FIX: WordPress absint() compatibility — negative virtual IDs stored under both negative and positive keys 246 * FIX: CacheManager serialization — meta registration survives Redis cache round-trips 247 * FIX: AutoExporter uses git add -A to stage deletions, not just additions 248 * FIX: SyncManager detects Added/Modified/Deleted/Renamed files via git diff --name-status after pull 249 250 = 1.0.9 = 231 251 * HOTFIX: CacheManager::getContentKey() - Replaced O(n) glob()+filemtime() with O(1) filemtime($dir) — critical for 100k+ file deployments 232 252 * HOTFIX: PostLoader - Added _index.json fast path for single-slug queries (O(1) lookup vs full directory scan) … … 286 306 == Upgrade Notice == 287 307 308 = 1.8.0 = 309 Major release: Bidirectional Git sync, incremental indexing, deletion handling, and get_post_meta() support for headless posts. Recommended update for all users. 310 288 311 = 1.0.9 = 289 312 Hotfix: Fixes invalid HTML from the Markdown fallback parser, YAML inline array and boolean parsing, permalink format, and SmartCacheInvalidator transient pattern. -
praison-file-content-git/trunk/src/Core/Bootstrap.php
r3491360 r3494675 22 22 23 23 /** 24 * Virtual post meta registry — populated by PostLoader, consumed by get_post_metadata filter. 25 * Structure: [ post_id => [ 'key' => value, ... ] ] 26 */ 27 private static $virtualMeta = []; 28 29 /** 24 30 * Initialize the plugin 25 31 */ … … 78 84 add_filter('posts_pre_query', [$this, 'injectFilePosts'], 10, 2); 79 85 86 // Intercept get_post_meta() for virtual (negative-ID) headless posts 87 add_filter('get_post_metadata', [$this, 'interceptVirtualMeta'], 10, 4); 88 80 89 // Register Settings page in admin 81 90 if (is_admin()) { … … 151 160 $autoExporter->register(); 152 161 } 162 } 163 164 /** 165 * Register virtual meta for a headless post (called by PostLoader). 166 * 167 * WordPress's get_metadata_raw() calls absint($object_id), converting 168 * negative IDs to positive before both filter and cache lookups. 169 * We store under both negative (for direct property access) and 170 * positive/absint'd (for WordPress metadata system compatibility). 171 * 172 * @param int $post_id Negative virtual post ID. 173 * @param array $meta Associative array of meta key => value. 174 */ 175 public static function registerVirtualMeta( int $post_id, array $meta ): void { 176 // Store under original negative ID (for internal lookups). 177 self::$virtualMeta[ $post_id ] = $meta; 178 179 // Also store under absint'd ID (WordPress converts negative → positive). 180 $abs_id = abs( $post_id ); 181 self::$virtualMeta[ $abs_id ] = $meta; 182 183 // Pre-populate WordPress metadata cache under the absint'd ID. 184 $cache_data = []; 185 foreach ( $meta as $key => $value ) { 186 $cache_data[ $key ] = [ maybe_serialize( $value ) ]; 187 } 188 wp_cache_set( $abs_id, $cache_data, 'post_meta' ); 189 } 190 191 /** 192 * Intercept get_post_meta() for virtual headless posts. 193 * 194 * NOTE: WordPress calls absint() on the post_id before passing it to 195 * this filter, so we receive the POSITIVE version of the ID. 196 * 197 * @param mixed $value Existing value (null = not filtered yet). 198 * @param int $post_id Post ID (already absint'd by WordPress). 199 * @param string $meta_key Meta key being requested. 200 * @param bool $single Whether to return a single value. 201 * @return mixed 202 */ 203 public function interceptVirtualMeta( $value, $post_id, $meta_key, $single ) { 204 if ( ! isset( self::$virtualMeta[ $post_id ] ) ) { 205 return $value; 206 } 207 208 $meta = self::$virtualMeta[ $post_id ]; 209 210 // Return all meta if no specific key requested. 211 if ( empty( $meta_key ) ) { 212 $result = []; 213 foreach ( $meta as $k => $v ) { 214 $result[ $k ] = [ $v ]; 215 } 216 return $result; 217 } 218 219 // Return specific key if it exists. 220 if ( isset( $meta[ $meta_key ] ) ) { 221 return [ $meta[ $meta_key ] ]; 222 } 223 224 // Key not in our registry — fall through to DB. 225 return $value; 153 226 } 154 227 … … 421 494 )); 422 495 } 496 $this->registerPostsMeta($file_posts); 423 497 return $file_posts; 424 498 } … … 451 525 } 452 526 527 $this->registerPostsMeta($merged); 453 528 return $merged; 529 } 530 531 /** 532 * Register virtual meta for an array of file-based posts. 533 * 534 * Reads custom field data from _index.json by slug, since cached 535 * WP_Post objects lose extra properties during serialization. 536 * 537 * @param array $posts Array of WP_Post objects. 538 */ 539 private function registerPostsMeta( array $posts ): void { 540 // WP_Post standard properties — everything else is a custom field. 541 static $wpProps = null; 542 if ( $wpProps === null ) { 543 $wpProps = [ 544 'ID', 'post_author', 'post_date', 'post_date_gmt', 'post_content', 545 'post_title', 'post_excerpt', 'post_status', 'comment_status', 546 'ping_status', 'post_password', 'post_name', 'to_ping', 'pinged', 547 'post_modified', 'post_modified_gmt', 'post_content_filtered', 548 'post_parent', 'guid', 'menu_order', 'post_type', 'post_mime_type', 549 'comment_count', 'filter', 550 // Plugin internal properties 551 '_praison_file', '_praison_categories', '_praison_tags', 552 '_praison_featured_image', '_praison_custom_fields', 553 ]; 554 } 555 556 foreach ( $posts as $post ) { 557 if ( ! is_object( $post ) || $post->ID >= 0 ) { 558 continue; 559 } 560 561 // Skip if meta already registered (by loadFromIndex or createPostObject). 562 // This prevents the double-registration bug where _index.json was loaded 563 // TWICE per request (once in PostLoader, again here). 564 $abs_id = abs( $post->ID ); 565 if ( isset( self::$virtualMeta[ $abs_id ] ) ) { 566 continue; 567 } 568 569 // Collect meta from the post object's dynamic properties. 570 // PostLoader::loadFromIndex() and createPostObject() set custom fields 571 // directly on the WP_Post object (e.g. $post->ta_first_line, $post->artist). 572 // These survive serialization through the transient cache. 573 $meta = []; 574 575 // Start with _praison_custom_fields if available. 576 if ( ! empty( $post->_praison_custom_fields ) && is_array( $post->_praison_custom_fields ) ) { 577 $meta = $post->_praison_custom_fields; 578 } 579 580 // Collect any dynamic properties (ta_first_line, artist, en_first_line, etc.) 581 // that aren't standard WP_Post properties and don't start with underscore. 582 foreach ( get_object_vars( $post ) as $k => $v ) { 583 if ( ! in_array( $k, $wpProps, true ) && strpos( $k, '_' ) !== 0 ) { 584 $meta[ $k ] = $v; 585 } 586 } 587 588 if ( ! empty( $meta ) ) { 589 self::registerVirtualMeta( $post->ID, $meta ); 590 } 591 } 454 592 } 455 593 -
praison-file-content-git/trunk/src/Export/AutoExporter.php
r3490358 r3494675 3 3 4 4 if ( ! defined( 'ABSPATH' ) ) exit; 5 6 use PraisonPress\Index\IndexManager; 5 7 6 8 /** … … 40 42 add_action( 'transition_post_status', [ $this, 'onStatusTransition' ], 20, 3 ); 41 43 44 // Deletion hooks — remove .md file and index entry 45 add_action( 'wp_trash_post', [ $this, 'onTrashPost' ], 20 ); 46 add_action( 'before_delete_post', [ $this, 'onTrashPost' ], 20 ); // permanent delete 47 add_action( 'untrash_post', [ $this, 'onUntrashPost' ], 20 ); 48 42 49 // Process the scheduled export 43 50 add_action( 'praison_auto_export_post', [ $this, 'exportAndSync' ] ); … … 138 145 } 139 146 147 // Incremental index update (~10ms instead of full rebuild). 148 $date_prefix = gmdate( 'Y-m-d', strtotime( $post->post_date ) ); 149 $md_file = $output_dir . '/' . $date_prefix . '-' . $post->post_name . '.md'; 150 IndexManager::addOrUpdate( $post->post_type, $post->post_name, $md_file ); 151 140 152 // Commit to Git if content directory is a git repo 141 153 if ( ! $this->isPushEnabled() ) { … … 147 159 148 160 /** 161 * Handle post trash / permanent delete: remove .md file + index entry. 162 */ 163 public function onTrashPost( int $post_id ): void { 164 $post = get_post( $post_id ); 165 if ( ! $post ) { 166 return; 167 } 168 169 // Check if this post type has an export directory. 170 $exportDir = $this->exportConfig->getExportDirectory( $post->post_type ); 171 if ( empty( $exportDir ) ) { 172 return; 173 } 174 175 $output_dir = PRAISON_CONTENT_DIR . '/' . $post->post_type; 176 $date_prefix = gmdate( 'Y-m-d', strtotime( $post->post_date ) ); 177 $md_file = $output_dir . '/' . $date_prefix . '-' . $post->post_name . '.md'; 178 179 // Delete the .md file. 180 if ( file_exists( $md_file ) ) { 181 unlink( $md_file ); 182 } 183 // Also try without date prefix (older export format). 184 $md_file_alt = $output_dir . '/' . $post->post_name . '.md'; 185 if ( file_exists( $md_file_alt ) ) { 186 unlink( $md_file_alt ); 187 } 188 189 // Remove from index. 190 IndexManager::remove( $post->post_type, $post->post_name ); 191 192 // Push deletion to Git. 193 if ( $this->isPushEnabled() ) { 194 $this->commitAndPush( $post, 'Deleted' ); 195 } 196 } 197 198 /** 199 * Handle untrash: re-export the post and add back to index. 200 */ 201 public function onUntrashPost( int $post_id ): void { 202 // Schedule re-export (reuses existing export logic). 203 wp_schedule_single_event( time(), 'praison_auto_export_post', [ $post_id ] ); 204 } 205 206 /** 149 207 * Commit the exported file and push to remote 150 208 */ 151 private function commitAndPush( \WP_Post $post ): void {209 private function commitAndPush( \WP_Post $post, string $action = 'Auto-export' ): void { 152 210 if ( ! is_dir( PRAISON_CONTENT_DIR . '/.git' ) ) { 153 211 return; … … 158 216 159 217 // Stage all changes in the post type directory 160 exec( 'git add ' . escapeshellarg( $post->post_type ) . '/ 2>&1', $addOutput, $addReturn );218 exec( 'git add -A ' . escapeshellarg( $post->post_type ) . '/ 2>&1', $addOutput, $addReturn ); 161 219 162 220 // Check if there are staged changes … … 170 228 // Commit 171 229 $message = sprintf( 172 'Auto-export: %s "%s" (%s)', 230 '%s: %s "%s" (%s)', 231 $action, 173 232 $post->post_type, 174 233 $post->post_title, -
praison-file-content-git/trunk/src/GitHub/SyncManager.php
r3426687 r3494675 289 289 } 290 290 291 $oldDir = getcwd(); 292 chdir($this->contentDir); 293 294 // Record HEAD before pull for diff comparison. 295 exec('git rev-parse HEAD 2>&1', $beforeOutput); 296 $beforeRef = isset($beforeOutput[0]) ? trim($beforeOutput[0]) : ''; 297 298 chdir($oldDir); 299 291 300 // Pull changes 292 return $this->pullFromRemote(); 301 $result = $this->pullFromRemote(); 302 303 if ($result['success'] && !empty($beforeRef)) { 304 // Detect changed files and do incremental index updates. 305 $this->processChangedFiles($beforeRef); 306 } 307 308 return $result; 309 } 310 311 /** 312 * Process changed files after a pull — incremental index updates. 313 * 314 * @param string $beforeRef Git ref before the pull. 315 */ 316 private function processChangedFiles(string $beforeRef): void { 317 $oldDir = getcwd(); 318 chdir($this->contentDir); 319 320 // Get list of changed files with status: A(dded), M(odified), D(eleted), R(enamed). 321 exec('git diff --name-status ' . escapeshellarg($beforeRef) . '..HEAD 2>&1', $diffOutput, $diffReturn); 322 323 chdir($oldDir); 324 325 if ($diffReturn !== 0 || empty($diffOutput)) { 326 return; 327 } 328 329 // Process each changed file. 330 foreach ($diffOutput as $line) { 331 // Format: "A\tlyrics/my-song.md" or "R100\tlyrics/old.md\tlyrics/new.md" 332 $parts = preg_split('/\t+/', $line); 333 if (count($parts) < 2) { 334 continue; 335 } 336 337 $status = $parts[0]; 338 $file = $parts[1]; 339 340 // Only process .md files (skip _index.json, .gitignore, etc). 341 if (substr($file, -3) !== '.md' || strpos(basename($file), '_') === 0) { 342 continue; 343 } 344 345 // Extract post type from path (first directory component). 346 $pathParts = explode('/', $file); 347 if (count($pathParts) < 2) { 348 continue; 349 } 350 $type = $pathParts[0]; 351 352 $fullPath = $this->contentDir . '/' . $file; 353 $slug = pathinfo(basename($file), PATHINFO_FILENAME); 354 355 if ($status === 'D') { 356 // Deleted file — remove from index. 357 \PraisonPress\Index\IndexManager::remove($type, $slug); 358 } elseif (strpos($status, 'R') === 0 && isset($parts[2])) { 359 // Renamed: remove old, add new. 360 $oldSlug = pathinfo(basename($parts[1]), PATHINFO_FILENAME); 361 \PraisonPress\Index\IndexManager::remove($type, $oldSlug); 362 363 $newFile = $this->contentDir . '/' . $parts[2]; 364 $newSlug = pathinfo(basename($parts[2]), PATHINFO_FILENAME); 365 $newType = explode('/', $parts[2])[0] ?? $type; 366 \PraisonPress\Index\IndexManager::addOrUpdate($newType, $newSlug, $newFile); 367 } else { 368 // Added or Modified — upsert in index. 369 \PraisonPress\Index\IndexManager::addOrUpdate($type, $slug, $fullPath); 370 } 371 } 372 373 if (get_option('praisonpress_qm_logging')) { 374 do_action('qm/info', sprintf( 375 '[PraisonPress] Webhook: processed %d file change(s) from git pull', 376 count($diffOutput) 377 )); 378 } 293 379 } 294 380 } -
praison-file-content-git/trunk/src/Loaders/PostLoader.php
r3491371 r3494675 227 227 $post->_praison_custom_fields = $entry['custom_fields'] ?? $entry['custom'] ?? []; 228 228 229 // Store custom fields as post properties for ACF compatibility 230 $custom = $entry['custom_fields'] ?? $entry['custom'] ?? []; 231 foreach ($custom as $key => $value) { 229 // Store custom fields as post properties for ACF compatibility. 230 // The FrontMatterParser flattens nested YAML blocks, so custom field keys 231 // appear as top-level entries in the index (e.g. 'artist', 'ta_first_line'). 232 // Collect them all: both $entry['custom_fields'] (if it's a dict) and 233 // any non-structural top-level keys. 234 $structural = [ 235 'file', 'title', 'slug', 'status', 'author', 'date', 'modified', 236 'excerpt', 'categories', 'tags', 'featured_image', 'custom_fields', 'custom', 237 ]; 238 $allMeta = []; 239 // First, merge custom_fields if it's a non-empty associative array. 240 $cf = $entry['custom_fields'] ?? $entry['custom'] ?? []; 241 if (is_array($cf) && !empty($cf) && !isset($cf[0])) { 242 $allMeta = $cf; 243 } 244 // Then, collect all top-level non-structural keys. 245 foreach ($entry as $k => $v) { 246 if (!in_array($k, $structural, true)) { 247 $allMeta[$k] = $v; 248 } 249 } 250 // Store on post object for direct property access and ACF. 251 foreach ($allMeta as $key => $value) { 232 252 $post->{$key} = $value; 253 } 254 // Register with Bootstrap's virtual meta registry for get_post_meta() interception. 255 if (!empty($allMeta)) { 256 \PraisonPress\Core\Bootstrap::registerVirtualMeta($post->ID, $allMeta); 233 257 } 234 258 … … 298 322 $post->_praison_custom_fields = $metadata['custom_fields'] ?? []; 299 323 300 // Store custom fields as post meta for ACF compatibility 301 // This allows get_field() and other ACF functions to work 302 if (!empty($metadata['custom_fields'])) { 303 foreach ($metadata['custom_fields'] as $key => $value) { 304 // Store in the post object so ACF can access it 305 $post->{$key} = $value; 306 } 324 // Store custom fields as post meta for ACF compatibility. 325 // The FrontMatterParser flattens nested YAML, so custom field keys 326 // appear as top-level metadata keys (e.g. 'artist', 'ta_first_line'). 327 $structural = [ 328 'title', 'slug', 'status', 'author', 'date', 'modified', 329 'excerpt', 'categories', 'tags', 'featured_image', 'custom_fields', 'custom', 'content', 330 ]; 331 $allMeta = []; 332 // Merge custom_fields if it's a non-empty associative array. 333 $cf = $metadata['custom_fields'] ?? []; 334 if (is_array($cf) && !empty($cf) && !isset($cf[0])) { 335 $allMeta = $cf; 336 } 337 // Collect all top-level non-structural keys. 338 foreach ($metadata as $k => $v) { 339 if (!in_array($k, $structural, true)) { 340 $allMeta[$k] = $v; 341 } 342 } 343 foreach ($allMeta as $key => $value) { 344 $post->{$key} = $value; 345 } 346 if (!empty($allMeta)) { 347 \PraisonPress\Core\Bootstrap::registerVirtualMeta($post->ID, $allMeta); 307 348 } 308 349 … … 430 471 */ 431 472 private function loadSinglePost(string $slug): array { 473 // 1. Fast path: direct file lookup by slug (O(1), ~0 MB overhead). 474 // This avoids loading the full _index.json (16-23 MB → 34-50 MB decoded) 475 // which would spike peak memory past the 150M PHP limit. 476 $file = $this->postsDir . '/' . $slug . '.md'; 477 if (file_exists($file)) { 478 $content = file_get_contents($file); 479 $parsed = $this->frontMatterParser->parse($content); 480 $post = $this->createPostObject($parsed, $file); 481 return $post ? [$post] : []; 482 } 483 484 // 2. Try date-prefixed files (e.g. 2024-01-15-my-song.md from AutoExporter). 485 $matches = glob($this->postsDir . '/*-' . $slug . '.md'); 486 if (!empty($matches)) { 487 $file = $matches[0]; 488 $content = file_get_contents($file); 489 $parsed = $this->frontMatterParser->parse($content); 490 $post = $this->createPostObject($parsed, $file); 491 return $post ? [$post] : []; 492 } 493 494 // 3. Last resort: check _index.json (slug may not match any filename pattern). 495 // This path loads the full index into memory — only reached when the slug 496 // doesn't directly correspond to a filename on disk. 432 497 $indexFile = $this->postsDir . '/_index.json'; 433 434 498 if (file_exists($indexFile)) { 435 499 $index = json_decode(file_get_contents($indexFile), true); … … 441 505 } 442 506 } 443 return []; // slug not in index → post doesn't exist 444 } 445 } 446 447 // Fallback: direct file lookup by slug (no full scan) 448 $file = $this->postsDir . '/' . $slug . '.md'; 449 if (file_exists($file)) { 450 $content = file_get_contents($file); 451 $parsed = $this->frontMatterParser->parse($content); 452 $post = $this->createPostObject($parsed, $file); 453 return $post ? [$post] : []; 507 } 454 508 } 455 509
Note: See TracChangeset
for help on using the changeset viewer.