FormTools.php

<?php

// require_once(__DIR__.'/JSLikeHTMLElement.php');

class FormTools
{
    protected $html = '';
    protected $doc;
    protected $event;
    protected $imageDestination;
    protected $imageUrlPrefix;

    public function __construct($formHtml,$hideXmlErrors=true)
    {
        $this->html = $formHtml;
        $this->doc = $this->docFromHTML($formHtml,$hideXmlErrors);
    }
    public function setImagePaths($destinationFolder,$urlPrefix){
        $this->imageDestination = $destinationFolder;
        $this->imageUrlPrefix = $urlPrefix;
    }
    public function setEvent($event){
        $this->event = $event;
        // var_dump($event);
        // exit;
    }

    public function resultsFromLookup($type,$lookup){
        $queries = explode(';',$lookup);
        $qs = [];
        $binds = [];
        $addColumns = [];
        foreach ($queries as $q){
            $parts = explode(':',$q);
            $key = $parts[0];
            $value = $parts[1] ?? null;
            if ($value===null)continue;
            $qs[] = $key.' = :'.$key;
            $binds[':'.$key] = $value;
            $addColumns[$key] = $value;
        }
        $where = implode(' AND ',$qs);
        // var_dump($where);
        // print_r($binds);
        // echo "\n\n";
        $results = \RDB::find($type,$where,$binds);
        return ['addcolumns'=>$addColumns,'results'=>$results];
    }
    protected function removeAutoSchemaProps($node){
        $node->removeAttribute('lookup');
        $node->removeAttribute('form');
        $node->removeAttribute('view');
        $node->removeAttribute('typeof');
    }
    public function SchemaPrepareDisplay($removeSchemaProps=true){
        $hostXPath='//*[@typeof]';
        $hostAttribute='typeof';
        $childAttribute='property';

        $xPath = new DOMXpath($this->doc);
        $entities = $xPath->query($hostXPath);
        $inputs = [];
        foreach ($entities as $input) {
            $type = $input->getAttribute($hostAttribute);
            $lookup = $input->getAttribute('lookup');
            if ($lookup=='new'){
                continue;
            }
            $ret = $this->resultsFromLookup($type,$lookup);
            $results = $ret['results'];
            $addColumns = $ret['addcolumns'];
            $obj = null;
            if (count($results)>1){
                // echo "\n{$lookup}\n\n";
                // print_r($results);
                // exit;
                throw new \Exception ("There were multiple matches. Contact the web developer.");
            } 
            else $obj = array_shift($results);//[0];
            
            $parentTag = $input->tagName;
            $singleParentXpath = "//{$parentTag}[@{$hostAttribute}=\"{$type}\"][@lookup=\"{$lookup}\"]";
            
            $xPath = new DOMXpath($this->doc);
            $props = $xPath->query($singleParentXpath.'//*[@'.$childAttribute.']');
            // echo $singleParentXpath.'//*[@'.$childAttribute.']';
            if ($obj==null){
                $obj = \RDB::dispense($type);
                foreach ($props as $node){
                    $tag = strtolower($node->tagName);
                    $propName = $node->getAttribute($childAttribute);
                    if ($tag=='img'){
                        $obj->$propName = $node->getAttribute('src');
                    } else {
                        $obj->$propName = $this->nodeInnerHTML($node);
                    }
                }
                foreach ($addColumns as $key=>$value){
                    $obj->$key=$value;
                }
                \RDB::store($obj);
            } else {
                // var_dump($obj);
                foreach ($props as $node){
                    $prop = $node->getAttribute($childAttribute);
                    $newVal = $obj->$prop;
                    $this->setNodeValue($node,$newVal);
                }
            }
            if ($removeSchemaProps){
                $this->removeAutoSchemaProps($input);
            }
        }
        return $inputs;
    }
    public function SchemaPrepareEdit($popupDialog){
        $this->SchemaPrepareDisplay(FALSE);
        $xPath = new DOMXpath($this->doc);
        $entities = $xPath->query('//*[@typeof]');
        $inputs = [];
        foreach ($entities as $node) {
            $class = $node->getAttribute('class');
            $list = explode(' ',$class);
            $list[] = 'SchemaEditable';
            $node->setAttribute('class',implode(' ',$list));
            $viewName = $node->getAttribute('view');
            if ($viewName!=null){
                $formName = $viewName .= 'Form';
                if (!$node->hasAttribute('view'))$node->setAttribute('view',$viewName);
                if (!$node->hasAttribute('form'))$node->setAttribute('form',$formName);
                // var_dump($formName);
                // exit;
            }
        }
        $bodyXPath = new \DOMXpath($this->doc);
        $bodies = $bodyXPath->query('//body');
        $body = $bodies[0];
        $body->innerHTML = $body->innerHTML . $popupDialog;
        // return $inputs;
    }
    protected function docFromHTML($html,$hideXmlErrors=true){
        libxml_use_internal_errors($hideXmlErrors);
        $domDoc = new \DomDocument();
        $domDoc->registerNodeClass('DOMElement', 'JSLikeHTMLElement');
        $domDoc->loadHtml($html);
        libxml_use_internal_errors(false);
        return $domDoc;
    }
    // protected function docFromNode($node,$hideXmlErrors=true){
        // $html = $this->nodeOuterHTML($node);
        // $doc
    // }
    protected function findLookupFromForm($form){
        $lookup = $form->getAttribute('lookup');
        if ($lookup!='')return $lookup;
        $viewName = $form->getAttribute('view');
        $event = $this->event;
        if ($event==null)return null;
        $view = $event->View($viewName);
        $doc = $this->docFromHTML($view);
        $xPath = new \DOMXPath($doc);
        $formName = $form->getAttribute('form');
        $nodes = $xPath->query('//*[@form="'.$formName.'"]');
        if ($nodes->count()>1){
            throw new \Exception("Could not find lookup from form '{$formName}' because there were multiple matches in the view '{$viewName}'");
        } else if ($nodes->count()==0){
            throw new \Exception("Could not find lookup from form '{$formName}' because there were no matches in the view '{$viewName}'");
        }
        $node = $nodes->item(0);
        
        $lookup = $node->getAttribute('lookup');
        if ($lookup!='')return $lookup;

        return null;
    }
    public function SchemaPrepareForm($formName){
        $form = $this->getFormNode();
        if ($form==null)return;
        if ($formName!=null){
            $viewName = substr($form,0,-strlen('Form'));//$viewName .= 'Form';
            if (!$form->hasAttribute('view'))$form->setAttribute('view',$viewName);
            if (!$form->hasAttribute('form'))$form->setAttribute('form',$formName);
            // var_dump($formName);
            // exit;
        }
        $this->insertHiddenIdInput($form);
        $type = $form->getAttribute('name');
        $initData = $form->getAttribute('data-init');
        $lookup = $this->findLookupFromForm($form);
        if ($lookup==null){
            echo json_encode(['error'=>"Could not find a 'lookup' for the form."]);
            exit;
        }
        $viewName = $form->getAttribute('view');
        $formName = $form->getAttribute('form');
        if ($lookup=='new'){
            $obj = null;
        } else {
            $ret = $this->resultsFromLookup($type,$lookup);
            $results = $ret['results'];
            $obj = reset($results);
        }

        if ($obj==null){
            // echo json_encode(['error'=>'null object']);
            // exit;
        }
        $this->RDBPrepareEdit($type,$obj->id??null);

        if ($viewName!=null){
            $form->innerHTML = "\n".'<input type="hidden" name="view_name" value="'.$viewName.'"/>'."\n".$form->innerHTML;
        }
        if ($formName!=null){
            $form->innerHTML = "\n".'<input type="hidden" name="form_name" value="'.$formName.'"/>'."\n".$form->innerHTML;
        }
        $form->setAttribute('method','POST');
        $form->setAttribute('action','/submit_auto_form/');
        if ($this->doesHaveFileInput($form)){
            $form->setAttribute('enctype','multipart/form-data');
        }
        if ($type!=null){
            $this->addHiddenInput($form,'autoschema_type',$type);
        }
        if ($lookup!=null){
            $this->addHiddenInput($form,'autoschema_lookup',$lookup);
        }
        if ($lookup=='new'&&$initData!=null){
            $data = $this->decodeDataString($initData);
            foreach ($data as $key=>$value){
                $this->addHiddenInput($form,$key,$value);
            }
        }

    }
    protected function decodeDataString($str){
        $entries = explode(';',$str);
        $data = [];
        foreach ($entries as $e){
            $parts = explode(':',$e);
            $key = $parts[0];
            if ($key==null)continue;
            $value = $parts[1] ?? null;
            // if ($value===null)continue;
            // $qs[] = $key.' = :'.$key;
            // $binds[':'.$key] = $value;
            $data[$key] = $value;
        }
        // $where = implode(' AND ',$qs);
        return $data;
    }
    protected function addHiddenInput($form,$name,$value){
        $xPath = new \DOMXpath($this->doc);
        $existing = $xPath->query('//input[@type="hidden"][@name="'.$name.'"]', $form);
        if ($existing->count()>1){
            throw new \Exception("There are multiple hidden inputs with the same name");
        } else if ($existing->count()==0){
            $form->innerHTML = "\n".'<input type="hidden" name="'.$name.'" value="'.$value.'"/>'."\n".$form->innerHTML;
        } else {
            $input = $existing->item(0);
            $input->value = $value;
        }
    }
    protected function doesHaveFileInput($form){
        $x = new \DOMXpath($this->doc);
        $inputs = $x->query('//input[@type="file"]');
        foreach ($inputs as $input){
            $parent = $input;
            while ($parent==$parent->parentNode){
                if ($parent==$form)return true;
            }
        }
        return false;
    }
    protected function insertHiddenIdInput($form){
        $xPath = new DOMXpath($this->doc);
        $inputs = $xPath->query('//input[@name="id"][@type="hidden"]');
        if (count($inputs)!=0)return;
        $form->innerHTML = "\n".'<input type="hidden" name="id" />'."\n".$form->innerHTML;
    }
    protected function getFormNode() {
        $xPath = new DOMXpath($this->doc);
        $entities = $xPath->query('//form[@name]');
        if (count($entities)==0)return null;
        $form = $entities[0];
        return $form;
    }

