nestedSeparator = '/'; $this->filename = $filename; $this->items = $items; } /** * Set filename for the blueprint. Can also be array of files for parent lookup. * * @param string|array $filename * @return $this */ public function setFilename($filename) { $this->filename = $filename; return $this; } /** * Get the filename of the blueprint. * * @return array|null|string */ public function getFilename() { return $this->filename; } /** * Set context for import@ and extend@. * * @param $context * @return $this */ public function setContext($context) { $this->context = $context; return $this; } /** * Set custom overrides for import@ and extend@. * * @param array $overrides * @return $this */ public function setOverrides($overrides) { $this->overrides = $overrides; return $this; } /** * Load blueprint. * * @return $this */ public function load() { // Only load and extend blueprint if it has not yet been loaded. if (empty($this->items) && $this->filename) { // Get list of files. $files = $this->getFiles($this->filename); // Load and extend blueprints. $data = $this->doLoad($files); $this->items = (array) array_shift($data); foreach ($data as $content) { $this->extend($content, true); } } // Import blueprints. $this->deepInit($this->items); return $this; } /** * Initialize blueprints with its dynamic fields. * * @return $this */ public function init() { foreach ($this->dynamic as $key => $data) { // Locate field. $path = explode('/', $key); $current = &$this->items; foreach ($path as $field) { if (is_object($current)) { // Handle objects. if (!isset($current->{$field})) { $current->{$field} = array(); } $current = &$current->{$field}; } else { // Handle arrays and scalars. if (!is_array($current)) { $current = array($field => array()); } elseif (!isset($current[$field])) { $current[$field] = array(); } $current = &$current[$field]; } } // Set dynamic property. foreach ($data as $property => $call) { $action = 'dynamic' . ucfirst($call['action']); if (method_exists($this, $action)) { $this->{$action}($current, $property, $call); } } } return $this; } /** * Get form. * * @return array */ public function form() { return (array) $this->get('form'); } /** * Get form fields. * * @return array */ public function fields() { return (array) $this->get('form/fields'); } /** * Extend blueprint with another blueprint. * * @param BlueprintForm|array $extends * @param bool $append * @return $this */ public function extend($extends, $append = false) { if ($extends instanceof BlueprintForm) { $extends = $extends->toArray(); } if ($append) { $a = $this->items; $b = $extends; } else { $a = $extends; $b = $this->items; } $this->items = $this->deepMerge($a, $b); return $this; } /** * @param string $name * @param mixed $value * @param string $separator * @param bool $append * @return $this */ public function embed($name, $value, $separator = '/', $append = false) { $oldValue = $this->get($name, null, $separator); if (is_array($oldValue) && is_array($value)) { if ($append) { $a = $oldValue; $b = $value; } else { $a = $value; $b = $oldValue; } $value = $this->deepMerge($a, $b); } $this->set($name, $value, $separator); return $this; } /** * Get blueprints by using dot notation for nested arrays/objects. * * @example $value = $this->resolve('this.is.my.nested.variable'); * returns ['this.is.my', 'nested.variable'] * * @param array $path * @param string $separator * @return array */ public function resolve(array $path, $separator = '/') { $fields = false; $parts = []; $current = $this['form.fields']; $result = [null, null, null]; while (($field = current($path)) !== null) { if (!$fields && isset($current['fields'])) { if (!empty($current['array'])) { $result = [$current, $parts, $path ? implode($separator, $path) : null]; // Skip item offset. $parts[] = array_shift($path); } $current = $current['fields']; $fields = true; } elseif (isset($current[$field])) { $parts[] = array_shift($path); $current = $current[$field]; $fields = false; } elseif (isset($current['.' . $field])) { $parts[] = array_shift($path); $current = $current['.' . $field]; $fields = false; } else { break; } } return $result; } /** * Deep merge two arrays together. * * @param array $a * @param array $b * @return array */ protected function deepMerge(array $a, array $b) { $bref_stack = array(&$a); $head_stack = array($b); do { end($bref_stack); $bref = &$bref_stack[key($bref_stack)]; $head = array_pop($head_stack); unset($bref_stack[key($bref_stack)]); foreach (array_keys($head) as $key) { if (!empty($key) && ($key[0] === '@' || $key[strlen($key) - 1] === '@')) { $list = explode('-', trim($key, '@'), 2); $action = array_shift($list); if ($action === 'unset' || $action === 'replace') { $property = array_shift($list); if (!$property) { $bref = ['unset@' => true]; } else { unset($bref[$property]); } continue; } } if (isset($key, $bref[$key]) && is_array($bref[$key]) && is_array($head[$key])) { $bref_stack[] = &$bref[$key]; $head_stack[] = $head[$key]; } else { $bref = array_merge($bref, [$key => $head[$key]]); } } } while (count($head_stack)); return $a; } /** * @param array $items * @param array $path * @return string */ protected function deepInit(array &$items, $path = []) { $ordering = ''; $order = []; $field = end($path) === 'fields'; foreach ($items as $key => &$item) { // Set name for nested field. if ($field && isset($item['type'])) { $item['name'] = $key; } // Handle special instructions in the form. if (!empty($key) && ($key[0] === '@' || $key[strlen($key) - 1] === '@')) { $list = explode('-', trim($key, '@'), 2); $action = array_shift($list); $property = array_shift($list); switch ($action) { case 'unset': unset($items[$key]); if (empty($items)) { return null; } break; case 'import': $this->doImport($item, $path); unset($items[$key]); break; case 'ordering': $ordering = $item; unset($items[$key]); break; default: $this->dynamic[implode('/', $path)][$property] = ['action' => $action, 'params' => $item]; } } elseif (is_array($item)) { // Recursively initialize form. $newPath = array_merge($path, [$key]); $location = $this->deepInit($item, $newPath); if ($location) { $order[$key] = $location; } elseif ($location === null) { unset($items[$key]); } } } if ($order) { // Reorder fields if needed. $items = $this->doReorder($items, $order); } return $ordering; } /** * @param array|string $value * @param array $path */ protected function doImport(&$value, array &$path) { $type = !is_string($value) ? !isset($value['type']) ? null : $value['type'] : $value; $files = $this->getFiles($type, isset($value['context']) ? $value['context'] : null); if (!$files) { return; } /** @var BlueprintForm $blueprint */ $blueprint = new static($files); $blueprint->setContext($this->context)->setOverrides($this->overrides)->load(); $name = implode('/', $path); $this->embed($name, $blueprint->form(), '/', false); } /** * Internal function that handles loading extended blueprints. * * @param array $files * @return array */ protected function doLoad(array $files) { $filename = array_shift($files); $content = $this->loadFile($filename); if (isset($content['extends@'])) { $key = 'extends@'; } elseif (isset($content['@extends'])) { $key = '@extends'; } elseif (isset($content['@extends@'])) { $key = '@extends@'; } $data = isset($key) ? $this->doExtend($filename, $files, (array) $content[$key]) : []; if (isset($key)) { unset($content[$key]); } $data[] = $content; return $data; } /** * Internal function to recursively load extended blueprints. * * @param string $filename * @param array $parents * @param array $extends * @return array */ protected function doExtend($filename, array $parents, array $extends) { if (is_string(key($extends))) { $extends = [$extends]; } $data = []; foreach ($extends as $value) { // Accept array of type and context or a string. $type = !is_string($value) ? !isset($value['type']) ? null : $value['type'] : $value; if (!$type) { continue; } if ($type === '@parent' || $type === 'parent@') { if (!$parents) { throw new \RuntimeException("Parent blueprint missing for '{$filename}'"); } $files = $parents; } else { $files = $this->getFiles($type, isset($value['context']) ? $value['context'] : null); // Detect extend loops. if ($files && array_intersect($files, $parents)) { // Let's check if user really meant extends@: parent@. $index = array_search($filename, $files); if ($index !== false) { // We want to grab only the parents of the file which is currently being loaded. $files = array_slice($files, $index + 1); } if ($files !== $parents) { throw new \RuntimeException("Loop detected while extending blueprint file '{$filename}'"); } if (!$parents) { throw new \RuntimeException("Parent blueprint missing for '{$filename}'"); } } } if ($files) { $data = array_merge($data, $this->doLoad($files)); } } return $data; } /** * Internal function to reorder items. * * @param array $items * @param array $keys * @return array */ protected function doReorder(array $items, array $keys) { $reordered = array_keys($items); foreach ($keys as $item => $ordering) { if ((string)(int) $ordering === (string) $ordering) { $location = array_search($item, $reordered); $rel = array_splice($reordered, $location, 1); array_splice($reordered, $ordering, 0, $rel); } elseif (isset($items[$ordering])) { $location = array_search($item, $reordered); $rel = array_splice($reordered, $location, 1); $location = array_search($ordering, $reordered); array_splice($reordered, $location + 1, 0, $rel); } } return array_merge(array_flip($reordered), $items); } }