Component.php

<?php

namespace Fresh;

/**
 * ## Component
 * \Fresh\Component is the base class. It holds the views, a handler, and other settings that need to be global. It also processes form submissions before passing to your `submit` handler.
 * 
 * @export(Contribute.Component)
 */
class Component {

    use \Fresh\Readonly;

    public $dir;
    protected $name;

    protected $views = [];

    protected $handler;
    private $r_handler;
    protected $fileUploadDir;
    protected $fileUploadUrlPrefix;

    /**
     * # Create a component
     * `new \Fresh\Component($name,$dir);`
     * I'm not actually sure what the name is used for, if anything.  
     * If you `extends \Fresh\Component`, you can declare `$name` and `$dir` as protected properties then one does not need to be supplied via the constructor
     * 
     * @export(Class.Component)
     */
    public function __construct($name = null,$dir = null){
        $this->name = $name ?? $this->name;
        $this->dir = $dir ?? $this->dir;
        $this->handler = new Handler('runtime');

    }

    /**
     * ## Uploading files
     * To enable automatic file uploads, call `$component->setUpload($storageDir, $urlPrefix)`. You are responsible for routing that file.  
     * The `$urlPrefix` is used to prepend the file-url when passing formdata to your `submit` handler
     * 
     * @export(Feature.uploadFile)
     */
    public function setUpload($dir,$urlPrefix){
        $this->fileUploadDir = $dir;
        $this->fileUploadUrlPrefix = $urlPrefix;
    }   
    
    public function viewNames(){
        static $viewNames = null;
        $viewDir = $this->dir.'/view/';
        if ($viewNames===null){
            $files = (new class{
            function getFiles($path,$ext='*'){
                $dh = opendir($path);
                $files = [];
                while ($file = readdir($dh)){
                    if ($file=='.'||$file=='..')continue;
                    $fPath = str_replace('//','/',$path.'/'.$file);
                    if (is_dir($fPath)){
                        $subFiles = $this->getFiles($fPath,$ext);
                        $files = array_merge($files,$subFiles);
                    } else if ($ext=='*'
                        ||($fExt=pathinfo($fPath,PATHINFO_EXTENSION))==$ext){
                        $files[] = $fPath;
                    }
                }
                return $files;
            }
            })->getFiles($viewDir,'php');

            foreach ($files as $file){
                if (substr($file,-strlen('View.php'))==='View.php'){
                    $vn = substr($file,strlen($viewDir)-1,-4);
                    $viewNames[] = $vn;
                }
            }
            // print_r($viewNames);
            // exit;
            return $viewNames;
        } else {
            return $viewNames;
        }
    }
    /**
     * # Load a view
     * The `$passthru` will be [`extract`](https://www.php.net/manual/en/function.extract.php)ed so the the view file can access values directly by their keys.  
     * `$passthru` is optional. 
     * 
     * ```php
     * $compo = new \Fresh\Component($componentName, $componentDir);
     * $passthru = ['id'=>1];
     * $viewName = 'Note';
     * $view = $compo->view($viewName,$passthru);
     * echo $view;
     * ```
     * This will load the file at `$componentDir/view/$viewNameView.php. To be clear, your view file must end in `View.php`
     * 
     * @export(Feature.getView)
     */
    public function view($view=null,$passthru=null){
        if ($view==null)$view='View';
        if (substr($view,-4)=='Form')$view = substr($view,0,-4);
        if (substr($view,-4)=='View')$view = substr($view,0,-4);
        $view .= 'View';

        $file = $this->dir.'/view/'.$view.'.php';
        if (!file_exists($file)){
            return null;
        }
        $existing = $this->views[$file] ?? null;
        if ($existing!=null){
            if (is_array($passthru))$existing->setPassthru($passthru);
            return $existing;
        }
        if ($passthru===null)$passthru = [];
        if ($view=='View')$view = '';
        $viewObj = new View($this,$view,$passthru);
        $this->handler->addChild($viewObj);
        $this->views[$file] = $viewObj;


        return $viewObj;
    }
    