    public function getValidSubmissions($userSubmissions,$ignore=[]){
        $inputs = $this->getInputs();

        $validSubmissions = [];
        foreach ($ignore as $index=>$ignoreKey){
            unset($userSubmissions[$ignoreKey]);
        }
        foreach ($inputs as $index=>$inputData){
            extract($inputData);
            $pass = $this->verify($inputData,$userSubmissions[$name]??null);
            if (!$pass){
                throw new \Exception("Input '{$name}' failed verification");
            }
            $validSubmissions[$name] = $userSubmissions[$name];
            unset($userSubmissions[$name]);
        }
        if (count($userSubmissions)>0){
            throw new \Exception("More data was sent to the server than was requested.");
        }
        return $validSubmissions;
    }

    protected function verify($inputData, $value)
    {
        //see $this->getInputs() to see what is extracted
        extract($inputData);
        if (empty($value) && $required) {
            return false;
        }

        if ($type == 'phone') {
            $clean = preg_replace('/[^0-9]/', '', $value);
            if (strlen($clean) == 10
                || strlen($clean) == 11) {
                return true;
            }
            return false;
        }
        if ($type == 'text') {
            return true;
        }
        if ($type == 'email') {
            $email = filter_var($value, FILTER_VALIDATE_EMAIL);
            if ($email === false) {
                return false;
            }

            return true;
        }

        return true;
    }

