PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.8
ZIP AI – AI Website Builder & AI Agent v0.0.8
0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / core / snippet-versions.php

snippet-versions.php in ZIP AI – AI Website Builder & AI Agent 0.0.8, at inc/core/snippet-versions.php

920 lines 29.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Snippet Versions — revision history for ZIP AI code snippets.
4 *
5 * Storage layout:
6 * wp-content/zip-ai-snippets/{slug}/.versions/index.json
7 * wp-content/zip-ai-snippets/{slug}/.versions/{id}/snippet.{php,js,css}
8 * wp-content/zip-ai-snippets/{slug}/.versions/{id}/meta.json
9 *
10 * Versions are append-only (newest first in index.json). Manual snapshots are
11 * kept forever; auto-snapshots are GC'd to the last N entries (default 50).
12 *
13 * The version_id is sortable by creation: "{unix_ts}-{6 hex}" — no DB needed.
14 * The user-visible label "v1, v2…" is derived from index position so it never
15 * drifts when GC removes old auto-versions.
16 *
17 * @since 0.0.5
18 * @package zip-ai
19 */
20
21 namespace ZipAI\MCP\Classes\Core;
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit;
25 }
26
27 class Snippet_Versions {
28
29 /**
30 * Default retention: last N auto-versions kept. Manual snapshots and any
31 * versions inside the retention-day window are always preserved.
32 */
33 private const DEFAULT_MAX_AUTO = 50;
34
35 /**
36 * Default retention window in days. null = no day-based cap.
37 */
38 private const DEFAULT_RETENTION_DAYS = null;
39
40 // ── Public API ────────────────────────────────────────────────────────
41
42 /**
43 * Snapshot HEAD files into a new version.
44 *
45 * @param string $slug Validated snippet slug.
46 * @param array<string,mixed> $context Author/reason metadata. Keys:
47 * - author_type: user|agent|system (required)
48 * - author_id: int|null
49 * - author_name: string
50 * - reason: string|null
51 * - manual: bool (default false).
52 *
53 * @return array<string,mixed>|null Created version index entry, or null on failure.
54 */
55 public static function create( $slug, array $context = array() ) {
56 $slug = Snippet_Store::validate_slug( $slug );
57 if ( ! $slug ) {
58 return null;
59 }
60
61 self::ensure_versions_dir( $slug );
62
63 $id = self::generate_id();
64 $dir = self::version_dir( $slug, $id );
65 if ( ! wp_mkdir_p( $dir ) ) {
66 return null;
67 }
68
69 // Version files are byte copies of HEAD, so each version dir needs the
70 // same deny files as the snippet dir — Apache, IIS and dir-listing.
71 Snippet_Store::write_dir_protection( $dir );
72
73 // Copy HEAD files into the version directory.
74 $hashes = array();
75 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
76 $src = Snippet_Store::snippet_file( $slug, $type );
77 if ( file_exists( $src ) && Snippet_Store::is_safe_path( $src ) ) {
78 $dst = $dir . '/snippet.' . $type;
79 copy( $src, $dst ); // phpcs:ignore WordPress.WP.AlternativeFunctions
80 $hash = hash_file( 'sha256', $dst );
81 if ( false !== $hash ) {
82 $hashes[ $type ] = $hash;
83 }
84 }
85 }
86
87 $index = self::read_index( $slug );
88 $parent = ( ! empty( $index ) && is_string( $index[0]['id'] ?? null ) ) ? $index[0]['id'] : null;
89
90 $summary = self::diff_summary( $slug, $parent, $hashes, $dir );
91 $manifest = Snippet_Store::load();
92 $snippet_meta = array();
93 if ( isset( $manifest[ $slug ] ) && is_array( $manifest[ $slug ] ) ) {
94 /**
95 * Narrowed type for `$manifest_entry`.
96 *
97 * @var array<string,mixed> $manifest_entry
98 */
99 $manifest_entry = $manifest[ $slug ];
100 $snippet_meta = self::snapshot_meta( $manifest_entry );
101 }
102
103 $author_type = $context['author_type'] ?? 'user';
104 $author_name = $context['author_name'] ?? '';
105
106 $entry = array(
107 'id' => $id,
108 'parent_id' => $parent,
109 'author_type' => self::sanitize_author_type( is_string( $author_type ) ? $author_type : 'user' ),
110 'author_id' => isset( $context['author_id'] ) && is_scalar( $context['author_id'] ) ? intval( $context['author_id'] ) : null,
111 'author_name' => sanitize_text_field( is_string( $author_name ) ? $author_name : '' ),
112 'reason' => isset( $context['reason'] ) && is_string( $context['reason'] ) ? sanitize_text_field( $context['reason'] ) : null,
113 'manual' => ! empty( $context['manual'] ),
114 'created_at' => gmdate( 'c' ),
115 'hash' => $hashes,
116 'snippet_meta' => $snippet_meta,
117 'diff_summary' => $summary,
118 );
119
120 // Write meta.json (full record per-version) and prepend to index.
121 Snippet_Store::put_file( $dir . '/meta.json', (string) wp_json_encode( $entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) );
122
123 array_unshift( $index, self::index_entry( $entry ) );
124 self::write_index( $slug, $index );
125 self::update_manifest_pointer( $slug, $id, count( $index ) );
126
127 // GC old auto versions after each create.
128 self::gc( $slug );
129
130 return $entry;
131 }
132
133 /**
134 * List versions for a snippet (newest first). Index entries only — no code.
135 *
136 * @param string $slug Validated snippet slug.
137 * @return array<int,array<string,mixed>>
138 */
139 public static function list_versions( $slug ) {
140 $slug = Snippet_Store::validate_slug( $slug );
141 if ( ! $slug ) {
142 return array();
143 }
144
145 $index = self::read_index( $slug );
146 $current = self::current_id( $slug );
147 $total = count( $index );
148
149 // Decorate index with derived label (vN — newest = highest) and current flag.
150 $out = array();
151 foreach ( $index as $i => $entry ) {
152 $entry['label'] = 'v' . ( $total - $i );
153 $entry['current'] = ( $entry['id'] === $current );
154 $out[] = $entry;
155 }
156 return $out;
157 }
158
159 /**
160 * Resolve a user-facing label (`vN`) or short alias to a real version id.
161 *
162 * Callers (the agent, the React UI) sometimes pass the label they see
163 * (`v3`) instead of the underlying ULID-style id. Resolve here so every
164 * version action accepts both forms transparently.
165 *
166 * @param string $slug Validated snippet slug.
167 * @param string $version_or_label Either a raw id or `vN` label.
168 * @return string|null Real id if resolved, null if not found.
169 */
170 public static function resolve_id( $slug, $version_or_label ) {
171 $slug = Snippet_Store::validate_slug( $slug );
172 if ( ! $slug || '' === $version_or_label ) {
173 return null;
174 }
175
176 $index = self::read_index( $slug );
177 $total = count( $index );
178
179 // Direct id match.
180 foreach ( $index as $entry ) {
181 if ( ( $entry['id'] ?? '' ) === $version_or_label ) {
182 return $entry['id'];
183 }
184 }
185
186 // Label match (`vN` — derived from index position, newest first).
187 if ( preg_match( '/^v(\d+)$/i', (string) $version_or_label, $m ) ) {
188 $n = (int) $m[1];
189 if ( $n >= 1 && $n <= $total ) {
190 $idx = $total - $n;
191 $id = $index[ $idx ]['id'] ?? null;
192 return is_string( $id ) ? $id : null;
193 }
194 }
195
196 return null;
197 }
198
199 /**
200 * Read full version (meta + file contents).
201 *
202 * Accepts either a raw id or a `vN` label.
203 *
204 * @param string $slug Validated snippet slug.
205 * @param string $version_id Version id or `vN` label.
206 * @return array<string,mixed>|\WP_Error Result {meta, files} on success, WP_Error
207 * with diagnostic on failure.
208 */
209 public static function get( $slug, $version_id ) {
210 $valid_slug = Snippet_Store::validate_slug( $slug );
211 if ( ! $valid_slug ) {
212 return new \WP_Error( 'invalid_slug', sprintf( 'Invalid snippet slug "%s".', (string) $slug ) );
213 }
214 $slug = $valid_slug;
215
216 // Accept label OR id.
217 $requested = $version_id;
218 $resolved = self::resolve_id( $slug, $version_id );
219 if ( $resolved ) {
220 $version_id = $resolved;
221 }
222
223 $dir = self::version_dir( $slug, $version_id );
224 if ( ! is_dir( $dir ) || ! Snippet_Store::is_safe_path( $dir ) ) {
225 $known = array_map(
226 static function ( array $e ) {
227 $id = $e['id'] ?? '';
228 return is_string( $id ) ? $id : '';
229 },
230 self::read_index( $slug )
231 );
232 return new \WP_Error(
233 'version_not_found',
234 sprintf(
235 'Version "%s" not found for snippet "%s". Call list_versions to map labels (vN) to ids. Known ids: %s',
236 (string) $requested,
237 $slug,
238 empty( $known ) ? '(none)' : implode( ', ', $known )
239 )
240 );
241 }
242
243 $meta_path = $dir . '/meta.json';
244 if ( ! file_exists( $meta_path ) ) {
245 return new \WP_Error(
246 'version_meta_missing',
247 sprintf( 'Version "%s" exists but its meta.json is missing — manifest may be corrupt.', $version_id )
248 );
249 }
250
251 $meta = json_decode( (string) file_get_contents( $meta_path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions
252 $files = array();
253 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
254 $file = $dir . '/snippet.' . $type;
255 if ( file_exists( $file ) ) {
256 $raw = (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
257 $files[ $type ] = 'php' === $type ? Snippet_Store::unwrap_php( $raw ) : $raw;
258 }
259 }
260
261 return array(
262 'meta' => is_array( $meta ) ? $meta : array(),
263 'files' => $files,
264 );
265 }
266
267 /**
268 * Compute add/del summary between two versions, or against HEAD.
269 *
270 * @param string $slug Validated snippet slug.
271 * @param string $a_id Base version id or `vN` label.
272 * @param string|null $b_id Defaults to current_id.
273 * @return array<string,mixed> { add, del, per_file }
274 */
275 public static function diff( $slug, $a_id, $b_id = null ) {
276 $slug = Snippet_Store::validate_slug( $slug );
277 if ( ! $slug ) {
278 return array(
279 'add' => 0,
280 'del' => 0,
281 'per_file' => array(),
282 );
283 }
284
285 // Accept labels (`vN`) for both ids.
286 $resolved_a = self::resolve_id( $slug, $a_id );
287 $a_id = $resolved_a ? $resolved_a : $a_id;
288 if ( null === $b_id ) {
289 $b_id = self::current_id( $slug );
290 } else {
291 $resolved_b = self::resolve_id( $slug, $b_id );
292 $b_id = $resolved_b ? $resolved_b : $b_id;
293 }
294
295 $a_files = self::read_version_files( $slug, $a_id );
296 $b_files = self::read_version_files( $slug, (string) $b_id );
297 $types = array_unique( array_merge( array_keys( $a_files ), array_keys( $b_files ) ) );
298
299 $total_add = 0;
300 $total_del = 0;
301 $per_file = array();
302
303 foreach ( $types as $type ) {
304 $old = isset( $a_files[ $type ] ) ? explode( "\n", $a_files[ $type ] ) : array();
305 $new = isset( $b_files[ $type ] ) ? explode( "\n", $b_files[ $type ] ) : array();
306 $add = count( array_diff( $new, $old ) );
307 $del = count( array_diff( $old, $new ) );
308 $per_file[ $type ] = array(
309 'add' => $add,
310 'del' => $del,
311 );
312 $total_add += $add;
313 $total_del += $del;
314 }
315
316 return array(
317 'add' => $total_add,
318 'del' => $total_del,
319 'per_file' => $per_file,
320 );
321 }
322
323 /**
324 * Restore an older version. Creates a safety snapshot of HEAD first
325 * (so no work is lost), then copies version files to HEAD, then records
326 * a new manual version capturing the restore.
327 *
328 * Accepts either a raw version id or a `vN` label.
329 *
330 * @param string $slug Validated snippet slug.
331 * @param string $version_id Version id or `vN` label to restore.
332 * @param array<string,mixed> $context Author/reason metadata (see create()).
333 * @return array<string,mixed>|\WP_Error|null New restore-version entry, WP_Error with diagnostic, or null on failure.
334 */
335 public static function restore( $slug, $version_id, array $context = array() ) {
336 $valid_slug = Snippet_Store::validate_slug( $slug );
337 if ( ! $valid_slug ) {
338 return new \WP_Error( 'invalid_slug', sprintf( 'Invalid snippet slug "%s".', (string) $slug ) );
339 }
340 $slug = $valid_slug;
341
342 // Resolve label (`vN`) → real id. If we can't resolve, surface the list
343 // of available ids so the caller (or the agent) can self-correct.
344 $requested = $version_id;
345 $resolved = self::resolve_id( $slug, $version_id );
346 if ( $resolved ) {
347 $version_id = $resolved;
348 }
349
350 $src = self::version_dir( $slug, $version_id );
351 if ( ! is_dir( $src ) ) {
352 $known = array_map(
353 static function ( array $e ) {
354 $id = $e['id'] ?? '';
355 return is_string( $id ) ? $id : '';
356 },
357 self::read_index( $slug )
358 );
359 return new \WP_Error(
360 'version_not_found',
361 sprintf(
362 'Version "%s" not found for snippet "%s". Call list_versions first to map the user-facing label (vN) to the real id. Known ids: %s',
363 (string) $requested,
364 $slug,
365 empty( $known ) ? '(none)' : implode( ', ', $known )
366 )
367 );
368 }
369
370 // Snapshot HEAD first if it differs from current pointer — guarantees
371 // the user's working code is preserved as an auto-version before we
372 // overwrite HEAD.
373 $current = self::current_id( $slug );
374 if ( $current && ! self::head_matches_version( $slug, $current ) ) {
375 self::create(
376 $slug,
377 array(
378 'author_type' => $context['author_type'] ?? 'user',
379 'author_id' => $context['author_id'] ?? null,
380 'author_name' => $context['author_name'] ?? '',
381 'reason' => 'Auto-snapshot before restore',
382 'manual' => false,
383 )
384 );
385 }
386
387 // Sync version files back to HEAD.
388 $snippet_dir = Snippet_Store::snippet_dir( $slug );
389 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
390 $from = $src . '/snippet.' . $type;
391 $to = $snippet_dir . '/snippet.' . $type;
392 if ( file_exists( $from ) && Snippet_Store::is_safe_path( $to ) ) {
393 copy( $from, $to ); // phpcs:ignore WordPress.WP.AlternativeFunctions
394 Snippet_Store::invalidate_opcache( $to );
395 } elseif ( file_exists( $to ) && Snippet_Store::is_safe_path( $to ) ) {
396 wp_delete_file( $to );
397 Snippet_Store::invalidate_opcache( $to );
398 }
399 }
400
401 // Update manifest hashes from the new HEAD and restore versioned metadata
402 // when available. Older versions created before metadata snapshots only
403 // restore files, then keep the current manifest metadata.
404 $manifest = Snippet_Store::load();
405 if ( isset( $manifest[ $slug ] ) && is_array( $manifest[ $slug ] ) ) {
406 $version_meta = self::read_version_meta( $slug, $version_id );
407 if ( ! empty( $version_meta['snippet_meta'] ) && is_array( $version_meta['snippet_meta'] ) ) {
408 foreach ( $version_meta['snippet_meta'] as $key => $value ) {
409 $manifest[ $slug ][ $key ] = $value;
410 }
411 }
412
413 $files = array();
414 $manifest[ $slug ]['file_hashes'] = array();
415 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
416 $file = $snippet_dir . '/snippet.' . $type;
417 if ( file_exists( $file ) ) {
418 $files[] = $type;
419 $manifest[ $slug ]['file_hashes'][ $type ] = hash_file( 'sha256', $file );
420 }
421 }
422 $manifest[ $slug ]['files'] = $files;
423 Snippet_Store::save( $manifest );
424 }
425
426 // Find label of the version being restored for the reason text.
427 $list = self::list_versions( $slug );
428 $label = '';
429 foreach ( $list as $v ) {
430 if ( $v['id'] === $version_id ) {
431 $label = is_string( $v['label'] ?? null ) ? $v['label'] : '';
432 break;
433 }
434 }
435
436 // Record the restore as a new manual version.
437 return self::create(
438 $slug,
439 array(
440 'author_type' => $context['author_type'] ?? 'user',
441 'author_id' => $context['author_id'] ?? null,
442 'author_name' => $context['author_name'] ?? '',
443 'reason' => sprintf( 'Restored from %s', $label ? $label : $version_id ),
444 'manual' => true,
445 )
446 );
447 }
448
449 /**
450 * Delete a manual version. Auto-versions are not user-deletable.
451 *
452 * @param string $slug Validated snippet slug.
453 * @param string $version_id Version id or `vN` label to delete.
454 * @return bool|\WP_Error
455 */
456 public static function delete( $slug, $version_id ) {
457 $valid_slug = Snippet_Store::validate_slug( $slug );
458 if ( ! $valid_slug ) {
459 return new \WP_Error( 'invalid_slug', sprintf( 'Invalid snippet slug "%s".', (string) $slug ) );
460 }
461 $slug = $valid_slug;
462
463 // Accept label (`vN`) OR raw id.
464 $requested = $version_id;
465 $resolved = self::resolve_id( $slug, $version_id );
466 if ( $resolved ) {
467 $version_id = $resolved;
468 }
469
470 $index = self::read_index( $slug );
471 $entry = null;
472 foreach ( $index as $e ) {
473 if ( $e['id'] === $version_id ) {
474 $entry = $e;
475 break;
476 }
477 }
478 if ( ! $entry ) {
479 $known = array_map(
480 static function ( array $e ) {
481 $id = $e['id'] ?? '';
482 return is_string( $id ) ? $id : '';
483 },
484 $index
485 );
486 return new \WP_Error(
487 'version_not_found',
488 sprintf( 'Version "%s" not found. Known ids: %s', (string) $requested, empty( $known ) ? '(none)' : implode( ', ', $known ) )
489 );
490 }
491
492 // The only hard constraint: cannot delete the version HEAD currently
493 // points to (would orphan the snippet's "now" state). Auto-versions
494 // are otherwise user-deletable — same surface as manual snapshots.
495 if ( self::current_id( $slug ) === $version_id ) {
496 return new \WP_Error(
497 'version_is_current',
498 'Cannot delete the version that HEAD currently points to. Restore a different version first, then delete this one.'
499 );
500 }
501
502 self::remove_version_dir( $slug, $version_id );
503 $index = array_values(
504 array_filter(
505 $index,
506 static function ( $e ) use ( $version_id ) {
507 return $e['id'] !== $version_id;
508 }
509 )
510 );
511 self::write_index( $slug, $index );
512 self::update_manifest_pointer( $slug, self::current_id( $slug ), count( $index ) );
513
514 return true;
515 }
516
517 /**
518 * Garbage-collect old auto versions.
519 *
520 * Keeps: all manual versions, all versions inside the retention-day window,
521 * and the latest N auto versions (default 50). Filters:
522 * zip_ai_snippets_max_versions (int)
523 * zip_ai_snippets_version_retention_days (int|null)
524 *
525 * @param string $slug Validated snippet slug.
526 * @return void
527 */
528 public static function gc( $slug ) {
529 $slug = Snippet_Store::validate_slug( $slug );
530 if ( ! $slug ) {
531 return;
532 }
533
534 $max_filter = apply_filters( 'zip_ai_snippets_max_versions', self::DEFAULT_MAX_AUTO );
535 $max_auto = max( 1, is_scalar( $max_filter ) ? intval( $max_filter ) : self::DEFAULT_MAX_AUTO );
536 $retention_days = apply_filters( 'zip_ai_snippets_version_retention_days', self::DEFAULT_RETENTION_DAYS );
537 $cutoff = ( ! is_scalar( $retention_days ) ) ? null : ( time() - ( intval( $retention_days ) * DAY_IN_SECONDS ) );
538 $current = self::current_id( $slug );
539
540 $index = self::read_index( $slug );
541 $auto_seen = 0;
542 $keep = array();
543 $removed = false;
544
545 foreach ( $index as $entry ) {
546 $is_manual = ! empty( $entry['manual'] );
547 $ts = is_string( $entry['created_at'] ?? null ) ? strtotime( $entry['created_at'] ) : 0;
548 $within = ( null !== $cutoff && $ts >= $cutoff );
549 $is_current = $entry['id'] === $current;
550
551 if ( $is_manual || $within || $is_current ) {
552 $keep[] = $entry;
553 if ( ! $is_manual ) {
554 ++$auto_seen;
555 }
556 continue;
557 }
558
559 if ( $auto_seen < $max_auto ) {
560 $keep[] = $entry;
561 ++$auto_seen;
562 continue;
563 }
564
565 // Drop this auto version.
566 $entry_id = $entry['id'] ?? '';
567 self::remove_version_dir( $slug, is_string( $entry_id ) ? $entry_id : '' );
568 $removed = true;
569 }
570
571 if ( $removed ) {
572 self::write_index( $slug, $keep );
573 self::update_manifest_pointer( $slug, $current, count( $keep ) );
574 }
575 }
576
577 // ── Internal ──────────────────────────────────────────────────────────
578
579 /**
580 * Read the index.json (newest first). Empty array if missing.
581 *
582 * @param string $slug Validated snippet slug.
583 * @return array<int,array<string,mixed>> Index entries, or empty array.
584 */
585 private static function read_index( $slug ) {
586 $path = self::index_path( $slug );
587 if ( ! file_exists( $path ) ) {
588 return array();
589 }
590 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
591 $arr = json_decode( (string) $raw, true );
592 if ( ! is_array( $arr ) ) {
593 return array();
594 }
595 /**
596 * Narrowed type for `$arr`.
597 *
598 * @var array<int,array<string,mixed>> $arr
599 */
600 return $arr;
601 }
602
603 /**
604 * Write the version index.json for a snippet.
605 *
606 * @param string $slug Validated snippet slug.
607 * @param array<int,array<string,mixed>> $index Index entries to persist.
608 * @return void
609 */
610 private static function write_index( $slug, array $index ) {
611 $path = self::index_path( $slug );
612 self::ensure_versions_dir( $slug );
613 Snippet_Store::put_file( $path, (string) wp_json_encode( array_values( $index ), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) );
614 }
615
616 /**
617 * Reduce a full version entry to a lean index entry.
618 *
619 * @param array<string,mixed> $entry Full version entry.
620 * @return array<string,mixed> Lean index entry.
621 */
622 private static function index_entry( array $entry ) {
623 // Keep index entries lean — full meta lives in {id}/meta.json.
624 return array(
625 'id' => $entry['id'],
626 'parent_id' => $entry['parent_id'] ?? null,
627 'author_type' => $entry['author_type'] ?? 'user',
628 'author_name' => $entry['author_name'] ?? '',
629 'reason' => $entry['reason'] ?? null,
630 'manual' => ! empty( $entry['manual'] ),
631 'created_at' => $entry['created_at'] ?? gmdate( 'c' ),
632 'hash' => $entry['hash'] ?? array(),
633 'diff_summary' => $entry['diff_summary'] ?? array(
634 'add' => 0,
635 'del' => 0,
636 'per_file' => array(),
637 ),
638 );
639 }
640
641 /**
642 * Capture the snippet metadata to store alongside a version.
643 *
644 * @param array<string,mixed> $meta Snippet manifest entry.
645 * @return array<string,mixed> Snapshot of key metadata fields (title, description, execution, conditions).
646 */
647 private static function snapshot_meta( array $meta ) {
648 $normalized = Snippet_Store::normalize_snippet( $meta );
649 return array(
650 'title' => is_string( $normalized['title'] ?? null ) ? $normalized['title'] : '',
651 'description' => is_string( $normalized['description'] ?? null ) ? $normalized['description'] : '',
652 'execution' => is_array( $normalized['execution'] ?? null ) ? $normalized['execution'] : array(),
653 'conditions' => is_array( $normalized['conditions'] ?? null ) ? $normalized['conditions'] : array(),
654 );
655 }
656
657 /**
658 * Read a version's meta.json.
659 *
660 * @param string $slug Validated snippet slug.
661 * @param string $version_id Version id.
662 * @return array<string,mixed> Version meta, or empty array if missing.
663 */
664 private static function read_version_meta( $slug, $version_id ) {
665 $path = self::version_dir( $slug, $version_id ) . '/meta.json';
666 if ( ! file_exists( $path ) ) {
667 return array();
668 }
669 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
670 $meta = json_decode( (string) $raw, true );
671 if ( ! is_array( $meta ) ) {
672 return array();
673 }
674 /**
675 * Narrowed type for `$meta`.
676 *
677 * @var array<string,mixed> $meta
678 */
679 return $meta;
680 }
681
682 /**
683 * Ensure the .versions dir exists with HTTP-deny protection files.
684 *
685 * @param string $slug Validated snippet slug.
686 * @return void
687 */
688 private static function ensure_versions_dir( $slug ) {
689 $dir = self::versions_dir( $slug );
690 if ( ! is_dir( $dir ) ) {
691 wp_mkdir_p( $dir );
692 }
693
694 // Defense in depth: even though the parent zip-ai-snippets/ already
695 // denies HTTP access, drop the same guards inside .versions/. Routed
696 // through the shared helper so the Apache, IIS and dir-listing denies
697 // stay in one place instead of drifting per directory.
698 Snippet_Store::write_dir_protection( $dir );
699 }
700
701 /**
702 * Absolute path to a snippet's .versions directory.
703 *
704 * @param string $slug Validated snippet slug.
705 * @return string
706 */
707 private static function versions_dir( $slug ) {
708 return Snippet_Store::snippet_dir( $slug ) . '/.versions';
709 }
710
711 /**
712 * Absolute path to a snippet's version index.json.
713 *
714 * @param string $slug Validated snippet slug.
715 * @return string
716 */
717 private static function index_path( $slug ) {
718 return self::versions_dir( $slug ) . '/index.json';
719 }
720
721 /**
722 * Absolute path to a single version directory (path-traversal safe).
723 *
724 * @param string $slug Validated snippet slug.
725 * @param string $id Version id.
726 * @return string
727 */
728 private static function version_dir( $slug, $id ) {
729 // Validate id format defensively to keep path traversal impossible.
730 if ( ! preg_match( '/^[a-zA-Z0-9_-]+$/', (string) $id ) ) {
731 return self::versions_dir( $slug ) . '/__invalid__';
732 }
733 return self::versions_dir( $slug ) . '/' . $id;
734 }
735
736 /**
737 * Generate a sortable version id ("{unix_ts}-{6 hex}").
738 *
739 * @return string
740 */
741 private static function generate_id() {
742 return time() . '-' . substr( bin2hex( random_bytes( 3 ) ), 0, 6 );
743 }
744
745 /**
746 * Clamp an author type to the allowed set (user, agent, system).
747 *
748 * @param string $type Raw author type.
749 * @return string
750 */
751 private static function sanitize_author_type( $type ) {
752 $valid = array( 'user', 'agent', 'system' );
753 return in_array( $type, $valid, true ) ? $type : 'user';
754 }
755
756 /**
757 * Read a version's snippet file contents keyed by type.
758 *
759 * @param string $slug Validated snippet slug.
760 * @param string $id Version id.
761 * @return array<string,string> File contents keyed by file type.
762 */
763 private static function read_version_files( $slug, $id ) {
764 $dir = self::version_dir( $slug, $id );
765 if ( ! is_dir( $dir ) ) {
766 return array();
767 }
768 $out = array();
769 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
770 $file = $dir . '/snippet.' . $type;
771 if ( file_exists( $file ) ) {
772 $content = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
773 if ( false !== $content ) {
774 $out[ $type ] = 'php' === $type ? Snippet_Store::unwrap_php( $content ) : $content;
775 }
776 }
777 }
778 return $out;
779 }
780
781 /**
782 * Compute diff summary against the parent version (just-written files vs
783 * the previous version's files). Set-based add/del — not git-accurate but
784 * fast and sufficient for the +N -N badge.
785 *
786 * @param string $slug Validated snippet slug.
787 * @param string|null $parent_id Parent version id, or null if none.
788 * @param array<string,string> $new_hashes SHA-256 hashes of the new files, keyed by type.
789 * @param string $new_dir Directory holding the new version's files.
790 * @return array<string,mixed> Diff summary ({ add, del, per_file }).
791 */
792 private static function diff_summary( $slug, $parent_id, array $new_hashes, $new_dir ) {
793 $summary = array(
794 'add' => 0,
795 'del' => 0,
796 'per_file' => array(),
797 );
798
799 $old_files = $parent_id ? self::read_version_files( $slug, $parent_id ) : array();
800 $new_files = array();
801 foreach ( $new_hashes as $type => $_hash ) {
802 $file = $new_dir . '/snippet.' . $type;
803 if ( file_exists( $file ) ) {
804 $raw = (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
805 $new_files[ $type ] = 'php' === $type ? Snippet_Store::unwrap_php( $raw ) : $raw;
806 }
807 }
808
809 $types = array_unique( array_merge( array_keys( $old_files ), array_keys( $new_files ) ) );
810 foreach ( $types as $type ) {
811 $old = isset( $old_files[ $type ] ) ? explode( "\n", $old_files[ $type ] ) : array();
812 $new = isset( $new_files[ $type ] ) ? explode( "\n", $new_files[ $type ] ) : array();
813 $add = count( array_diff( $new, $old ) );
814 $del = count( array_diff( $old, $new ) );
815 $summary['per_file'][ $type ] = array(
816 'add' => $add,
817 'del' => $del,
818 );
819 $summary['add'] += $add;
820 $summary['del'] += $del;
821 }
822
823 return $summary;
824 }
825
826 /**
827 * The manifest's current HEAD version id for a snippet.
828 *
829 * @param string $slug Validated snippet slug.
830 * @return string|null
831 */
832 private static function current_id( $slug ) {
833 $manifest = Snippet_Store::load();
834 $entry = $manifest[ $slug ] ?? null;
835 if ( ! is_array( $entry ) ) {
836 return null;
837 }
838 $id = $entry['current_version_id'] ?? null;
839 return is_string( $id ) ? $id : null;
840 }
841
842 /**
843 * Whether HEAD files hash-match a given version's files.
844 *
845 * @param string $slug Validated snippet slug.
846 * @param string $version_id Version id to compare against.
847 * @return bool
848 */
849 private static function head_matches_version( $slug, $version_id ) {
850 $head_files = array();
851 $snippet_dir = Snippet_Store::snippet_dir( $slug );
852 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
853 $file = $snippet_dir . '/snippet.' . $type;
854 if ( file_exists( $file ) ) {
855 $head_files[ $type ] = hash_file( 'sha256', $file );
856 }
857 }
858
859 $ver_files = array();
860 $ver_dir = self::version_dir( $slug, $version_id );
861 foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
862 $file = $ver_dir . '/snippet.' . $type;
863 if ( file_exists( $file ) ) {
864 $ver_files[ $type ] = hash_file( 'sha256', $file );
865 }
866 }
867
868 ksort( $head_files );
869 ksort( $ver_files );
870 return $head_files === $ver_files;
871 }
872
873 /**
874 * Update the manifest's version pointer, count, and timestamp.
875 *
876 * @param string $slug Validated snippet slug.
877 * @param string|null $version_id New current version id.
878 * @param int $count Total version count.
879 * @return void
880 */
881 private static function update_manifest_pointer( $slug, $version_id, $count ) {
882 $manifest = Snippet_Store::load();
883 if ( ! isset( $manifest[ $slug ] ) || ! is_array( $manifest[ $slug ] ) ) {
884 return;
885 }
886 $manifest[ $slug ]['current_version_id'] = $version_id;
887 $manifest[ $slug ]['versions_count'] = (int) $count;
888 $manifest[ $slug ]['last_version_at'] = gmdate( 'c' );
889 Snippet_Store::save( $manifest );
890 }
891
892 /**
893 * Delete a single version directory and its files.
894 *
895 * @param string $slug Validated snippet slug.
896 * @param string $id Version id.
897 * @return void
898 */
899 private static function remove_version_dir( $slug, $id ) {
900 $dir = self::version_dir( $slug, $id );
901 if ( ! is_dir( $dir ) || ! Snippet_Store::is_safe_path( $dir ) ) {
902 return;
903 }
904 // scandir + filter is portable; glob(GLOB_BRACE) is GNU-only and silently no-ops on some hosts.
905 $entries = @scandir( $dir );
906 if ( is_array( $entries ) ) {
907 foreach ( $entries as $entry ) {
908 if ( '.' === $entry || '..' === $entry ) {
909 continue;
910 }
911 $path = $dir . '/' . $entry;
912 if ( is_file( $path ) ) {
913 wp_delete_file( $path );
914 }
915 }
916 }
917 Snippet_Store::delete_dir( $dir );
918 }
919 }
920