PageStorage.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @package Grav\Common\Flex
  5. *
  6. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  7. * @license MIT License; see LICENSE file for details.
  8. */
  9. namespace Grav\Common\Flex\Types\Pages\Storage;
  10. use FilesystemIterator;
  11. use Grav\Common\Debugger;
  12. use Grav\Common\Flex\Types\Pages\PageIndex;
  13. use Grav\Common\Grav;
  14. use Grav\Common\Language\Language;
  15. use Grav\Common\Utils;
  16. use Grav\Framework\Filesystem\Filesystem;
  17. use Grav\Framework\Flex\Storage\FolderStorage;
  18. use RocketTheme\Toolbox\File\MarkdownFile;
  19. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  20. use RuntimeException;
  21. use SplFileInfo;
  22. use function in_array;
  23. use function is_string;
  24. /**
  25. * Class GravPageStorage
  26. * @package Grav\Plugin\FlexObjects\Types\GravPages
  27. */
  28. class PageStorage extends FolderStorage
  29. {
  30. /** @var bool */
  31. protected $ignore_hidden;
  32. /** @var array */
  33. protected $ignore_files;
  34. /** @var array */
  35. protected $ignore_folders;
  36. /** @var bool */
  37. protected $include_default_lang_file_extension;
  38. /** @var bool */
  39. protected $recurse;
  40. /** @var string */
  41. protected $base_path;
  42. /** @var int */
  43. protected $flags;
  44. /** @var string */
  45. protected $regex;
  46. /**
  47. * @param array $options
  48. */
  49. protected function initOptions(array $options): void
  50. {
  51. parent::initOptions($options);
  52. $this->flags = FilesystemIterator::KEY_AS_FILENAME | FilesystemIterator::CURRENT_AS_FILEINFO
  53. | FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS;
  54. $grav = Grav::instance();
  55. $config = $grav['config'];
  56. $this->ignore_hidden = (bool)$config->get('system.pages.ignore_hidden');
  57. $this->ignore_files = (array)$config->get('system.pages.ignore_files');
  58. $this->ignore_folders = (array)$config->get('system.pages.ignore_folders');
  59. $this->include_default_lang_file_extension = (bool)$config->get('system.languages.include_default_lang_file_extension', true);
  60. $this->recurse = (bool)($options['recurse'] ?? true);
  61. $this->regex = '/(\.([\w\d_-]+))?\.md$/D';
  62. }
  63. /**
  64. * @param string $key
  65. * @param bool $variations
  66. * @return array
  67. */
  68. public function parseKey(string $key, bool $variations = true): array
  69. {
  70. if (mb_strpos($key, '|') !== false) {
  71. [$key, $params] = explode('|', $key, 2);
  72. } else {
  73. $params = '';
  74. }
  75. $key = ltrim($key, '/');
  76. $keys = parent::parseKey($key, false) + ['params' => $params];
  77. if ($variations) {
  78. $keys += $this->parseParams($key, $params);
  79. }
  80. return $keys;
  81. }
  82. /**
  83. * @param string $key
  84. * @return string
  85. */
  86. public function readFrontmatter(string $key): string
  87. {
  88. $path = $this->getPathFromKey($key);
  89. $file = $this->getFile($path);
  90. try {
  91. if ($file instanceof MarkdownFile) {
  92. $frontmatter = $file->frontmatter();
  93. } else {
  94. $frontmatter = $file->raw();
  95. }
  96. } catch (RuntimeException $e) {
  97. $frontmatter = 'ERROR: ' . $e->getMessage();
  98. } finally {
  99. $file->free();
  100. unset($file);
  101. }
  102. return $frontmatter;
  103. }
  104. /**
  105. * @param string $key
  106. * @return string
  107. */
  108. public function readRaw(string $key): string
  109. {
  110. $path = $this->getPathFromKey($key);
  111. $file = $this->getFile($path);
  112. try {
  113. $raw = $file->raw();
  114. } catch (RuntimeException $e) {
  115. $raw = 'ERROR: ' . $e->getMessage();
  116. } finally {
  117. $file->free();
  118. unset($file);
  119. }
  120. return $raw;
  121. }
  122. /**
  123. * @param array $keys
  124. * @param bool $includeParams
  125. * @return string
  126. */
  127. public function buildStorageKey(array $keys, bool $includeParams = true): string
  128. {
  129. $key = $keys['key'] ?? null;
  130. if (null === $key) {
  131. $key = $keys['parent_key'] ?? '';
  132. if ($key !== '') {
  133. $key .= '/';
  134. }
  135. $order = $keys['order'] ?? null;
  136. $folder = $keys['folder'] ?? 'undefined';
  137. $key .= is_numeric($order) ? sprintf('%02d.%s', $order, $folder) : $folder;
  138. }
  139. $params = $includeParams ? $this->buildStorageKeyParams($keys) : '';
  140. return $params ? "{$key}|{$params}" : $key;
  141. }
  142. /**
  143. * @param array $keys
  144. * @return string
  145. */
  146. public function buildStorageKeyParams(array $keys): string
  147. {
  148. $params = $keys['template'] ?? '';
  149. $language = $keys['lang'] ?? '';
  150. if ($language) {
  151. $params .= '.' . $language;
  152. }
  153. return $params;
  154. }
  155. /**
  156. * @param array $keys
  157. * @return string
  158. */
  159. public function buildFolder(array $keys): string
  160. {
  161. return $this->dataFolder . '/' . $this->buildStorageKey($keys, false);
  162. }
  163. /**
  164. * @param array $keys
  165. * @return string
  166. */
  167. public function buildFilename(array $keys): string
  168. {
  169. $file = $this->buildStorageKeyParams($keys);
  170. // Template is optional; if it is missing, we need to have to load the object metadata.
  171. if ($file && $file[0] === '.') {
  172. $meta = $this->getObjectMeta($this->buildStorageKey($keys, false));
  173. $file = ($meta['template'] ?? 'folder') . $file;
  174. }
  175. return $file . $this->dataExt;
  176. }
  177. /**
  178. * @param array $keys
  179. * @return string
  180. */
  181. public function buildFilepath(array $keys): string
  182. {
  183. $folder = $this->buildFolder($keys);
  184. $filename = $this->buildFilename($keys);
  185. return rtrim($folder, '/') !== $folder ? $folder . $filename : $folder . '/' . $filename;
  186. }
  187. /**
  188. * @param array $row
  189. * @param bool $setDefaultLang
  190. * @return array
  191. */
  192. public function extractKeysFromRow(array $row, bool $setDefaultLang = true): array
  193. {
  194. $meta = $row['__META'] ?? null;
  195. $storageKey = $row['storage_key'] ?? $meta['storage_key'] ?? '';
  196. $keyMeta = $storageKey !== '' ? $this->extractKeysFromStorageKey($storageKey) : null;
  197. $parentKey = $row['parent_key'] ?? $meta['parent_key'] ?? $keyMeta['parent_key'] ?? '';
  198. $order = $row['order'] ?? $meta['order'] ?? $keyMeta['order'] ?? null;
  199. $folder = $row['folder'] ?? $meta['folder'] ?? $keyMeta['folder'] ?? '';
  200. $template = $row['template'] ?? $meta['template'] ?? $keyMeta['template'] ?? '';
  201. $lang = $row['lang'] ?? $meta['lang'] ?? $keyMeta['lang'] ?? '';
  202. // Handle default language, if it should be saved without language extension.
  203. if ($setDefaultLang && empty($meta['markdown'][$lang])) {
  204. $grav = Grav::instance();
  205. /** @var Language $language */
  206. $language = $grav['language'];
  207. $default = $language->getDefault();
  208. // Make sure that the default language file doesn't exist before overriding it.
  209. if (empty($meta['markdown'][$default])) {
  210. if ($this->include_default_lang_file_extension) {
  211. if ($lang === '') {
  212. $lang = $language->getDefault();
  213. }
  214. } elseif ($lang === $language->getDefault()) {
  215. $lang = '';
  216. }
  217. }
  218. }
  219. $keys = [
  220. 'key' => null,
  221. 'params' => null,
  222. 'parent_key' => $parentKey,
  223. 'order' => is_numeric($order) ? (int)$order : null,
  224. 'folder' => $folder,
  225. 'template' => $template,
  226. 'lang' => $lang
  227. ];
  228. $keys['key'] = $this->buildStorageKey($keys, false);
  229. $keys['params'] = $this->buildStorageKeyParams($keys);
  230. return $keys;
  231. }
  232. /**
  233. * @param string $key
  234. * @return array
  235. */
  236. public function extractKeysFromStorageKey(string $key): array
  237. {
  238. if (mb_strpos($key, '|') !== false) {
  239. [$key, $params] = explode('|', $key, 2);
  240. [$template, $language] = mb_strpos($params, '.') !== false ? explode('.', $params, 2) : [$params, ''];
  241. } else {
  242. $params = $template = $language = '';
  243. }
  244. $objectKey = Utils::basename($key);
  245. if (preg_match('|^(\d+)\.(.+)$|', $objectKey, $matches)) {
  246. [, $order, $folder] = $matches;
  247. } else {
  248. [$order, $folder] = ['', $objectKey];
  249. }
  250. $filesystem = Filesystem::getInstance(false);
  251. $parentKey = ltrim($filesystem->dirname('/' . $key), '/');
  252. return [
  253. 'key' => $key,
  254. 'params' => $params,
  255. 'parent_key' => $parentKey,
  256. 'order' => is_numeric($order) ? (int)$order : null,
  257. 'folder' => $folder,
  258. 'template' => $template,
  259. 'lang' => $language
  260. ];
  261. }
  262. /**
  263. * @param string $key
  264. * @param string $params
  265. * @return array
  266. */
  267. protected function parseParams(string $key, string $params): array
  268. {
  269. if (mb_strpos($params, '.') !== false) {
  270. [$template, $language] = explode('.', $params, 2);
  271. } else {
  272. $template = $params;
  273. $language = '';
  274. }
  275. if ($template === '') {
  276. $meta = $this->getObjectMeta($key);
  277. $template = $meta['template'] ?? 'folder';
  278. }
  279. return [
  280. 'file' => $template . ($language ? '.' . $language : ''),
  281. 'template' => $template,
  282. 'lang' => $language
  283. ];
  284. }
  285. /**
  286. * Prepares the row for saving and returns the storage key for the record.
  287. *
  288. * @param array $row
  289. */
  290. protected function prepareRow(array &$row): void
  291. {
  292. // Remove keys used in the filesystem.
  293. unset($row['parent_key'], $row['order'], $row['folder'], $row['template'], $row['lang']);
  294. }
  295. /**
  296. * @param string $key
  297. * @return array
  298. */
  299. protected function loadRow(string $key): ?array
  300. {
  301. $data = parent::loadRow($key);
  302. // Special case for root page.
  303. if ($key === '' && null !== $data) {
  304. $data['root'] = true;
  305. }
  306. return $data;
  307. }
  308. /**
  309. * Page storage supports moving and copying the pages and their languages.
  310. *
  311. * $row['__META']['copy'] = true Use this if you want to copy the whole folder, otherwise it will be moved
  312. * $row['__META']['clone'] = true Use this if you want to clone the file, otherwise it will be renamed
  313. *
  314. * @param string $key
  315. * @param array $row
  316. * @return array
  317. */
  318. protected function saveRow(string $key, array $row): array
  319. {
  320. // Initialize all key-related variables.
  321. $newKeys = $this->extractKeysFromRow($row);
  322. $newKey = $this->buildStorageKey($newKeys);
  323. $newFolder = $this->buildFolder($newKeys);
  324. $newFilename = $this->buildFilename($newKeys);
  325. $newFilepath = rtrim($newFolder, '/') !== $newFolder ? $newFolder . $newFilename : $newFolder . '/' . $newFilename;
  326. try {
  327. if ($key === '' && empty($row['root'])) {
  328. throw new RuntimeException('Page has no path');
  329. }
  330. $grav = Grav::instance();
  331. /** @var Debugger $debugger */
  332. $debugger = $grav['debugger'];
  333. $debugger->addMessage("Save page: {$newKey}", 'debug');
  334. // Check if the row already exists.
  335. $oldKey = $row['__META']['storage_key'] ?? null;
  336. if (is_string($oldKey)) {
  337. // Initialize all old key-related variables.
  338. $oldKeys = $this->extractKeysFromRow(['__META' => $row['__META']], false);
  339. $oldFolder = $this->buildFolder($oldKeys);
  340. $oldFilename = $this->buildFilename($oldKeys);
  341. // Check if folder has changed.
  342. if ($oldFolder !== $newFolder && file_exists($oldFolder)) {
  343. $isCopy = $row['__META']['copy'] ?? false;
  344. if ($isCopy) {
  345. if (strpos($newFolder, $oldFolder . '/') === 0) {
  346. throw new RuntimeException(sprintf('Page /%s cannot be copied to itself', $oldKey));
  347. }
  348. $this->copyRow($oldKey, $newKey);
  349. $debugger->addMessage("Page copied: {$oldFolder} => {$newFolder}", 'debug');
  350. } else {
  351. if (strpos($newFolder, $oldFolder . '/') === 0) {
  352. throw new RuntimeException(sprintf('Page /%s cannot be moved to itself', $oldKey));
  353. }
  354. $this->renameRow($oldKey, $newKey);
  355. $debugger->addMessage("Page moved: {$oldFolder} => {$newFolder}", 'debug');
  356. }
  357. }
  358. // Check if filename has changed.
  359. if ($oldFilename !== $newFilename) {
  360. // Get instance of the old file (we have already copied/moved it).
  361. $oldFilepath = "{$newFolder}/{$oldFilename}";
  362. $file = $this->getFile($oldFilepath);
  363. // Rename the file if we aren't supposed to clone it.
  364. $isClone = $row['__META']['clone'] ?? false;
  365. if (!$isClone && $file->exists()) {
  366. /** @var UniformResourceLocator $locator */
  367. $locator = $grav['locator'];
  368. $toPath = $locator->isStream($newFilepath) ? $locator->findResource($newFilepath, true, true) : GRAV_ROOT . "/{$newFilepath}";
  369. $success = $file->rename($toPath);
  370. if (!$success) {
  371. throw new RuntimeException("Changing page template failed: {$oldFilepath} => {$newFilepath}");
  372. }
  373. $debugger->addMessage("Page template changed: {$oldFilename} => {$newFilename}", 'debug');
  374. } else {
  375. $file = null;
  376. $debugger->addMessage("Page template created: {$newFilename}", 'debug');
  377. }
  378. }
  379. }
  380. // Clean up the data to be saved.
  381. $this->prepareRow($row);
  382. unset($row['__META'], $row['__ERROR']);
  383. if (!isset($file)) {
  384. $file = $this->getFile($newFilepath);
  385. }
  386. // Compare existing file content to the new one and save the file only if content has been changed.
  387. $file->free();
  388. $oldRaw = $file->raw();
  389. $file->content($row);
  390. $newRaw = $file->raw();
  391. if ($oldRaw !== $newRaw) {
  392. $file->save($row);
  393. $debugger->addMessage("Page content saved: {$newFilepath}", 'debug');
  394. } else {
  395. $debugger->addMessage('Page content has not been changed, do not update the file', 'debug');
  396. }
  397. } catch (RuntimeException $e) {
  398. $name = isset($file) ? $file->filename() : $newKey;
  399. throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $name, $e->getMessage()));
  400. } finally {
  401. /** @var UniformResourceLocator $locator */
  402. $locator = Grav::instance()['locator'];
  403. $locator->clearCache();
  404. if (isset($file)) {
  405. $file->free();
  406. unset($file);
  407. }
  408. }
  409. $row['__META'] = $this->getObjectMeta($newKey, true);
  410. return $row;
  411. }
  412. /**
  413. * Check if page folder should be deleted.
  414. *
  415. * Deleting page can be done either by deleting everything or just a single language.
  416. * If key contains the language, delete only it, unless it is the last language.
  417. *
  418. * @param string $key
  419. * @return bool
  420. */
  421. protected function canDeleteFolder(string $key): bool
  422. {
  423. // Return true if there's no language in the key.
  424. $keys = $this->extractKeysFromStorageKey($key);
  425. if (!$keys['lang']) {
  426. return true;
  427. }
  428. // Get the main key and reload meta.
  429. $key = $this->buildStorageKey($keys);
  430. $meta = $this->getObjectMeta($key, true);
  431. // Return true if there aren't any markdown files left.
  432. return empty($meta['markdown'] ?? []);
  433. }
  434. /**
  435. * Get key from the filesystem path.
  436. *
  437. * @param string $path
  438. * @return string
  439. */
  440. protected function getKeyFromPath(string $path): string
  441. {
  442. if ($this->base_path) {
  443. $path = $this->base_path . '/' . $path;
  444. }
  445. return $path;
  446. }
  447. /**
  448. * Returns list of all stored keys in [key => timestamp] pairs.
  449. *
  450. * @return array
  451. */
  452. protected function buildIndex(): array
  453. {
  454. $this->clearCache();
  455. return $this->getIndexMeta();
  456. }
  457. /**
  458. * @param string $key
  459. * @param bool $reload
  460. * @return array
  461. */
  462. protected function getObjectMeta(string $key, bool $reload = false): array
  463. {
  464. $keys = $this->extractKeysFromStorageKey($key);
  465. $key = $keys['key'];
  466. if ($reload || !isset($this->meta[$key])) {
  467. /** @var UniformResourceLocator $locator */
  468. $locator = Grav::instance()['locator'];
  469. if (mb_strpos($key, '@@') === false) {
  470. $path = $this->getStoragePath($key);
  471. if (is_string($path)) {
  472. $path = $locator->isStream($path) ? $locator->findResource($path) : GRAV_ROOT . "/{$path}";
  473. } else {
  474. $path = null;
  475. }
  476. } else {
  477. $path = null;
  478. }
  479. $modified = 0;
  480. $markdown = [];
  481. $children = [];
  482. if (is_string($path) && is_dir($path)) {
  483. $modified = filemtime($path);
  484. $iterator = new FilesystemIterator($path, $this->flags);
  485. /** @var SplFileInfo $info */
  486. foreach ($iterator as $k => $info) {
  487. // Ignore all hidden files if set.
  488. if ($k === '' || ($this->ignore_hidden && $k[0] === '.')) {
  489. continue;
  490. }
  491. if ($info->isDir()) {
  492. // Ignore all folders in ignore list.
  493. if ($this->ignore_folders && in_array($k, $this->ignore_folders, true)) {
  494. continue;
  495. }
  496. $children[$k] = false;
  497. } else {
  498. // Ignore all files in ignore list.
  499. if ($this->ignore_files && in_array($k, $this->ignore_files, true)) {
  500. continue;
  501. }
  502. $timestamp = $info->getMTime();
  503. // Page is the one that matches to $page_extensions list with the lowest index number.
  504. if (preg_match($this->regex, $k, $matches)) {
  505. $mark = $matches[2] ?? '';
  506. $ext = $matches[1] ?? '';
  507. $ext .= $this->dataExt;
  508. $markdown[$mark][Utils::basename($k, $ext)] = $timestamp;
  509. }
  510. $modified = max($modified, $timestamp);
  511. }
  512. }
  513. }
  514. $rawRoute = trim(preg_replace(PageIndex::PAGE_ROUTE_REGEX, '/', "/{$key}") ?? '', '/');
  515. $route = PageIndex::normalizeRoute($rawRoute);
  516. ksort($markdown, SORT_NATURAL | SORT_FLAG_CASE);
  517. ksort($children, SORT_NATURAL | SORT_FLAG_CASE);
  518. $file = array_key_first($markdown[''] ?? (reset($markdown) ?: []));
  519. $meta = [
  520. 'key' => $route,
  521. 'storage_key' => $key,
  522. 'template' => $file,
  523. 'storage_timestamp' => $modified,
  524. ];
  525. if ($markdown) {
  526. $meta['markdown'] = $markdown;
  527. }
  528. if ($children) {
  529. $meta['children'] = $children;
  530. }
  531. $meta['checksum'] = md5(json_encode($meta) ?: '');
  532. // Cache meta as copy.
  533. $this->meta[$key] = $meta;
  534. } else {
  535. $meta = $this->meta[$key];
  536. }
  537. $params = $keys['params'];
  538. if ($params) {
  539. $language = $keys['lang'];
  540. $template = $keys['template'] ?: array_key_first($meta['markdown'][$language]) ?? $meta['template'];
  541. $meta['exists'] = ($template && !empty($meta['children'])) || isset($meta['markdown'][$language][$template]);
  542. $meta['storage_key'] .= '|' . $params;
  543. $meta['template'] = $template;
  544. $meta['lang'] = $language;
  545. }
  546. return $meta;
  547. }
  548. /**
  549. * @return array
  550. */
  551. protected function getIndexMeta(): array
  552. {
  553. $queue = [''];
  554. $list = [];
  555. do {
  556. $current = array_pop($queue);
  557. if ($current === null) {
  558. break;
  559. }
  560. $meta = $this->getObjectMeta($current);
  561. $storage_key = $meta['storage_key'];
  562. if (!empty($meta['children'])) {
  563. $prefix = $storage_key . ($storage_key !== '' ? '/' : '');
  564. foreach ($meta['children'] as $child => $value) {
  565. $queue[] = $prefix . $child;
  566. }
  567. }
  568. $list[$storage_key] = $meta;
  569. } while ($queue);
  570. ksort($list, SORT_NATURAL | SORT_FLAG_CASE);
  571. // Update parent timestamps.
  572. foreach (array_reverse($list) as $storage_key => $meta) {
  573. if ($storage_key !== '') {
  574. $filesystem = Filesystem::getInstance(false);
  575. $storage_key = (string)$storage_key;
  576. $parentKey = $filesystem->dirname($storage_key);
  577. if ($parentKey === '.') {
  578. $parentKey = '';
  579. }
  580. /** @phpstan-var array{'storage_key': string, 'storage_timestamp': int, 'children': array<string, mixed>} $parent */
  581. $parent = &$list[$parentKey];
  582. $basename = Utils::basename($storage_key);
  583. if (isset($parent['children'][$basename])) {
  584. $timestamp = $meta['storage_timestamp'];
  585. $parent['children'][$basename] = $timestamp;
  586. if ($basename && $basename[0] === '_') {
  587. $parent['storage_timestamp'] = max($parent['storage_timestamp'], $timestamp);
  588. }
  589. }
  590. }
  591. }
  592. return $list;
  593. }
  594. /**
  595. * @return string
  596. */
  597. protected function getNewKey(): string
  598. {
  599. throw new RuntimeException('Generating random key is disabled for pages');
  600. }
  601. }