    /**
     * # Load a form
     * The `$passthru` will be [`extract`](https://www.php.net/manual/en/function.extract.php)ed so the the form file can access values directly by their keys.  
     * `$passthru` is optional. 
     * 
     * ```php
     * $compo = new \Fresh\Component($componentName, $componentDir);
     * $passthru = ['id'=>1, 'submit_url'=>'/submit/myform/'];
     * $viewName = 'Note';
     * $view = $compo->form($viewName,$passthru);
     * echo $view;
     * ```
     * This will load the file at `$componentDir/view/$viewNameForm.php. To be clear, your form file must end in `Form.php`.
     * 
     * 
     * @export(Feature.getForm)
     */
    public function form($view='',$passthru=[]){
        if ($view=='View')$view='';
        else if (substr($view,-4)=='View')$view = substr($view,0,-4);
        $view .= 'Form';

        $file = $this->dir.'/view/'.$view.'.php';
        if (!file_exists($file))return null;
        $existing = $this->views[$file] ?? null;
        if ($existing!=null){
            $existing->setPassthru($passthru);
            return $existing;
        }
        $view = substr($view,0,-strlen('Form'));
        $formObj = new Form($this,$view,$passthru);
        $this->handler->addChild($formObj);
        $this->views[$file] = $formObj;

        return $formObj;
    }

    /**
     * ## Filter submitted data
     * I haven't tested this, and I don't trust it. But here's my best notes on it.
     * 
     * This uses a `filter` handler, which you set by:
     * ```php
     * $compo->setFilterHandler(
     *     function($name, $value, $input){
     *         //modify the value, like normalizing a phone number
     *         return $modifiedValue; 
     *     }
     * )
     * ```
     * 
     * I'm confused, and not 100% sure how this works in practice.  
     * Anyway, for a `$_POST['somekey'] = 'some value';`, there should be an `<input name="somekey" />` in the source form.  
     * Your `filter` handler will be called as `$filter('somekey','some value', $domInputNode)`.  
     * If there are multiple `filter` handlers... it might break.
     * 
     * @export(Feature.filterSubmission)
     */
    public function filter($name,$value,$input){

        $filterererererer = $this->handler->compile->filter ?? null;
        if ($filterererererer==null)return $value;
        else {
            $filteredededValue = $filterererererer($name,$value,$input);
            return $filteredededValue;
        }
    }
    /**
     * ## Submitting data sort of automaticallyish
     * You must still set a `submit` handler like so:
     * ```php
     * $myDbObj = get_my_db_instance();
     * $compo->setSubmitHandler(
     *     function (string $tableName, array $dataToSave, $itemId) use ($myDbObj){
     *         $myDbObj->save($tableName,$dataToSave,$itemId);
     *     }
     * );
     * ```
     * 
     * @export(Feature.submission)
     */
    public function submit($formName,$passthru=[],$submitData=null){
//TODO how do file uploads work with this abstraction?
        if ($submitData===null)$submitData = $_POST;
        // if ($submitData===null)$submitData = $_GET;
        $data = $submitData;
        $form = $this->form($formName);
        $inputs = $form->getInputs();
        // echo 'this is where we submit... i guess.';
        // echo implode("\n",$inputs);
        
        $saveData = [];
        foreach ($inputs as $input){
            $name = $input->name;
            
            if ($input->type=='checkbox'){
                $value = ($submitData[$name]??false)=='on' ? 1 : 0;
                $filteredValue = $this->filter($name,$value,$input);
                $saveData[$name][] = $filteredValue;
                unset($submitData[$name]);
            } else if (isset($submitData[$name])){
                $value = $submitData[$name];
                $filteredValue = $this->filter($name,$value,$input);
                $saveData[$name][] = $filteredValue;
                unset($submitData[$name]);
            } else if ($input->type=='file'){
                $entry = $_FILES[$name] ?? null;
                if ($entry==null)continue;
                if ($this->fileUploadDir==null){
                    throw new \Exception("You must set File upload Info on your component to handle file uploads. Call \$component->setUpload(\$dir,\$urlPrefix)");
                }
                $permaFileName = $this->uploadFile($entry,$this->fileUploadDir);
                if ($permaFileName!==false){
                    $saveData[$name][] = str_replace('//','/',$this->fileUploadUrlPrefix.'/'.$permaFileName);
                }
                // var_dump($saveData);
                // exit;
            } 
        }
        $table = $submitData['fresh_table'] ?? null;
            unset($submitData['fresh_table']);
        $id = $submitData['id'] ?? null;
            unset($submitData['id']);
        
        if (count($submitData)>0){
            throw new \Exception("There was more data submitted than the form allows.");
        }
        if ($id!==null)$saveData['id'][] = $id;

        if ($table==null)throw new \Exception("Cannot autosubmit form because the table is null.");


        $submitter = $this->handler->runtime->submit;


        if ($submitter==null){
            throw new \Exception("There is no submitter. Call setSubmitHandler(\$callback);... "
                                ."where \$callback accepts: string \$tableName, array \$dataToSave, string|int \$itemId. "
                                ."");
        }
        return $submitter($table,$saveData,$id,$passthru);
    }