    public function getInputs()
    {
        $xPath = new DOMXpath($this->doc);
        $htmlInputs = $xPath->query('//*[@name]');
        $inputs = [];
        foreach ($htmlInputs as $input) {
            if ($input->tagName == 'form') {
                continue;
            }

            $data = [];
            $data['name'] = $input->getAttribute('name');
            $data['required'] = $input->hasAttribute('required');
            $data['type'] = $input->getAttribute('type');
            $data['tag'] = $input->tagName;
            $data['input'] = $input;
            if (isset($inputs[$data['name']])) {
                throw new \Exception("Input '" . $data['name'] . "' occurs twice on the form, which FormTools doesn't know how to handle.");
            }
            $inputs[$data['name']] = $data;
            continue;
        }
        return $inputs;
    }
    public function getFormName()
    {
        $forms = $this->doc->getElementsByTagName('form');
        if ($forms->count() > 1) {
            throw new \Exception("There are two forms... Form name cannot be retrieved.");
        } else if ($forms->count() == 0) {
            throw new \Exception("There are no forms. Cannot get form name.");
        }

        $name = $forms->item(0)->getAttribute('name');
        if ($name == null) {
            $name = null;
        }

        return $name;
    }

    public function insertPlaceholders($placeholders)
    {
        $xPath = new DOMXpath($this->doc);
        $htmlInputs = $xPath->query('//*[@name]');

        $formName = $this->getFormName();
        foreach ($htmlInputs as $input) {
            if ($input->tagName == 'form') {
                // $input->getAttribute('name');
                continue;
            }
            $name = $input->getAttribute('name');
            $ph = $placeholders[$name] ?? null;
            // var_dump($name,$ph);
            if ($ph === null) {
                continue;
            }

            $input->setAttribute('placeholder', $ph);
        }
    }

