PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / QuickEdit / Controllers / WPNavigationController.php

WPNavigationController.php in Extendify 3.1.5, at app/QuickEdit/Controllers/WPNavigationController.php

184 lines 6.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\QuickEdit\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 use Extendify\Config;
8 use Extendify\QuickEdit\Schemas\Registry;
9 use Extendify\QuickEdit\Services\BlockFingerprint;
10
11 // Ref-based navigations (core/navigation with `ref`) keep their items in
12 // a separate wp_navigation post that SaveController's findBlock walk
13 // can't reach. This endpoint takes a (navPostId, itemIndex) addressed
14 // by NavRefTagger and applies the schema patch directly.
15 class WPNavigationController
16 {
17 public static function init()
18 {
19 add_action('rest_api_init', [self::class, 'registerRoutes']);
20 }
21
22 public static function registerRoutes()
23 {
24 register_rest_route('extendify/v1', '/quick-edit/wp-navigation', [
25 'methods' => 'POST',
26 'permission_callback' => [self::class, 'permissionCallback'],
27 'callback' => [self::class, 'handle'],
28 ]);
29 }
30
31 public static function permissionCallback(): bool
32 {
33 return current_user_can(Config::$requiredCapability);
34 }
35
36 public static function handle(\WP_REST_Request $req)
37 {
38 $body = $req->get_json_params() ?: [];
39 $navPostId = (int) ($body['navPostId'] ?? 0);
40 $itemIndex = (int) ($body['itemIndex'] ?? -1);
41 $blockType = (string) ($body['blockType'] ?? '');
42 $patches = is_array($body['patches'] ?? null) ? $body['patches'] : [];
43
44 if ($navPostId <= 0 || $itemIndex < 0 || $blockType === '' || !$patches) {
45 return new \WP_REST_Response(
46 ['error' => 'navPostId, itemIndex, blockType, patches required'],
47 400
48 );
49 }
50 if (!in_array($blockType, ['core/navigation-link', 'core/navigation-submenu'], true)) {
51 return new \WP_REST_Response(
52 ['error' => 'unsupported blockType for wp-navigation save'],
53 400
54 );
55 }
56
57 $navPost = get_post($navPostId);
58 if (!$navPost || $navPost->post_type !== 'wp_navigation') {
59 return new \WP_REST_Response(['error' => 'wp_navigation post not found'], 404);
60 }
61 if (!current_user_can('edit_post', $navPostId)) {
62 return new \WP_REST_Response(['error' => 'forbidden for this navigation'], 403);
63 }
64
65 $schema = Registry::get($blockType);
66 if (!$schema) {
67 return new \WP_REST_Response(
68 ['error' => 'no schema for blockType', 'blockType' => $blockType],
69 400
70 );
71 }
72
73 $blocks = parse_blocks($navPost->post_content);
74
75 $found = self::findNthNavItem($blocks, $itemIndex);
76 if ($found === null) {
77 return new \WP_REST_Response(
78 ['error' => 'nav item not found at index', 'itemIndex' => $itemIndex],
79 404
80 );
81 }
82
83 $target = $found['block'];
84 if (($target['blockName'] ?? '') !== $blockType) {
85 return new \WP_REST_Response([
86 'error' => 'block type mismatch',
87 'expected' => $blockType,
88 'actual' => $target['blockName'] ?? null,
89 ], 409);
90 }
91
92 // Positional itemIndex can address a different same-type item when the
93 // render-time and parse-time orderings of a nested menu diverge; refuse
94 // when the resolved item doesn't carry the clicked item's fingerprint.
95 $fingerprint = is_array($body['fingerprint'] ?? null) ? $body['fingerprint'] : [];
96 if ($fingerprint && !BlockFingerprint::matches($target, $fingerprint)) {
97 return new \WP_REST_Response([
98 'error' => 'block fingerprint mismatch',
99 'itemIndex' => $itemIndex,
100 ], 409);
101 }
102
103 foreach ($patches as $patch) {
104 if (!is_array($patch)) {
105 continue;
106 }
107 $fieldKey = (string) ($patch['fieldKey'] ?? '');
108 if ($fieldKey === '') {
109 continue;
110 }
111 $target = $schema->apply($target, $fieldKey, $patch['value'] ?? null);
112 }
113
114 $blocks = self::replaceAtPath($blocks, $found['path'], $target);
115 $newContent = serialize_blocks($blocks);
116
117 $update = wp_update_post([
118 'ID' => $navPostId,
119 'post_content' => wp_slash($newContent),
120 ], true);
121 if (is_wp_error($update)) {
122 return new \WP_REST_Response(['error' => $update->get_error_message()], 500);
123 }
124
125 return new \WP_REST_Response(['ok' => true]);
126 }
127
128 // Pre-order, counting only navigation-link/-submenu so the index
129 // matches NavRefTagger's render-time counter.
130 private static function findNthNavItem(array $blocks, int $targetIndex)
131 {
132 $counter = 0;
133 $found = null;
134
135 $walk = function (array $list, array $pathSoFar) use (&$walk, &$counter, &$found, $targetIndex) {
136 foreach ($list as $i => $block) {
137 $name = $block['blockName'] ?? '';
138 $isItem = $name === 'core/navigation-link' || $name === 'core/navigation-submenu';
139 if ($isItem) {
140 if ($counter === $targetIndex) {
141 $found = ['block' => $block, 'path' => array_merge($pathSoFar, [$i])];
142 return;
143 }
144 $counter++;
145 }
146 if (!empty($block['innerBlocks'])) {
147 $walk($block['innerBlocks'], array_merge($pathSoFar, [$i, 'innerBlocks']));
148 if ($found !== null) {
149 return;
150 }
151 }
152 }
153 };
154 $walk($blocks, []);
155
156 return $found;
157 }
158
159 // Mirrors SaveController::replaceBlockAtPath.
160 private static function replaceAtPath(array $blocks, array $path, array $newBlock): array
161 {
162 if (empty($path)) {
163 return $blocks;
164 }
165 $head = $path[0];
166 $rest = array_slice($path, 1);
167 if (!is_int($head) || !isset($blocks[$head])) {
168 return $blocks;
169 }
170 if (empty($rest)) {
171 $blocks[$head] = $newBlock;
172 return $blocks;
173 }
174 if ($rest[0] === 'innerBlocks') {
175 $blocks[$head]['innerBlocks'] = self::replaceAtPath(
176 $blocks[$head]['innerBlocks'] ?? [],
177 array_slice($rest, 1),
178 $newBlock
179 );
180 }
181 return $blocks;
182 }
183 }
184