    /**
     * ## Routing, Setting things up
     * Use the setup step to do routing, basically. This requires you to append setup code to views. Here's a simple (bad) example:
     * ```php
     * $phpCode = '<?php if ($url=='/')echo $this; ?>`
     * //`$this` refers to the view which is being setup.
     * $view = $compo->view("Page_Home"); //this points to $compoDir/view/Page_HomeView.php
     * $view->appendSetupCode($phpCode);
     * ```
     * This setup code must be appended pre-compile or inside a compile handler.
     * 
     * Then to execute your setup:
     * ```php
     * $compo = new \Fresh\Component($name,$componentDir);
     * $forceRecompilation = true;// good when developing. Bad for production.
     * $compo->compile($forceRecompilation); //  Defaults to false, if you don't pass a paramater
     * $passthru = ['url'=> cleanMyUrl( $_SERVER['REQUEST_URI'] ) ];
     * $compo->setup($passthru);
     * ```
     * All your `setup` code will be run, in no particular order, and your `Page_Home` view will be displayed when `yoursite.atld/` is requested
     * 
     * @export(Feature.setup)
     */
    public function setup($passthru=[]){
        $views = $this->viewNames();
        foreach ($views as $name){
            $v = $this->view($name);
            $v->setup($passthru);
            $f = $this->form($name) ?? null;
            if ($f!=null)$f->setup($passthru);
        }
    }

    /**
     * ## Handling `rb-find` parameter.
     * You can write `rb-find` in any way you want, but the built in idea is a simple list of `key1:value1;key2:value2;`  
     * call $compo->parseLookupString($find) to break those into an array. 
     * 
     * If your key or value contains a colon (:) or semi-colon (;), that'll probably break things.
     * 
     * @export(Method.parseLookupStr)
     */
    public function parseLookupStr(string $lookupStr): \stdClass {
        $entries = explode(';',$lookupStr);
        $out = [];
        foreach ($entries as $e){
            if ($e=='')continue;
            $p = explode(':',$e);
            $out[$p[0]] = $p[1];
        }
        return (object)$out;
    }

    /**
     * ## Compiling
     * Call `$compo->compile(true)` to force re-compilation of all views & forms. Call `$compo->compile()` to use the default `$forceRecompile` value of `false` to only compile views that have changed.  
     * You should use `true` in development probably.  
     * I've had troubles with conditional recompiling (passing `false`) on my localhost, and I think it was because of file permissions issues, but I'm not sure.
     * 
     * And you can set a host of your own `compile` handlers.
     * 
     * @export(Feature.compile)
     */
    public function compile($forceRecompile=false){
        $views = $this->viewNames();
        foreach ($views as $name){
            $v = $this->view($name);
            $v->compile($forceRecompile);
            $f = $this->form($name) ?? null;
            if ($f!=null)$f->compile($forceRecompile);
        }
    }