    public function nodeInnerHtml($node){
        $innerHTML = '';
        $children = $node->childNodes;
        foreach ($children as $child) {
            $innerHTML .= $child->ownerDocument->saveXML( $child );
        } 
        return $innerHTML;
    }
    public function nodeOuterHtml($node){
        $outerHTML = $node->ownerDocument->saveXML($node);
        // $children = $node->childNodes;
        // foreach ($children as $child) {
            // $outerHTML .= $child->ownerDocument->saveXML( $child );
        // } 
        return $outerHTML;
    }
    public function innerBodyHTML(){
        // this was in my old FormsPlus code...
        $output = $this->doc->saveHtml($this->doc->childNodes[1]->childNodes[0]);
        $output = substr($output,strlen('<body>'),-strlen('</body>'));
        return $output;
    }
    public function __toString()
    {
        return $this->doc->saveHtml();
    }
    public function RDBPrepareEdit($tableName = null, $id = null)
    {
        $tableName = $tableName===null ? null: preg_replace('/[^a-z]/','',strtolower($tableName));


        $domDoc = $this->doc;
        $xPath = new DOMXpath($domDoc);
        $htmlInputs = $xPath->query('//*[@name]');
        $formName = $tableName ?? $this->getFormName();
        $bean = \RDB::findOne($formName, 'id = ?', [$id ?? $_GET['id'] ?? null]);
        if ($bean == null) {
            $bean = \RDB::dispense($formName);
        }

        foreach ($htmlInputs as $input) {
            if ($input->tagName == 'form') {
                continue;
            }
            $name = $input->getAttribute('name');
            $value = $bean->$name ?? null;
            if ($value !== null) {
                // this MIGHT need to be textContent for a <textarea>
                $this->setFormInputValue($input,$value);
            }
        }
        // return $domDoc->saveHTML();
    }
    protected function setNodeValue($node,$newValue){
        $tagName = strtolower($node->tagName);
        $format = $node->getAttribute('format');
        if ($tagName=='input'){
            $this->setFormInputValue($node,$newValue);
            return;
        } else if ($tagName=='img'){
            $node->src = $newValue;
            return;
        } else if ($format=='md'){
            $converter = new \League\CommonMark\CommonMarkConverter([
                'html_input' => 'strip',
                'allow_unsafe_links' => false,
            ]);
            $newValue = $converter->convertToHtml($newValue);
        }

        $node->innerHTML = $newValue;
    }
    public function setFormInputValue($node,$value){
        $tagName = strtolower($node->tagName);
        $type = strtolower($node->getAttribute('type'));
        // echo "\n\n---------".$tagName.':'.$type."\n\n";
        // exit;
        if ($tagName=='textarea'){
            $node->innerHTML = $value;
        } else if ($tagName=='input'&&$type=='file'){
            // echo "it's a file input";
            $attr = $node->getAttribute('name');
            $queryStr = "//img[@for='{$attr}']";
            $xPath = new \DOMXPath($this->doc);
            $holderList = $xPath->query($queryStr);
            $fileHolder = $holderList[0];
            $fileHolder->setAttribute('src',$value);
            $attr = $fileHolder->getAttribute($attr);
            $fileHolder->removeAttribute($attr);
            $fileHolder->setAttribute('data-name',$attr);
            // exit;
            // echo 'fileholder';
            // exit;
        } else if ($tagName=='input'){
            $node->setAttribute('value',$value);
        } 
        return true;
    }
    public function RDBSubmit($postData, $formName = null)
    {
        $this->insertHiddenIdInput($this->getFormNode());
        $fields = $this->getInputs();
        $formName = $formName ?? $this->getFormName();
        $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, $postData[$name] ?? null);
            }
            if (!$pass) {
                throw new \Exception("Input '{$name}' failed verification");
            }
            $saveData[$name] = $postData[$name];
            unset($postData[$name]);
        }
        if (count($postData) > 0) {
            throw new \Exception("More data was sent to the server than was requested.");
        }
        foreach ($filesToUpload as $index=>$data){
            $file = $data['file'];
            $inputData = $data['inputData'];
            $fileName = static::uploadFile($file,$this->imageDestination);
            if ($fileName===false)continue;
            $urlPath = '/'.$this->imageUrlPrefix.'/'.$fileName;
            $urlPath = str_replace(['///','//'],'/',$urlPath);
            $saveData[$inputData['name']] = $urlPath;
        }
        $bean = \RDB::dispense($formName);
        $bean->import($saveData);
        \RDB::store($bean);
    }

    public static function uploadFile($file, $destinationFolder, $validExts = ['jpg', 'png'], $maxMB = 15)
    {
        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, 0775, 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;

        }
    }

}