PluginProbe
FluentSnippets – High-Performance Code Snippets, Header & Footer Code, Custom CSS & PHP Code Manager / trunk
FluentSnippets – High-Performance Code Snippets, Header & Footer Code, Custom CSS & PHP Code Manager vtrunk
10.56 1.2.1 10 10.1 10.2 10.3 10.31 10.32 10.33 10.34 10.50 10.51 10.52 10.53 10.55 9.0 9.0.1 9.4 trunk 1.0.0 1.1 1.2
easy-code-manager / app / Model / Snippet.php

Snippet.php in FluentSnippets – High-Performance Code Snippets, Header & Footer Code, Custom CSS & PHP Code Manager trunk, at app/Model/Snippet.php

571 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSnippets\App\Model;
4
5 use FluentSnippets\App\Helpers\Arr;
6 use FluentSnippets\App\Helpers\Helper;
7 use FluentSnippets\App\Services\SnippetErrors;
8
9 class Snippet
10 {
11
12 private $args = [];
13
14 public function __construct($args = [])
15 {
16 $this->args = $args;
17 }
18
19 public function get($args = [])
20 {
21 if ($args) {
22 $this->args = $args;
23 }
24
25 $args = $this->args;
26
27 $snippetDir = Helper::getStorageDir();
28 // get the file paths and store them in an array
29 $files = glob($snippetDir . '/*.php');
30
31 if (isset($args['order']) && $args['order'] == 'new_first') {
32 $files = array_reverse($files);
33 }
34
35 $formattedFiles = [];
36 foreach ($files as $file) {
37 $fileContent = file_get_contents($file);
38 [$docBlockArray, $code] = $this->parseBlock($fileContent);
39
40 if (!$docBlockArray) {
41 continue;
42 }
43
44 if (!empty($args['status'])) {
45 if ($args['status'] !== $docBlockArray['status']) {
46 continue;
47 }
48 }
49
50 $formattedFiles[] = [
51 'meta' => $docBlockArray,
52 'code' => $code,
53 'file' => $file,
54 'status' => (!empty($docBlockArray['status'])) ? $docBlockArray['status'] : 'draft'
55 ];
56 }
57 return $formattedFiles;
58 }
59
60 public function paginate($perPage = null, $page = null)
61 {
62 if ($perPage === null) {
63 if (isset($_GET['per_page'])) {
64 $perPage = $_GET['per_page'];
65 } else {
66 $perPage = 10;
67 }
68 }
69
70 if ($page === null) {
71 if (isset($_GET['page'])) {
72 $page = $_GET['page'];
73 } else {
74 $page = 1;
75 }
76 }
77 $offset = ($page - 1) * $perPage;
78
79 $snippets = $this->get([
80 'order' => 'new_first'
81 ]);
82
83 $total = count($snippets);
84 $snippets = array_slice($snippets, $offset, $perPage);
85
86 return [
87 'data' => $snippets,
88 'total' => $total,
89 'per_page' => (int)$perPage,
90 'current_page' => (int)$page,
91 'last_page' => (int)ceil($total / $perPage)
92 ];
93 }
94
95 public function getIndexedSnippets($perPage = null, $page = null)
96 {
97 $config = Helper::getIndexedConfig();
98
99 if (!$config || empty($config['meta'])) {
100 return $this->emptyIndexedSnippets($perPage, $page);
101 }
102
103 if (empty($config['published']) && empty($config['draft'])) {
104 return $this->emptyIndexedSnippets($perPage, $page);
105 }
106
107 if (!empty($this->args['status'])) {
108 if ($this->args['status'] == 'published') {
109 $snippets = $config['published'];
110 } else if ($this->args['status'] == 'draft') {
111 $snippets = $config['draft'];
112 } else if ($this->args['status'] == 'paused') {
113 $snippets = array_merge($config['published'], $config['draft']);
114 $errorFiles = Arr::get($config, 'error_files', []);
115 $snippets = Arr::only($snippets, array_keys($errorFiles));
116 } else {
117 $snippets = array_merge($config['published'], $config['draft']);
118 }
119 } else {
120 $snippets = array_merge($config['published'], $config['draft']);
121 }
122
123 if (empty($snippets)) {
124 return $this->emptyIndexedSnippets($perPage, $page);
125 }
126
127 $errorFiles = Arr::get($config, 'error_files', []);
128 if ($errorFiles) {
129 foreach ($errorFiles as $fileName => $error) {
130 if (isset($snippets[$fileName])) {
131 $snippets[$fileName]['error'] = $error;
132 }
133 }
134 }
135
136 $snippets = array_values($snippets);
137
138 $type = Arr::get($this->args, 'type');
139
140 if ($type && $type != 'all') {
141 $snippets = array_filter($snippets, function ($snippet) use ($type) {
142 return $snippet['type'] == $type;
143 });
144 }
145
146 if ($search = Arr::get($this->args, 'search')) {
147 $snippets = array_filter($snippets, function ($snippet) use ($search) {
148 // stripos, not strpos: searching "Header" should find "header script".
149 return (stripos($snippet['name'], $search) !== false) || (stripos($snippet['description'], $search) !== false) || (stripos($snippet['tags'], $search) !== false) || (stripos($snippet['group'], $search) !== false);
150 });
151 }
152
153 if ($tag = Arr::get($this->args, 'tag')) {
154 $snippets = array_filter($snippets, function ($snippet) use ($tag) {
155 if (!$snippet['tags']) {
156 return false;
157 }
158 $tags = array_map('trim', explode(',', $snippet['tags']));
159 return in_array($tag, $tags);
160 });
161 }
162
163 $snippets = $this->sortSnippets($snippets);
164
165 if ($perPage != null && $page != null) {
166 $total = count($snippets); // has to be counted before slicing the current page out
167 $snippets = array_slice($snippets, ($page - 1) * $perPage, $perPage);
168 return [
169 'data' => $snippets,
170 'page' => (int)$page,
171 'per_page' => (int)$perPage,
172 'total' => $total,
173 'last_page' => (int)ceil($total / $perPage)
174 ];
175 }
176
177 return $snippets;
178 }
179
180 /*
181 * Keeps the response shape consistent when there is nothing to return.
182 * Without this the paginated callers get a bare array and no data key.
183 */
184 private function emptyIndexedSnippets($perPage = null, $page = null)
185 {
186 if ($perPage != null && $page != null) {
187 return [
188 'data' => [],
189 'page' => (int)$page,
190 'per_page' => (int)$perPage,
191 'total' => 0,
192 'last_page' => 0
193 ];
194 }
195
196 return [];
197 }
198
199 private function sortSnippets($snippets)
200 {
201 $sortingMaps = [
202 'created_at' => 'strtotime',
203 'updated_at' => 'strtotime',
204 'priority' => 'intval',
205 'name' => 'strtolower',
206 ];
207
208 $sortBy = $this->args['sort_by'] ?? 'created_at';
209 $sortOrder = $this->args['sort_order'] ?? 'desc';
210
211 $callback = Arr::get($sortingMaps, $sortBy);
212
213 if (!$callback) {
214 return $snippets;
215 }
216
217 // Short the snippets by name
218 usort($snippets, function ($a, $b) use ($sortBy, $sortOrder, $callback) {
219 $value1 = call_user_func($callback, $a[$sortBy]);
220 $value2 = call_user_func($callback, $b[$sortBy]);
221 if ($sortBy == 'name') {
222 return $sortOrder == 'asc' ? strcasecmp(trim($a['name']), trim($b['name'])) : strcasecmp(trim($b['name']), trim($a['name']));
223 }
224 return $sortOrder == 'asc' ? $value1 <=> $value2 : $value2 <=> $value1;
225 });
226
227 return $snippets;
228 }
229
230 public function getAllSnippetTagsGroups()
231 {
232 $config = Helper::getIndexedConfig();
233
234 if (!$config || empty($config['meta'])) {
235 return [[], []];
236 }
237
238 if (empty($config['published']) && empty($config['draft'])) {
239 return [[], []];
240 }
241
242 $snippets = array_merge($config['published'], $config['draft']);
243 if (!$snippets) {
244 return [[], []];
245 }
246
247 $allTags = [];
248 $allGroups = [];
249
250 foreach ($snippets as $snippet) {
251 if (!empty($snippet['tags'])) {
252 $tags = array_map('trim', explode(',', $snippet['tags']));
253 $allTags = array_merge($allTags, $tags);
254 }
255
256 if (!empty($snippet['group'])) {
257 $allGroups[] = trim($snippet['group']);
258 }
259 }
260
261 $allTags = array_unique($allTags);
262 asort($allTags);
263 $allGroups = array_unique($allGroups);
264 asort($allGroups);
265 return [array_values($allTags), array_values($allGroups)];
266 }
267
268 public function findByFileName($fileName)
269 {
270 $snippetDir = Helper::getStorageDir();
271 $file = $snippetDir . '/' . $fileName;
272
273 if (!is_file($file) || $fileName === 'index.php') {
274 return SnippetErrors::fileMissing($fileName);
275 }
276
277 $fileContent = file_get_contents($snippetDir . '/' . $fileName);
278 [$docBlockArray, $code] = $this->parseBlock($fileContent);
279
280 return [
281 'meta' => $docBlockArray,
282 'code' => $code,
283 'file' => $file,
284 'status' => (!empty($docBlockArray['status'])) ? $docBlockArray['status'] : 'draft'
285 ];
286 }
287
288 public function updateSnippet($fileName, $code, $metaData)
289 {
290 $metaData['updated_at'] = date('Y-m-d H:i:s');
291
292 $file = Helper::getStorageDir() . '/' . $fileName;
293
294 if (!is_file($file)) {
295 return SnippetErrors::fileMissing($fileName);
296 }
297
298 $docBlockString = $this->parseInputMeta($metaData, true);
299 $fullCode = $docBlockString . $code;
300
301 // atomicPut() returns false when the storage directory is not writable. Ignoring
302 // that was the one failure mode where the plugin actively lied: the editor said
303 // "Snippet has been updated successfully" and the old code came back on reload.
304 if (Helper::atomicPut($file, $fullCode) === false) {
305 return SnippetErrors::writeFailed($file);
306 }
307
308 Helper::invalidateOpcache($file);
309
310 $type = Arr::get($metaData, 'type');
311
312 if ($type == 'css' || $type == 'js') {
313 $this->maybeCacheCssJs($fileName, $metaData, $code);
314 }
315
316 return $fileName;
317 }
318
319 public function createSnippet($code, $metaData)
320 {
321 $storageDir = Helper::getStorageDir();
322 $fileCount = count(glob($storageDir . '/*.php'));
323
324 if (!$fileCount) {
325 Helper::cacheSnippetIndex();
326 $fileCount = 1;
327 }
328
329 // get the first 4 words of the snippet name
330 $fileTitle = $metaData['name'];
331 $nameArr = explode(' ', $fileTitle);
332 if (count($nameArr) > 4) {
333 $nameArr = array_slice($nameArr, 0, 4);
334 $fileTitle = implode(' ', $nameArr);
335 }
336
337 $fileTitle = sanitize_title($fileTitle, 'snippet');
338
339 $fileName = $fileCount . '-' . $fileTitle . '.php';
340
341 $fileName = sanitize_file_name($fileName);
342
343 $file = $storageDir . '/' . $fileName;
344
345 if (is_file($file)) {
346 return SnippetErrors::make('file_exists', [
347 'title' => __('A snippet file with this name already exists', 'easy-code-manager'),
348 'reason' => sprintf(
349 /* translators: %s: snippet file name */
350 __('Snippet files are named after the snippet, and %s is taken. This normally happens when a snippet was deleted and recreated, so the numbering no longer lines up.', 'easy-code-manager'),
351 $fileName
352 ),
353 'fix' => __('Change the snippet name slightly and save again.', 'easy-code-manager'),
354 ]);
355 }
356
357 $docBlockString = $this->parseInputMeta($metaData, true);
358
359 $fullCode = $docBlockString . $code;
360
361 if (Helper::atomicPut($file, $fullCode) === false) {
362 return SnippetErrors::writeFailed($file);
363 }
364
365 Helper::invalidateOpcache($file);
366
367 $this->maybeCacheCssJs($fileName, $metaData, $code);
368
369 return $fileName;
370 }
371
372 public function deleteSnippet($fileName)
373 {
374 $snippetDir = Helper::getStorageDir();
375 $file = $snippetDir . '/' . $fileName;
376
377 // `&&` here meant the guard never fired for the files it names: index.php exists,
378 // so !is_file() was false and the whole condition collapsed to false, falling
379 // through to unlink(). Unreachable in practice — the only caller runs
380 // findByFileName() first, which rejects index.php — but the protection this reads
381 // as providing was not there at all.
382 if (!is_file($file) || $fileName === 'index.php' || $fileName === 'cached') {
383 return new \WP_Error('file_not_found', 'File not found');
384 }
385
386 unlink($file);
387
388 Helper::invalidateOpcache($file);
389
390 return true;
391 }
392
393 public function parseBlock($fileContent, $codeOnly = false)
394 {
395 // get content from // <Internal Doc Start> to // <Internal Doc End>
396 $fileContent = explode('// <Internal Doc Start>', $fileContent);
397
398 if (count($fileContent) < 2) {
399 if ($codeOnly) {
400 return '';
401 }
402 return [null, null];
403 }
404
405 // Try different possible formats of the end marker
406 $endMarkers = [
407 '// <Internal Doc End> ?>' . PHP_EOL,
408 '// <Internal Doc End> ?>',
409 '<?php if (!defined("ABSPATH")) { return;} // <Internal Doc End> ?>' . PHP_EOL,
410 '<?php if (!defined("ABSPATH")) { return;} // <Internal Doc End> ?>'
411 ];
412
413 $docBlock = null;
414 $code = null;
415
416 foreach ($endMarkers as $marker) {
417 $parts = explode($marker, $fileContent[1]);
418 if (count($parts) > 1) {
419 $docBlock = $parts[0];
420 $code = $parts[1];
421 break;
422 }
423 }
424
425 if (!$docBlock || !$code) {
426 if ($codeOnly) {
427 return '';
428 }
429 return [null, null];
430 }
431
432 if ($codeOnly) {
433 return $code;
434 }
435
436 $docBlock = explode('*', $docBlock);
437 // Explode by : and get the key and value
438 $docBlockArray = [
439 'name' => '',
440 'status' => '',
441 'tags' => '',
442 'description' => '',
443 'type' => '',
444 'run_at' => '',
445 'group' => '',
446 // Defaulted because a hand-edited or legacy snippet file may have no
447 // @priority line, and cacheSnippetIndex() sorts on it — a missing key warned
448 // on every rebuild under PHP 8. Matches getMetaData()'s own default.
449 'priority' => 10,
450 'condition' => '',
451 'load_as_file' => '',
452 'load_in_block_editor' => ''
453 ];
454
455 foreach ($docBlock as $key => $value) {
456 $value = trim($value);
457 $arr = explode(':', $value);
458 if (count($arr) < 2) {
459 continue;
460 }
461
462 // get the first item from the array and remove it from $arr
463 $key = array_shift($arr);
464 $key = trim(str_replace('@', '', $key));
465 if (!$key) {
466 continue;
467 }
468 $docBlockArray[$key] = trim(implode(':', $arr));
469 }
470
471 if (!empty($docBlockArray['condition'])) {
472 $data = json_decode($docBlockArray['condition'], true);
473 if ($data && is_array($data)) {
474 $docBlockArray['condition'] = $data;
475 }
476 } else {
477 $docBlockArray['condition'] = [
478 'status' => 'no',
479 'run_if' => 'assertive',
480 'items' => [[]]
481 ];
482 }
483
484 if (empty($docBlockArray['condition'])) {
485 $docBlockArray['condition'] = [
486 'status' => 'no',
487 'run_if' => 'assertive',
488 'items' => [[]]
489 ];
490 }
491
492 return [$docBlockArray, $code];
493 }
494
495 private function parseInputMeta($metaData, $convertString = false)
496 {
497 $metaDefaults = [
498 'description' => '',
499 'tags' => '',
500 'group' => '',
501 'name' => 'Snippet Created @ ' . current_time('mysql'),
502 'type' => 'PHP',
503 'status' => 'draft',
504 'created_by' => get_current_user_id(),
505 'created_at' => gmdate('Y-m-d H:i:s'),
506 'updated_at' => gmdate('Y-m-d H:i:s'),
507 'is_valid' => 1,
508 'updated_by' => get_current_user_id(),
509 'priority' => 10,
510 'run_at' => '',
511 'load_as_file' => '',
512 'load_in_block_editor' => '',
513 'condition' => [
514 'status' => 'no',
515 'run_if' => 'assertive',
516 'items' => [[]]
517 ]
518 ];
519
520 $metaData = Arr::only($metaData, array_keys($metaDefaults));
521 $metaData = wp_parse_args($metaData, $metaDefaults);
522
523 if (!is_numeric($metaData['priority']) || $metaData['priority'] < 1) {
524 $metaData['priority'] = 10;
525 }
526
527 if (!$convertString) {
528 return $metaData;
529 }
530
531 $metaData['condition'] = json_encode($metaData['condition']);
532
533 // Helper::sanitizeMetaValue() has to neutralise `*` in every meta value, because
534 // parseBlock() splits the docblock on it. For this one value that would corrupt
535 // real data, so escape it as a JSON unicode escape instead — json_decode() turns
536 // * back into `*`, and the docblock never sees a literal one.
537 $metaData['condition'] = str_replace('*', '\\u002a', $metaData['condition']);
538
539 $docBlockString = '<?php' . PHP_EOL . '// <Internal Doc Start>' . PHP_EOL . '/*' . PHP_EOL . '*';
540
541 foreach ($metaData as $key => $value) {
542 $docBlockString .= PHP_EOL . '* @' . $key . ': ' . Helper::sanitizeMetaValue($value);
543 }
544
545 $docBlockString .= PHP_EOL . '*/' . PHP_EOL . '?>' . PHP_EOL . '<?php if (!defined("ABSPATH")) { return;} // <Internal Doc End> ?>' . PHP_EOL;
546
547 return $docBlockString;
548 }
549
550 private function maybeCacheCssJs($fileName, $metaData = [], $code = '')
551 {
552 // type
553 $type = Arr::get($metaData, 'type');
554 if ($type == 'css' || $type == 'js') {
555 // get file name without extension
556 $cacheFileName = str_replace('.php', '.' . $type, $fileName);
557 $fullFileName = Helper::getCachedDir() . '/' . $cacheFileName;
558 if (Arr::get($metaData, 'load_as_file') == 'yes') {
559 Helper::atomicPut($fullFileName, $code);
560 return $cacheFileName;
561 }
562
563 if (file_exists($fullFileName)) {
564 @unlink($fullFileName);
565 }
566 }
567
568 return false;
569 }
570 }
571