    /**
     * See docs on the `filter()` method for more information
     */
    public function setFilterHandler($callback){
        $this->handler->setHandler('compile','filter',$callback);
    }
    /**
     * ## Submission Handler
     * 
     * 
     * @export(Handler.filter)
     */
    public function setSubmitHandler($callback){
        $this->handler->setHandler('runtime','submit',$callback);
    }
    /**
     * ## Runtime Handlers
     * Use `$compo->setRuntimeHandler(...)` or `$compo->addRuntimeHandler($handlerName,$function)`. What the handler receives varies greatly.  
     * `setRuntimeHandler` will only allow one handler per `$name` & the callback's value will be returned when the runtime handler is called.
     * `addRuntimeHandler` will allow multiple handlers. When the named handler is called, each callback's value will be stored in an array & that array will be returned.
     * 
     * ### Noteable runtime handlers:
     * - `find`: `function($table, $findProperty){return $anArrayOfObjects};`
     *    - This MUST be declared to use the entity features
     * - `format_formatterName`: `function($value){return convertToMyFormat($value);}`
     *    - `$compo->setRuntimeHandler('runtime','format_'.$formatterName,$callback);`
     *    - or `$compo->view($view)->addFormatter($name,$callback)`
     * - `addResources`: `function($viewObj, $resourcesList){...use your framework or whatever...}
     * 
     * 
     * 
     * @export(Extend.runtimeHandler)
     */
    public function setRuntimeHandler($name,$callback){
        $this->handler->setHandler('runtime',$name,$callback);
    }
    public function addRuntimeHandler($name,$callback){
        $this->handler->addHandler('runtime',$name,$callback);
    }
    /**
     * ## Compile Handlers
     * These will be run during the compile step.
     * 
     * NEED TO REDO THIS DOC FOR COMPILE HANDLERS
     * 
     * Use `$compo->addCompileHandler($handlerName,$function)`. What the handler receives varies greatly.  
     * 
     * ### Noteable Compile handlers: (name: Callable example)
     * - `preDoc`: `function($cleanSource,$viewContent,$compiler,$view){ return str_replace('<bad-tag', '<good-tag',$cleanSource); }`
     *    - This runs before the \Taeluf\PHTML (DOMDocument) instance is created
     *    - Generally, you should return a modified version of the $cleanSource
     *    - $cleanSource is the view's source code with the PHP extracted by $compiler
     *    - $viewContent is the view's source code, with inline PHP still in tact
     *    - $compiler is an instance of \Taeluf\PHTML\Compiler, which extracted the php
     *    - $view is an instance of the view object
     * - `form`: `function($formViewObj,$doc,$compiler,$formNode){$formNode->setAttribute('action','/some/url/');}`
     *    - Return values are discarded, but you can modify the objects.
     *    - I'm not sure, but some things might be overwritten by this library, such as the `action` or `method` attributes on the node.
     *    - $formViewObj is an instance of \Fresh\Form
     *    - $doc is \Taeluf\PHTML instance
     *    - $compiler is an \Taeluf\PHTML\Compiler
     *    - $formNode is an \Taeluf\PHTML\Node (DOMElement) instance, who's tag is `<form ...>`
     * 
     * 
     * @export(Extend.compileHandler)
     */
    public function addCompileHandler($name,$callback){
        $this->handler->addHandler('compile',$name,$callback);
    }
    /**
     * ### View Queries: A compile handler
     * In [my Liaison Package](https://github.com/Taeluf/Liaison-FreshCompo), I use this for routing. Here's a simplified version.
     * ```php
     * $compo->addViewQuery('//lia-route', //runs on every `<lia-route` tag
     *     function($rbDoc, $view, $compiler, $node){
     *         $route = $node->getAttribute('pattern');
     *         $escRoute = var_export($route, true); // because `$route` is only available during this compile step. 
     *         $phpCode = '<?php if ($url==$escRoute)echo $this; ?>`
     *         $view->appendSetupCode($phpCode);
     *     }
     * );
     * ```
     * This function will be run during the `compile` step for each `<lia-route` tag your view contains. No errors if not found.  
     * It will append setup code that does routing.  
     * Then when you call `$compo->setup['url'=>$theUrl]`, the setup code will run and your view will be displayed if the route matches the url.
     * 
     * @export(Feature.viewQueries)
     */
    public function addViewQuery($xpathQuery,$callback){
        $this->handler->addHandler('query','all',['xpath'=>$xpathQuery,'callback'=>$callback]);
    }
    public function addHandler($domain,$name,$data){
        $this->handlers->addHandler($domain,$name,$data);
    }





