PageStorage.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @package Grav\Common\Flex
  5. *
  6. * @copyright Copyright (c) 2015 - 2021 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\Framework\Filesystem\Filesystem;
  16. use Grav\Framework\Flex\Storage\FolderStorage;
  17. use RocketTheme\Toolbox\File\MarkdownFile;
  18. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  19. use RuntimeException;
  20. use SplFileInfo;
  21. use function assert;
  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 = 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('No storage key given');
  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. $this->copyRow($oldKey, $newKey);
  346. $debugger->addMessage("Page copied: {$oldFolder} => {$newFolder}", 'debug');
  347. } else {
  348. $this->renameRow($oldKey, $newKey);
  349. $debugger->addMessage("Page moved: {$oldFolder} => {$newFolder}", 'debug');
  350. }
  351. }
  352. // Check if filename has changed.
  353. if ($oldFilename !== $newFilename) {
  354. // Get instance of the old file (we have already copied/moved it).
  355. $oldFilepath = "{$newFolder}/{$oldFilename}";
  356. $file = $this->getFile($oldFilepath);
  357. // Rename the file if we aren't supposed to clone it.
  358. $isClone = $row['__META']['clone'] ?? false;
  359. if (!$isClone && $file->exists()) {
  360. /** @var UniformResourceLocator $locator */
  361. $locator = $grav['locator'];
  362. $toPath = $locator->isStream($newFilepath) ? $locator->findResource($newFilepath, true, true) : GRAV_ROOT . "/{$newFilepath}";
  363. $success = $file->rename($toPath);
  364. if (!$success) {
  365. throw new RuntimeException("Changing page template failed: {$oldFilepath} => {$newFilepath}");
  366. }
  367. $debugger->addMessage("Page template changed: {$oldFilename} => {$newFilename}", 'debug');
  368. } else {
  369. $file = null;
  370. $debugger->addMessage("Page template created: {$newFilename}", 'debug');
  371. }
  372. }
  373. }
  374. // Clean up the data to be saved.
  375. $this->prepareRow($row);
  376. unset($row['__META'], $row['__ERROR']);
  377. if (!isset($file)) {
  378. $file = $this->getFile($newFilepath);
  379. }
  380. // Compare existing file content to the new one and save the file only if content has been changed.
  381. $file->free();
  382. $oldRaw = $file->raw();
  383. $file->content($row);
  384. $newRaw = $file->raw();
  385. if ($oldRaw !== $newRaw) {
  386. $file->save($row);
  387. $debugger->addMessage("Page content saved: {$newFilepath}", 'debug');
  388. } else {
  389. $debugger->addMessage('Page content has not been changed, do not update the file', 'debug');
  390. }
  391. } catch (RuntimeException $e) {
  392. $name = isset($file) ? $file->filename() : $newKey;
  393. throw new RuntimeException(sprintf('Flex saveRow(%s): %s', $name, $e->getMessage()));
  394. } finally {
  395. /** @var UniformResourceLocator $locator */
  396. $locator = Grav::instance()['locator'];
  397. $locator->clearCache();
  398. if (isset($file)) {
  399. $file->free();
  400. unset($file);
  401. }
  402. }
  403. $row['__META'] = $this->getObjectMeta($newKey, true);
  404. return $row;
  405. }
  406. /**
  407. * @param string $key
  408. * @return bool
  409. */
  410. protected function canDeleteFolder(string $key): bool
  411. {
  412. $keys = $this->extractKeysFromStorageKey($key);
  413. if ($keys['lang']) {
  414. return false;
  415. }
  416. return true;
  417. }
  418. /**
  419. * Get key from the filesystem path.
  420. *
  421. * @param string $path
  422. * @return string
  423. */
  424. protected function getKeyFromPath(string $path): string
  425. {
  426. if ($this->base_path) {
  427. $path = $this->base_path . '/' . $path;
  428. }
  429. return $path;
  430. }
  431. /**
  432. * Returns list of all stored keys in [key => timestamp] pairs.
  433. *
  434. * @return array
  435. */
  436. protected function buildIndex(): array
  437. {
  438. $this->clearCache();
  439. return $this->getIndexMeta();
  440. }
  441. /**
  442. * @param string $key
  443. * @param bool $reload
  444. * @return array
  445. */
  446. protected function getObjectMeta(string $key, bool $reload = false): array
  447. {
  448. $keys = $this->extractKeysFromStorageKey($key);
  449. $key = $keys['key'];
  450. if ($reload || !isset($this->meta[$key])) {
  451. /** @var UniformResourceLocator $locator */
  452. $locator = Grav::instance()['locator'];
  453. if (mb_strpos($key, '@@') === false) {
  454. $path = $this->getStoragePath($key);
  455. if (is_string($path)) {
  456. $path = $locator->isStream($path) ? $locator->findResource($path) : GRAV_ROOT . "/{$path}";
  457. } else {
  458. $path = null;
  459. }
  460. } else {
  461. $path = null;
  462. }
  463. $modified = 0;
  464. $markdown = [];
  465. $children = [];
  466. if (is_string($path) && file_exists($path)) {
  467. $modified = filemtime($path);
  468. $iterator = new FilesystemIterator($path, $this->flags);
  469. /** @var SplFileInfo $info */
  470. foreach ($iterator as $k => $info) {
  471. // Ignore all hidden files if set.
  472. if ($k === '' || ($this->ignore_hidden && $k[0] === '.')) {
  473. continue;
  474. }
  475. if ($info->isDir()) {
  476. // Ignore all folders in ignore list.
  477. if ($this->ignore_folders && in_array($k, $this->ignore_folders, true)) {
  478. continue;
  479. }
  480. $children[$k] = false;
  481. } else {
  482. // Ignore all files in ignore list.
  483. if ($this->ignore_files && in_array($k, $this->ignore_files, true)) {
  484. continue;
  485. }
  486. $timestamp = $info->getMTime();
  487. // Page is the one that matches to $page_extensions list with the lowest index number.
  488. if (preg_match($this->regex, $k, $matches)) {
  489. $mark = $matches[2] ?? '';
  490. $ext = $matches[1] ?? '';
  491. $ext .= $this->dataExt;
  492. $markdown[$mark][basename($k, $ext)] = $timestamp;
  493. }
  494. $modified = max($modified, $timestamp);
  495. }
  496. }
  497. }
  498. $rawRoute = trim(preg_replace(PageIndex::PAGE_ROUTE_REGEX, '/', "/{$key}") ?? '', '/');
  499. $route = PageIndex::normalizeRoute($rawRoute);
  500. ksort($markdown, SORT_NATURAL | SORT_FLAG_CASE);
  501. ksort($children, SORT_NATURAL | SORT_FLAG_CASE);
  502. $file = array_key_first($markdown[''] ?? (reset($markdown) ?: []));
  503. $meta = [
  504. 'key' => $route,
  505. 'storage_key' => $key,
  506. 'template' => $file,
  507. 'storage_timestamp' => $modified,
  508. ];
  509. if ($markdown) {
  510. $meta['markdown'] = $markdown;
  511. }
  512. if ($children) {
  513. $meta['children'] = $children;
  514. }
  515. $meta['checksum'] = md5(json_encode($meta) ?: '');
  516. // Cache meta as copy.
  517. $this->meta[$key] = $meta;
  518. } else {
  519. $meta = $this->meta[$key];
  520. }
  521. $params = $keys['params'];
  522. if ($params) {
  523. $language = $keys['lang'];
  524. $template = $keys['template'] ?: array_key_first($meta['markdown'][$language]) ?? $meta['template'];
  525. $meta['exists'] = ($template && !empty($meta['children'])) || isset($meta['markdown'][$language][$template]);
  526. $meta['storage_key'] .= '|' . $params;
  527. $meta['template'] = $template;
  528. $meta['lang'] = $language;
  529. }
  530. return $meta;
  531. }
  532. /**
  533. * @return array
  534. */
  535. protected function getIndexMeta(): array
  536. {
  537. $queue = [''];
  538. $list = [];
  539. do {
  540. $current = array_pop($queue);
  541. if ($current === null) {
  542. break;
  543. }
  544. $meta = $this->getObjectMeta($current);
  545. $storage_key = $meta['storage_key'];
  546. if (!empty($meta['children'])) {
  547. $prefix = $storage_key . ($storage_key !== '' ? '/' : '');
  548. foreach ($meta['children'] as $child => $value) {
  549. $queue[] = $prefix . $child;
  550. }
  551. }
  552. $list[$storage_key] = $meta;
  553. } while ($queue);
  554. ksort($list, SORT_NATURAL | SORT_FLAG_CASE);
  555. // Update parent timestamps.
  556. foreach (array_reverse($list) as $storage_key => $meta) {
  557. if ($storage_key !== '') {
  558. $filesystem = Filesystem::getInstance(false);
  559. $storage_key = (string)$storage_key;
  560. $parentKey = $filesystem->dirname($storage_key);
  561. if ($parentKey === '.') {
  562. $parentKey = '';
  563. }
  564. /** @phpstan-var array{'storage_key': string, 'storage_timestamp': int, 'children': array<string, mixed>} $parent */
  565. $parent = &$list[$parentKey];
  566. $basename = basename($storage_key);
  567. if (isset($parent['children'][$basename])) {
  568. $timestamp = $meta['storage_timestamp'];
  569. $parent['children'][$basename] = $timestamp;
  570. if ($basename && $basename[0] === '_') {
  571. $parent['storage_timestamp'] = max($parent['storage_timestamp'], $timestamp);
  572. }
  573. }
  574. }
  575. }
  576. return $list;
  577. }
  578. /**
  579. * @return string
  580. */
  581. protected function getNewKey(): string
  582. {
  583. throw new RuntimeException('Generating random key is disabled for pages');
  584. }
  585. }