PluginProbe
Gutenberg / 23.6.0
Gutenberg v23.6.0
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / knowledge / knowledge.php

knowledge.php in Gutenberg 23.6.0, at lib/experimental/knowledge/knowledge.php

458 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Knowledge public API.
4 *
5 * @package gutenberg
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 if ( ! function_exists( 'wp_knowledge_types' ) ) {
13 /**
14 * Returns the registered knowledge types keyed by slug.
15 *
16 * Plugins can register their own types via the `wp_knowledge_types` filter.
17 *
18 * @return array {
19 * Slug-keyed map of knowledge types.
20 *
21 * @type array ...$0 {
22 * Data for a single knowledge type.
23 *
24 * @type string $title The human-readable label for the type.
25 * }
26 * }
27 * @phpstan-return array<string, array{title: string}>
28 */
29 function wp_knowledge_types(): array {
30 /**
31 * Filters the knowledge types available on this site.
32 *
33 * @param array $types {
34 * Slug-keyed map of knowledge types.
35 *
36 * @type array ...$0 {
37 * Data for a single knowledge type.
38 *
39 * @type string $title The human-readable label for the type.
40 * }
41 * }
42 * @phpstan-param array<string, array{title: string}> $types
43 */
44 return apply_filters(
45 'wp_knowledge_types',
46 array(
47 'guideline' => array(
48 'title' => _x( 'Guideline', 'knowledge type', 'gutenberg' ),
49 ),
50 'memory' => array(
51 'title' => _x( 'Memory', 'knowledge type', 'gutenberg' ),
52 ),
53 'note' => array(
54 'title' => _x( 'Note', 'knowledge type', 'gutenberg' ),
55 ),
56 )
57 );
58 }
59 }
60
61 if ( ! function_exists( 'wp_guideline_scopes' ) ) {
62 /**
63 * Returns the registered guideline scopes keyed by slug.
64 *
65 * Scopes are the sections shown on the Settings → Guidelines page. Each
66 * scope is backed by at most one `guideline`-typed `wp_knowledge` row whose
67 * slug is `guideline-{scope}`. Plugins can register their own scopes via the
68 * `wp_guideline_scopes` filter and the Settings page grows a section
69 * automatically. The registry carries identity and presentation only; rows
70 * are created on first save.
71 *
72 * The `blocks` scope is the one exception: it has no single `guideline-blocks`
73 * row. Its section lists per-block guidelines stored as `guideline-block-*`
74 * rows. Removing it from this registry (via the filter) hides that section on
75 * the Settings page.
76 *
77 * @return array {
78 * Slug-keyed map of guideline scopes.
79 *
80 * @type array ...$0 {
81 * Data for a single scope.
82 *
83 * @type string $title Human-readable section title.
84 * @type string $description Human-readable section description.
85 * @type int $order Sort order on the Settings page.
86 * }
87 * }
88 * @phpstan-return array<string, array{title: string, description: string, order: int}>
89 */
90 function wp_guideline_scopes(): array {
91 /**
92 * Filters the guideline scopes available on this site.
93 *
94 * @param array $scopes Slug-keyed map of guideline scopes.
95 */
96 return apply_filters(
97 'wp_guideline_scopes',
98 array(
99 'site' => array(
100 'title' => __( 'Site', 'gutenberg' ),
101 'description' => __( "Describe your site's purpose, goals, and primary audience.", 'gutenberg' ),
102 'order' => 10,
103 ),
104 'copy' => array(
105 'title' => __( 'Copy', 'gutenberg' ),
106 'description' => __( 'Set your writing standards for tone, voice, style, and formatting.', 'gutenberg' ),
107 'order' => 20,
108 ),
109 'images' => array(
110 'title' => __( 'Images', 'gutenberg' ),
111 'description' => __( 'Outline your style, dimensions, formats, mood and aesthetic preferences.', 'gutenberg' ),
112 'order' => 30,
113 ),
114 'blocks' => array(
115 'title' => __( 'Blocks', 'gutenberg' ),
116 'description' => __( 'Create tailored guidelines for specific block types.', 'gutenberg' ),
117 'order' => 40,
118 ),
119 'additional' => array(
120 'title' => __( 'Additional', 'gutenberg' ),
121 'description' => __( 'Add additional guidelines.', 'gutenberg' ),
122 'order' => 50,
123 ),
124 )
125 );
126 }
127 }
128
129 if ( ! function_exists( 'wp_guideline_max_length' ) ) {
130 /**
131 * Returns the maximum length, in characters, of a guideline row's content.
132 *
133 * @return int Maximum number of characters allowed in guideline content.
134 */
135 function wp_guideline_max_length(): int {
136 /**
137 * Filters the maximum length, in characters, of a guideline row's content.
138 *
139 * @param int $max_length Maximum number of characters. Default 5000.
140 */
141 return (int) apply_filters( 'wp_guideline_max_length', 5000 );
142 }
143 }
144
145 if ( ! function_exists( 'wp_knowledge_get_or_create_type_term' ) ) {
146 /**
147 * Resolve a `wp_knowledge_type` term by slug, creating it lazily.
148 *
149 * Created term names are written once in the site locale (via
150 * `wp_knowledge_maybe_map_term_label`) so they don't vary with whoever
151 * triggered creation.
152 *
153 * @access private
154 *
155 * @param string $slug Term slug.
156 * @return int|null Term ID, or null on failure.
157 */
158 function wp_knowledge_get_or_create_type_term( string $slug ): ?int {
159 $term = term_exists( $slug, 'wp_knowledge_type' );
160 if ( $term ) {
161 return (int) $term['term_id'];
162 }
163
164 $switched = switch_to_locale( get_locale() );
165 $term = wp_insert_term( $slug, 'wp_knowledge_type' );
166 if ( $switched ) {
167 restore_previous_locale();
168 }
169
170 if ( is_wp_error( $term ) ) {
171 return null;
172 }
173
174 return (int) $term['term_id'];
175 }
176 }
177
178 if ( ! function_exists( 'wp_knowledge_ensure_default_type_term' ) ) {
179 /**
180 * Hook callback for the `save_post_wp_knowledge` action that assigns the
181 * knowledge type term.
182 *
183 * Rows whose slug begins with `guideline-` are forced onto the `guideline`
184 * type (the reservation rule: the prefix is reserved for guideline-typed
185 * rows). Any other row without a type term falls back to `note`.
186 *
187 * @access private
188 *
189 * @param int $post_id Saved post ID.
190 */
191 function wp_knowledge_ensure_default_type_term( int $post_id ): void {
192 if ( wp_is_post_revision( $post_id ) ) {
193 return;
194 }
195
196 $post = get_post( $post_id );
197 if ( ! $post instanceof WP_Post ) {
198 return;
199 }
200
201 if ( str_starts_with( $post->post_name, 'guideline-' ) ) {
202 $term_id = wp_knowledge_get_or_create_type_term( 'guideline' );
203 if ( null !== $term_id ) {
204 wp_set_object_terms( $post_id, $term_id, 'wp_knowledge_type' );
205 }
206 return;
207 }
208
209 $terms = get_the_terms( $post_id, 'wp_knowledge_type' );
210 if ( is_wp_error( $terms ) || ! empty( $terms ) ) {
211 return;
212 }
213
214 $term_id = wp_knowledge_get_or_create_type_term( 'note' );
215 if ( null !== $term_id ) {
216 wp_set_object_terms( $post_id, $term_id, 'wp_knowledge_type' );
217 }
218 }
219 }
220
221 if ( ! function_exists( 'wp_maybe_grant_knowledge_caps' ) ) {
222 /**
223 * Filters the user capabilities to grant the `wp_knowledge` post type capabilities as necessary.
224 *
225 * The `wp_knowledge` post type uses a `knowledge`-prefixed capability set that
226 * is granted dynamically rather than stored on roles. Administrators (users
227 * with `manage_options`) receive every knowledge capability. Contributors,
228 * authors, and editors (users with `edit_posts`) may list and create knowledge
229 * rows and fully manage their own private rows. Publishing knowledge and acting
230 * on other users' rows is reserved for administrators. Subscribers receive
231 * nothing and are stopped at the post-type door by the `read_knowledge_items` mapping.
232 *
233 * @param bool[] $allcaps An array of all the user's capabilities.
234 * @param string[] $caps Required primitive capabilities for the requested capability.
235 * @param array $args Arguments that accompany the requested capability check.
236 * @param WP_User $user The user object.
237 * @return bool[] Filtered array of the user's capabilities.
238 */
239 function wp_maybe_grant_knowledge_caps( $allcaps, $caps, $args, $user ) {
240 if ( ! empty( $allcaps['manage_options'] ) ) {
241 $allcaps['read_knowledge_items'] = true;
242 $allcaps['edit_knowledge_items'] = true;
243 $allcaps['edit_others_knowledge_items'] = true;
244 $allcaps['edit_published_knowledge_items'] = true;
245 $allcaps['edit_private_knowledge_items'] = true;
246 $allcaps['publish_knowledge_items'] = true;
247 $allcaps['delete_knowledge_items'] = true;
248 $allcaps['delete_others_knowledge_items'] = true;
249 $allcaps['delete_published_knowledge_items'] = true;
250 $allcaps['delete_private_knowledge_items'] = true;
251 $allcaps['read_private_knowledge_items'] = true;
252 return $allcaps;
253 }
254
255 if ( empty( $allcaps['edit_posts'] ) ) {
256 return $allcaps;
257 }
258
259 // Ambient floor for Contributor+: `read_knowledge_items` clears the
260 // post-type read check; `edit_knowledge_items` clears the create and
261 // ownership checks that don't pass a post ID. Per-post primitives
262 // are granted only in the per-post branch below.
263 $allcaps['read_knowledge_items'] = true;
264 $allcaps['edit_knowledge_items'] = true;
265
266 if ( ! isset( $args[0], $args[2] ) ) {
267 return $allcaps;
268 }
269
270 if ( ! in_array( $args[0], array( 'edit_post', 'delete_post', 'read_post' ), true ) ) {
271 return $allcaps;
272 }
273
274 $post = get_post( $args[2] );
275 if (
276 ! $post instanceof WP_Post ||
277 'wp_knowledge' !== $post->post_type ||
278 (int) $post->post_author !== (int) $user->ID
279 ) {
280 return $allcaps;
281 }
282
283 /*
284 * A trashed row keeps its pre-trash status in `_wp_trash_meta_status`.
285 * Resolve that effective status so the author keeps the ability to
286 * restore or permanently delete their own row once it is in the trash. A
287 * row trashed from a non-private status (only reachable for
288 * administrators) still falls outside the grant.
289 */
290 $status = $post->post_status;
291 if ( 'trash' === $status ) {
292 $status = get_post_meta( $post->ID, '_wp_trash_meta_status', true );
293 }
294
295 if ( 'private' !== $status ) {
296 return $allcaps;
297 }
298
299 $allcaps['edit_private_knowledge_items'] = true;
300 $allcaps['delete_knowledge_items'] = true;
301 $allcaps['delete_private_knowledge_items'] = true;
302 $allcaps['read_private_knowledge_items'] = true;
303
304 return $allcaps;
305 }
306 }
307
308 if ( ! function_exists( 'wp_knowledge_maybe_map_term_label' ) ) {
309 /**
310 * Hook callback for the `wp_insert_term_data` filter that swaps a
311 * raw knowledge-type slug for its human-readable label when WordPress
312 * is about to lazily create the term.
313 *
314 * When `wp_set_object_terms()` is called with a slug that doesn't yet
315 * exist, `wp_insert_term()` fires and the filter runs after WP has
316 * computed both `name` and `slug`. A `name` equal to `slug` indicates
317 * the term was created from a raw slug (e.g. by `wp_set_object_terms()`)
318 * rather than from a user-provided label, so the label is replaced with
319 * the title from `wp_knowledge_types()`.
320 *
321 * @access private
322 *
323 * @param array $data Term data to be inserted (keyed by column name).
324 * @param string $taxonomy Taxonomy slug.
325 * @return array Possibly modified term data.
326 */
327 function wp_knowledge_maybe_map_term_label( array $data, string $taxonomy ): array {
328 if ( 'wp_knowledge_type' !== $taxonomy ) {
329 return $data;
330 }
331
332 if ( $data['name'] !== $data['slug'] ) {
333 return $data;
334 }
335
336 $types = wp_knowledge_types();
337 if ( isset( $types[ $data['slug'] ] ) ) {
338 $data['name'] = $types[ $data['slug'] ]['title'];
339 }
340
341 return $data;
342 }
343 }
344
345 if ( ! function_exists( 'wp_guideline_scope_from_slug' ) ) {
346 /**
347 * Resolve the scope that owns a guideline row slug.
348 *
349 * Returns the scope key for a `guideline-{scope}` slug that matches a
350 * registered scope. A registered scope key always wins, so a scope keyed
351 * like `block-foo` resolves to itself rather than the blocks scope. Any
352 * other `guideline-block-*` slug is a per-block row that belongs to the
353 * `blocks` scope while it is registered. Returns null for the bare
354 * `guideline-block-` slug and for any unknown scope.
355 *
356 * @access private
357 *
358 * @param string $slug Post slug.
359 * @return string|null Scope key, or null if the slug is not a registered scope.
360 */
361 function wp_guideline_scope_from_slug( string $slug ): ?string {
362 if ( ! str_starts_with( $slug, 'guideline-' ) ) {
363 return null;
364 }
365
366 $scopes = wp_guideline_scopes();
367 $scope = substr( $slug, strlen( 'guideline-' ) );
368
369 // A slug that matches a registered scope key is that scope. Checking this
370 // first lets a scope keyed like `block-foo` win over the per-block
371 // namespace below, instead of being swallowed by the blocks scope.
372 if ( isset( $scopes[ $scope ] ) ) {
373 return $scope;
374 }
375
376 // Otherwise a `guideline-block-<name>` row is a per-block row that belongs
377 // to the blocks scope while it is registered. A real block name never
378 // equals a registered scope key, so the check above stays safe.
379 if ( str_starts_with( $slug, 'guideline-block-' ) && strlen( $slug ) > strlen( 'guideline-block-' ) ) {
380 return isset( $scopes['blocks'] ) ? 'blocks' : null;
381 }
382
383 return null;
384 }
385 }
386
387 if ( ! function_exists( 'wp_knowledge_guard_guideline_row' ) ) {
388 /**
389 * Hook callback for `rest_pre_insert_wp_knowledge` that sanitizes and
390 * normalizes guideline rows on the REST insert path.
391 *
392 * Only rows whose slug maps to a registered guideline scope are shaped (see
393 * `wp_guideline_scope_from_slug()`); any other row is left untouched. For a
394 * recognized guideline row this callback:
395 * - Sanitizes `post_content` to plain text capped at the guideline length.
396 * - Re-stamps the title of single-row registry scopes from
397 * `wp_guideline_scopes()` in the site locale. Per-block rows (the multi-row
398 * `blocks` scope) keep the client-provided canonical block name.
399 *
400 * Slug uniqueness is intentionally left to WordPress: the published row keeps
401 * its exact slug because the first save has no conflict and later saves reuse
402 * that row by ID, while any other row with the same desired slug is suffixed
403 * (`guideline-copy-2`) by `wp_unique_post_slug()`. The Settings page reads
404 * only the published row by its exact slug, so suffixed rows are ignored. The
405 * client save flow reclaims an existing same-slug row instead of creating a
406 * duplicate (see routes/guidelines/data.ts).
407 *
408 * @access private
409 *
410 * @param stdClass $prepared_post Prepared post object.
411 * @param WP_REST_Request $request Request object.
412 * @return stdClass Prepared post.
413 */
414 function wp_knowledge_guard_guideline_row( $prepared_post, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
415 $slug = '';
416 if ( ! empty( $prepared_post->post_name ) ) {
417 $slug = $prepared_post->post_name;
418 } elseif ( ! empty( $prepared_post->ID ) ) {
419 $existing = get_post( $prepared_post->ID );
420 if ( $existing instanceof WP_Post ) {
421 $slug = $existing->post_name;
422 }
423 }
424
425 // Only shape rows whose slug maps to a registered guideline scope.
426 $scope = wp_guideline_scope_from_slug( (string) $slug );
427 if ( null === $scope ) {
428 return $prepared_post;
429 }
430
431 // Sanitize content: plain text, capped at the guideline length.
432 if ( isset( $prepared_post->post_content ) ) {
433 $content = sanitize_textarea_field( $prepared_post->post_content );
434 $max = wp_guideline_max_length();
435 if ( mb_strlen( $content, 'UTF-8' ) > $max ) {
436 $content = mb_substr( $content, 0, $max, 'UTF-8' );
437 }
438 $prepared_post->post_content = $content;
439 }
440
441 // Re-stamp single-row registry scope titles in the site locale. The
442 // blocks scope is multi-row, so its per-block rows keep the
443 // client-provided canonical block name.
444 if ( 'blocks' !== $scope ) {
445 $switched = switch_to_locale( get_locale() );
446 $scopes = wp_guideline_scopes();
447 if ( $switched ) {
448 restore_previous_locale();
449 }
450 if ( isset( $scopes[ $scope ]['title'] ) ) {
451 $prepared_post->post_title = $scopes[ $scope ]['title'];
452 }
453 }
454
455 return $prepared_post;
456 }
457 }
458