Plugin Directory

Changeset 3494675


Ignore:
Timestamp:
03/30/2026 02:15:02 PM (3 days ago)
Author:
mervinpraison
Message:

Update to version 1.8.1 from GitHub

Location:
praison-file-content-git
Files:
12 added
18 edited
1 copied

Legend:

Unmodified
Added
Removed
  • praison-file-content-git/tags/1.8.1/AGENTS.md

    r3488026 r3494675  
    1616│   ├── Core/               # Bootstrap, Router
    1717│   ├── 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)
    1821│   ├── Cache/              # CacheManager, SmartCacheInvalidator
    1922│   ├── Parsers/            # FrontMatterParser, MarkdownParser
     
    3336
    3437```php
    35 Version: 1.0.9
     38Version: 1.8.0
    3639```
    3740
     
    4447| Class | Purpose |
    4548|-------|---------|
    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) |
    4854| `CacheManager` | Transient caching (O(1) dir-mtime key) |
    4955| `SmartCacheInvalidator` | Cache clear on PR merge |
  • praison-file-content-git/tags/1.8.1/docs/index.md

    r3488026 r3494675  
    11# WP Git Posts
    22
    3 Load WordPress content from files without database writes.
     3Load WordPress content from files without database writes. Bidirectional Git sync keeps your content repository and WordPress site perfectly in sync.
    44
    55```mermaid
    66graph 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
    1011   
    1112    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
    1515```
    1616
     
    1818
    19191. **Install** → Upload plugin to WordPress
    20 2. **Create** → Add Markdown/JSON/YAML files
    21 3. **Sync** → Git push to deploy content
     202. **Create** → Add Markdown files to `content/` directory
     213. **Sync** → Changes flow both ways automatically
    2222
    2323No database writes! 🎉
    24 
    25 ## Supported Formats
    26 
    27 | Format | Use Case |
    28 |--------|----------|
    29 | 📝 Markdown | Blog posts, pages |
    30 | 📋 JSON | Structured data |
    31 | ⚙️ YAML | Configuration |
    3224
    3325## Key Features
     
    3527| Feature | Description |
    3628|---------|-------------|
    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
    4144
    4245## Next Steps
     
    4447- [Installation](getting-started/installation.md)
    4548- [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  
    5656    - File-Based Content: features/file-based-content.md
    5757    - 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
    5862    - Collaborative Editing: features/collaborative-editing.md
    5963    - Performance & Caching: features/performance.md
  • praison-file-content-git/tags/1.8.1/praisonpressgit.php

    r3491385 r3494675  
    33 * Plugin Name: PraisonAI Git Posts
    44 * Description: Load WordPress content from files (Markdown, JSON, YAML) without database writes, with Git-based version control
    5  * Version: 1.7.0
     5 * Version: 1.8.1
    66 * Author: MervinPraison
    77 * Author URI: https://mer.vin
     
    1313
    1414// Define constants
    15 define('PRAISON_VERSION', '1.7.0');
     15define('PRAISON_VERSION', '1.8.1');
    1616define('PRAISON_PLUGIN_DIR', __DIR__);
    1717define('PRAISON_PLUGIN_URL', trailingslashit(plugins_url('', __FILE__)));
  • praison-file-content-git/tags/1.8.1/readme.txt

    r3491385 r3494675  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.7.0
     7Stable tag: 1.8.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    228228* HOTFIX: Cache keys now include category_name and tag query vars to prevent taxonomy archive cache collisions
    229229
    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 =
    231251* HOTFIX: CacheManager::getContentKey() - Replaced O(n) glob()+filemtime() with O(1) filemtime($dir) — critical for 100k+ file deployments
    232252* HOTFIX: PostLoader - Added _index.json fast path for single-slug queries (O(1) lookup vs full directory scan)
     
    286306== Upgrade Notice ==
    287307
     308= 1.8.0 =
     309Major release: Bidirectional Git sync, incremental indexing, deletion handling, and get_post_meta() support for headless posts. Recommended update for all users.
     310
    288311= 1.0.9 =
    289312Hotfix: 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  
    2222   
    2323    /**
     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    /**
    2430     * Initialize the plugin
    2531     */
     
    7884        add_filter('posts_pre_query', [$this, 'injectFilePosts'], 10, 2);
    7985       
     86        // Intercept get_post_meta() for virtual (negative-ID) headless posts
     87        add_filter('get_post_metadata', [$this, 'interceptVirtualMeta'], 10, 4);
     88       
    8089        // Register Settings page in admin
    8190        if (is_admin()) {
     
    151160            $autoExporter->register();
    152161        }
     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;
    153226    }
    154227   
     
    421494                ));
    422495            }
     496            $this->registerPostsMeta($file_posts);
    423497            return $file_posts;
    424498        }
     
    451525        }
    452526       
     527        $this->registerPostsMeta($merged);
    453528        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        }
    454592    }
    455593   
  • praison-file-content-git/tags/1.8.1/src/Export/AutoExporter.php

    r3490358 r3494675  
    33
    44if ( ! defined( 'ABSPATH' ) ) exit;
     5
     6use PraisonPress\Index\IndexManager;
    57
    68/**
     
    4042        add_action( 'transition_post_status', [ $this, 'onStatusTransition' ], 20, 3 );
    4143
     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
    4249        // Process the scheduled export
    4350        add_action( 'praison_auto_export_post', [ $this, 'exportAndSync' ] );
     
    138145        }
    139146
     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
    140152        // Commit to Git if content directory is a git repo
    141153        if ( ! $this->isPushEnabled() ) {
     
    147159
    148160    /**
     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    /**
    149207     * Commit the exported file and push to remote
    150208     */
    151     private function commitAndPush( \WP_Post $post ): void {
     209    private function commitAndPush( \WP_Post $post, string $action = 'Auto-export' ): void {
    152210        if ( ! is_dir( PRAISON_CONTENT_DIR . '/.git' ) ) {
    153211            return;
     
    158216
    159217        // 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 );
    161219
    162220        // Check if there are staged changes
     
    170228        // Commit
    171229        $message = sprintf(
    172             'Auto-export: %s "%s" (%s)',
     230            '%s: %s "%s" (%s)',
     231            $action,
    173232            $post->post_type,
    174233            $post->post_title,
  • praison-file-content-git/tags/1.8.1/src/GitHub/SyncManager.php

    r3426687 r3494675  
    289289        }
    290290       
     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       
    291300        // 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        }
    293379    }
    294380}
  • praison-file-content-git/tags/1.8.1/src/Loaders/PostLoader.php

    r3491371 r3494675  
    227227            $post->_praison_custom_fields = $entry['custom_fields'] ?? $entry['custom'] ?? [];
    228228
    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) {
    232252                $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);
    233257            }
    234258           
     
    298322        $post->_praison_custom_fields = $metadata['custom_fields'] ?? [];
    299323       
    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);
    307348        }
    308349       
     
    430471     */
    431472    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.
    432497        $indexFile = $this->postsDir . '/_index.json';
    433 
    434498        if (file_exists($indexFile)) {
    435499            $index = json_decode(file_get_contents($indexFile), true);
     
    441505                    }
    442506                }
    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            }
    454508        }
    455509
  • praison-file-content-git/trunk/AGENTS.md

    r3488026 r3494675  
    1616│   ├── Core/               # Bootstrap, Router
    1717│   ├── 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)
    1821│   ├── Cache/              # CacheManager, SmartCacheInvalidator
    1922│   ├── Parsers/            # FrontMatterParser, MarkdownParser
     
    3336
    3437```php
    35 Version: 1.0.9
     38Version: 1.8.0
    3639```
    3740
     
    4447| Class | Purpose |
    4548|-------|---------|
    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) |
    4854| `CacheManager` | Transient caching (O(1) dir-mtime key) |
    4955| `SmartCacheInvalidator` | Cache clear on PR merge |
  • praison-file-content-git/trunk/docs/index.md

    r3488026 r3494675  
    11# WP Git Posts
    22
    3 Load WordPress content from files without database writes.
     3Load WordPress content from files without database writes. Bidirectional Git sync keeps your content repository and WordPress site perfectly in sync.
    44
    55```mermaid
    66graph 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
    1011   
    1112    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
    1515```
    1616
     
    1818
    19191. **Install** → Upload plugin to WordPress
    20 2. **Create** → Add Markdown/JSON/YAML files
    21 3. **Sync** → Git push to deploy content
     202. **Create** → Add Markdown files to `content/` directory
     213. **Sync** → Changes flow both ways automatically
    2222
    2323No database writes! 🎉
    24 
    25 ## Supported Formats
    26 
    27 | Format | Use Case |
    28 |--------|----------|
    29 | 📝 Markdown | Blog posts, pages |
    30 | 📋 JSON | Structured data |
    31 | ⚙️ YAML | Configuration |
    3224
    3325## Key Features
     
    3527| Feature | Description |
    3628|---------|-------------|
    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
    4144
    4245## Next Steps
     
    4447- [Installation](getting-started/installation.md)
    4548- [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  
    5656    - File-Based Content: features/file-based-content.md
    5757    - 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
    5862    - Collaborative Editing: features/collaborative-editing.md
    5963    - Performance & Caching: features/performance.md
  • praison-file-content-git/trunk/praisonpressgit.php

    r3491385 r3494675  
    33 * Plugin Name: PraisonAI Git Posts
    44 * Description: Load WordPress content from files (Markdown, JSON, YAML) without database writes, with Git-based version control
    5  * Version: 1.7.0
     5 * Version: 1.8.1
    66 * Author: MervinPraison
    77 * Author URI: https://mer.vin
     
    1313
    1414// Define constants
    15 define('PRAISON_VERSION', '1.7.0');
     15define('PRAISON_VERSION', '1.8.1');
    1616define('PRAISON_PLUGIN_DIR', __DIR__);
    1717define('PRAISON_PLUGIN_URL', trailingslashit(plugins_url('', __FILE__)));
  • praison-file-content-git/trunk/readme.txt

    r3491385 r3494675  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 1.7.0
     7Stable tag: 1.8.1
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    228228* HOTFIX: Cache keys now include category_name and tag query vars to prevent taxonomy archive cache collisions
    229229
    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 =
    231251* HOTFIX: CacheManager::getContentKey() - Replaced O(n) glob()+filemtime() with O(1) filemtime($dir) — critical for 100k+ file deployments
    232252* HOTFIX: PostLoader - Added _index.json fast path for single-slug queries (O(1) lookup vs full directory scan)
     
    286306== Upgrade Notice ==
    287307
     308= 1.8.0 =
     309Major release: Bidirectional Git sync, incremental indexing, deletion handling, and get_post_meta() support for headless posts. Recommended update for all users.
     310
    288311= 1.0.9 =
    289312Hotfix: 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  
    2222   
    2323    /**
     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    /**
    2430     * Initialize the plugin
    2531     */
     
    7884        add_filter('posts_pre_query', [$this, 'injectFilePosts'], 10, 2);
    7985       
     86        // Intercept get_post_meta() for virtual (negative-ID) headless posts
     87        add_filter('get_post_metadata', [$this, 'interceptVirtualMeta'], 10, 4);
     88       
    8089        // Register Settings page in admin
    8190        if (is_admin()) {
     
    151160            $autoExporter->register();
    152161        }
     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;
    153226    }
    154227   
     
    421494                ));
    422495            }
     496            $this->registerPostsMeta($file_posts);
    423497            return $file_posts;
    424498        }
     
    451525        }
    452526       
     527        $this->registerPostsMeta($merged);
    453528        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        }
    454592    }
    455593   
  • praison-file-content-git/trunk/src/Export/AutoExporter.php

    r3490358 r3494675  
    33
    44if ( ! defined( 'ABSPATH' ) ) exit;
     5
     6use PraisonPress\Index\IndexManager;
    57
    68/**
     
    4042        add_action( 'transition_post_status', [ $this, 'onStatusTransition' ], 20, 3 );
    4143
     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
    4249        // Process the scheduled export
    4350        add_action( 'praison_auto_export_post', [ $this, 'exportAndSync' ] );
     
    138145        }
    139146
     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
    140152        // Commit to Git if content directory is a git repo
    141153        if ( ! $this->isPushEnabled() ) {
     
    147159
    148160    /**
     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    /**
    149207     * Commit the exported file and push to remote
    150208     */
    151     private function commitAndPush( \WP_Post $post ): void {
     209    private function commitAndPush( \WP_Post $post, string $action = 'Auto-export' ): void {
    152210        if ( ! is_dir( PRAISON_CONTENT_DIR . '/.git' ) ) {
    153211            return;
     
    158216
    159217        // 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 );
    161219
    162220        // Check if there are staged changes
     
    170228        // Commit
    171229        $message = sprintf(
    172             'Auto-export: %s "%s" (%s)',
     230            '%s: %s "%s" (%s)',
     231            $action,
    173232            $post->post_type,
    174233            $post->post_title,
  • praison-file-content-git/trunk/src/GitHub/SyncManager.php

    r3426687 r3494675  
    289289        }
    290290       
     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       
    291300        // 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        }
    293379    }
    294380}
  • praison-file-content-git/trunk/src/Loaders/PostLoader.php

    r3491371 r3494675  
    227227            $post->_praison_custom_fields = $entry['custom_fields'] ?? $entry['custom'] ?? [];
    228228
    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) {
    232252                $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);
    233257            }
    234258           
     
    298322        $post->_praison_custom_fields = $metadata['custom_fields'] ?? [];
    299323       
    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);
    307348        }
    308349       
     
    430471     */
    431472    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.
    432497        $indexFile = $this->postsDir . '/_index.json';
    433 
    434498        if (file_exists($indexFile)) {
    435499            $index = json_decode(file_get_contents($indexFile), true);
     
    441505                    }
    442506                }
    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            }
    454508        }
    455509
Note: See TracChangeset for help on using the changeset viewer.