initDataFormatter($options['formatter'] ?? []); $this->initOptions($options); // Make sure that the data folder exists. $folder = $this->resolvePath($this->dataFolder); if (!file_exists($folder)) { try { Folder::create($folder); } catch (\RuntimeException $e) { throw new \RuntimeException(sprintf('Flex: %s', $e->getMessage())); } } } /** * {@inheritdoc} * @see FlexStorageInterface::getExistingKeys() */ public function getExistingKeys(): array { return $this->buildIndex(); } /** * {@inheritdoc} * @see FlexStorageInterface::hasKey() */ public function hasKey(string $key): bool { return $key && strpos($key, '@@') === false && file_exists($this->getPathFromKey($key)); } /** * {@inheritdoc} * @see FlexStorageInterface::createRows() */ public function createRows(array $rows): array { $list = []; foreach ($rows as $key => $row) { // Create new file and save it. $key = $this->getNewKey(); $path = $this->getPathFromKey($key); $file = $this->getFile($path); $list[$key] = $this->saveFile($file, $row); } return $list; } /** * {@inheritdoc} * @see FlexStorageInterface::readRows() */ public function readRows(array $rows, array &$fetched = null): array { $list = []; foreach ($rows as $key => $row) { if (null === $row || (!\is_object($row) && !\is_array($row))) { // Only load rows which haven't been loaded before. $key = (string)$key; if (!$this->hasKey($key)) { $list[$key] = null; } else { $path = $this->getPathFromKey($key); $file = $this->getFile($path); $list[$key] = $this->loadFile($file); } if (null !== $fetched) { $fetched[$key] = $list[$key]; } } else { // Keep the row if it has been loaded. $list[$key] = $row; } } return $list; } /** * {@inheritdoc} * @see FlexStorageInterface::updateRows() */ public function updateRows(array $rows): array { $list = []; foreach ($rows as $key => $row) { $key = (string)$key; if (!$this->hasKey($key)) { $list[$key] = null; } else { $path = $this->getPathFromKey($key); $file = $this->getFile($path); $list[$key] = $this->saveFile($file, $row); } } return $list; } /** * {@inheritdoc} * @see FlexStorageInterface::deleteRows() */ public function deleteRows(array $rows): array { $list = []; foreach ($rows as $key => $row) { $key = (string)$key; if (!$this->hasKey($key)) { $list[$key] = null; } else { $path = $this->getPathFromKey($key); $file = $this->getFile($path); $list[$key] = $this->deleteFile($file); $storage = $this->getStoragePath($key); $media = $this->getMediaPath($key); $this->deleteFolder($storage, true); $media && $this->deleteFolder($media, true); } } return $list; } /** * {@inheritdoc} * @see FlexStorageInterface::replaceRows() */ public function replaceRows(array $rows): array { $list = []; foreach ($rows as $key => $row) { $key = (string)$key; if (strpos($key, '@@')) { $key = $this->getNewKey(); } $path = $this->getPathFromKey($key); $file = $this->getFile($path); $list[$key] = $this->saveFile($file, $row); } return $list; } /** * {@inheritdoc} * @see FlexStorageInterface::renameRow() */ public function renameRow(string $src, string $dst): bool { if ($this->hasKey($dst)) { throw new \RuntimeException("Cannot rename object: key '{$dst}' is already taken"); } if (!$this->hasKey($src)) { return false; } return $this->moveFolder($this->getMediaPath($src), $this->getMediaPath($dst)); } /** * {@inheritdoc} * @see FlexStorageInterface::getStoragePath() */ public function getStoragePath(string $key = null): string { if (null === $key) { $path = $this->dataFolder; } else { $path = sprintf($this->dataPattern, $this->dataFolder, $key, substr($key, 0, 2)); } return $path; } /** * {@inheritdoc} * @see FlexStorageInterface::getMediaPath() */ public function getMediaPath(string $key = null): string { return null !== $key ? \dirname($this->getStoragePath($key)) : $this->getStoragePath(); } /** * Get filesystem path from the key. * * @param string $key * @return string */ public function getPathFromKey(string $key): string { return sprintf($this->dataPattern, $this->dataFolder, $key, substr($key, 0, 2)); } /** * @param File $file * @return array|null */ protected function loadFile(File $file): ?array { if (!$file->exists()) { return null; } try { $content = (array)$file->content(); if (isset($content[0])) { throw new \RuntimeException('Broken object file.'); } } catch (\RuntimeException $e) { $content = ['__error' => $e->getMessage()]; } return $content; } /** * @param File $file * @param array $data * @return array */ protected function saveFile(File $file, array $data): array { try { $file->save($data); /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; if ($locator->isStream($file->filename())) { $locator->clearCache($file->filename()); } } catch (\RuntimeException $e) { throw new \RuntimeException(sprintf('Flex saveFile(%s): %s', $file->filename(), $e->getMessage())); } return $data; } /** * @param File $file * @return array|string */ protected function deleteFile(File $file) { try { $data = $file->content(); $file->delete(); /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; if ($locator->isStream($file->filename())) { $locator->clearCache($file->filename()); } } catch (\RuntimeException $e) { throw new \RuntimeException(sprintf('Flex deleteFile(%s): %s', $file->filename(), $e->getMessage())); } return $data; } /** * @param string $src * @param string $dst * @return bool */ protected function moveFolder(string $src, string $dst): bool { try { Folder::move($this->resolvePath($src), $this->resolvePath($dst)); /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; if ($locator->isStream($src) || $locator->isStream($dst)) { $locator->clearCache(); } } catch (\RuntimeException $e) { throw new \RuntimeException(sprintf('Flex moveFolder(%s, %s): %s', $src, $dst, $e->getMessage())); } return true; } /** * @param string $path * @param bool $include_target * @return bool */ protected function deleteFolder(string $path, bool $include_target = false): bool { try { $success = Folder::delete($this->resolvePath($path), $include_target); /** @var UniformResourceLocator $locator */ $locator = Grav::instance()['locator']; if ($locator->isStream($path)) { $locator->clearCache(); } return $success; } catch (\RuntimeException $e) { throw new \RuntimeException(sprintf('Flex deleteFolder(%s): %s', $path, $e->getMessage())); } } /** * Get key from the filesystem path. * * @param string $path * @return string */ protected function getKeyFromPath(string $path): string { return basename($path); } /** * Returns list of all stored keys in [key => timestamp] pairs. * * @return array */ protected function buildIndex(): array { $path = $this->getStoragePath(); if (!file_exists($path)) { return []; } if ($this->prefixed) { $list = $this->buildPrefixedIndexFromFilesystem($path); } else { $list = $this->buildIndexFromFilesystem($path); } ksort($list, SORT_NATURAL); return $list; } protected function buildIndexFromFilesystem($path) { $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; $iterator = new \FilesystemIterator($path, $flags); $list = []; /** @var \SplFileInfo $info */ foreach ($iterator as $filename => $info) { if (!$info->isDir()) { continue; } $key = $this->getKeyFromPath($filename); $filename = $this->getPathFromKey($key); $modified = is_file($filename) ? filemtime($filename) : null; if (null === $modified) { continue; } $list[$key] = [ 'storage_key' => $key, 'storage_timestamp' => $modified ]; } return $list; } protected function buildPrefixedIndexFromFilesystem($path) { $flags = \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS; $iterator = new \FilesystemIterator($path, $flags); $list = []; /** @var \SplFileInfo $info */ foreach ($iterator as $filename => $info) { if (!$info->isDir() || strpos($info->getFilename(), '.') === 0) { continue; } $list[] = $this->buildIndexFromFilesystem($filename); } if (!$list) { return []; } return \count($list) > 1 ? array_merge(...$list) : $list[0]; } /** * @return string */ protected function getNewKey(): string { // Make sure that the file doesn't exist. do { $key = $this->generateKey(); } while (file_exists($this->getPathFromKey($key))); return $key; } /** * @param array $options */ protected function initOptions(array $options): void { $extension = $this->dataFormatter->getDefaultFileExtension(); /** @var string $pattern */ $pattern = !empty($options['pattern']) ? $options['pattern'] : $this->dataPattern; $this->dataFolder = $options['folder']; $this->prefixed = (bool)($options['prefixed'] ?? strpos($pattern, '/{KEY:2}/')); $this->indexed = (bool)($options['indexed'] ?? false); $this->keyField = $options['key'] ?? 'storage_key'; $pattern = preg_replace(['/{FOLDER}/', '/{KEY}/', '/{KEY:2}/'], ['%1$s', '%2$s', '%3$s'], $pattern); $this->dataPattern = \dirname($pattern) . '/' . basename($pattern, $extension) . $extension; } }