    public function uploadFile($file, $destinationFolder=null, $validExts = ['jpg', 'png','pdf'], $maxMB = 15)
    {
        if ($destinationFolder==null)$destinationFolder = $this->imageDestination;
        if (!is_array($file) || $file == []
            || $file['size'] == 0
            || $file['name'] == ''
            || $file['tmp_name'] == ''
            || !is_int($file['error'])) {
            return false;
        }

        try {
            if (!isset($file['error']) ||
                is_array($file['error'])
            ) {
                throw new RuntimeException('Invalid parameters.');
            }

            switch ($file['error']) {
                case UPLOAD_ERR_OK:
                    break;
                case UPLOAD_ERR_NO_FILE:
                    throw new RuntimeException('No file sent.');
                case UPLOAD_ERR_INI_SIZE:
                case UPLOAD_ERR_FORM_SIZE:
                    throw new RuntimeException('Exceeded filesize limit.');
                default:
                    throw new RuntimeException('Unknown errors.');
            }

            // You should also check filesize here.
            if ($file['size'] > ($maxMB * 1024 * 1024)) {
                throw new RuntimeException('Exceeded filesize limit.');
            }

            $ext = pathinfo($file['name'], PATHINFO_EXTENSION);
            if (!in_array($ext, $validExts)) {
                // var_dump($ext);
                // var_dump($validExts);
                // var_dump($file);
                // exit;
                throw new RuntimeException('Invalid file format.');
            }

            if (!file_exists($destinationFolder)) {
                mkdir($destinationFolder, 0771, true);
            }

            $fileName = sha1_file($file['tmp_name']) . '.' . $ext;
            if (!move_uploaded_file(
                $file['tmp_name'],
                $destinationFolder . '/' . $fileName
            )
            ) {
                throw new RuntimeException('Failed to move uploaded file.');
            }

            return $fileName;

        } catch (RuntimeException $e) {

            throw $e;

        }
    }


}












// this could be useful
//
//  public function submit($submitData){
//         // var_dump($this->initData);
//         // exit;
//         // $this->insertHiddenIdInput($this->node);
//         $fields = $this->getInputs();
//         $table = $this->table;
//         $filesToUpload = [];
//         foreach ($fields as $index => $data) {
//             $name = $data['name'];
//             $required = $data['required'];
//             $type = $data['type'];
//             $tag = $data['tag'];
//             $input = $data['input'];
//             if ($type=='file'){
//                 $file = $_FILES[$name]?? null;
//                 if ($file==null)$pass=false;
//                 $filesToUpload[] = ['file'=>$_FILES[$name],
//                                     'inputData'=>$data
//                                     ];
//                 unset($_FILES[$name]);
//                 if ($pass)continue;
//             } else {
//                 $pass = $this->verify($data, $submitData[$name] ?? null);
//             }
//             if (!$pass) {
//                 throw new \Exception("Input '{$name}' failed verification");
//             }
//             $saveData[$name] = $submitData[$name];
//             unset($submitData[$name]);
//         }
//         if (count($submitData) > 0) {
//             // print_r($submitData);
//             throw new \Exception("More data was sent to the server than was requested.");
//         }
//         foreach ($this->initData as $key=>$value){
//             $saveData[$key] = $value;
//         }
//         foreach ($filesToUpload as $index=>$data){
//             $file = $data['file'];
//             $inputData = $data['inputData'];
//             // print_r($this->options);
//             // exit;
//             $fileName = $this->schemaDoc->uploadFile($file);
//             if ($fileName===false)continue;
//             $urlPath = '/'.$this->schemaDoc->imageUrlPrefix.'/'.$fileName;
//             $urlPath = str_replace(['///','//'],'/',$urlPath);
//             $saveData[$inputData['name']] = $urlPath;
//         }
//         $bean = \RDB::dispense($table);
//         $bean->import($saveData);
//         \RDB::store($bean);
//     }