Phad.php

<?php

class Phad {

    // contains submit(), delete() & utility functions for these
    use Phad\Submission;
    // idk, does routing stuff?
    use Phad\Routes;
    // contains can_read_data(), can_read_row(), and can_delete()
    use Phad\Can_Do;

    // contains extra utility functions
    use Phad\Utility;
    use Phad\SpamControl;

    public ?\PDO $pdo = null;
    

    public $route_prefix = '';

    /**
     * array of configs, typically from an on-disk json file. These are not directly used by Phad, but may be useful to subclasses or integrations.
     */
    public array $configs = [];
    /**
     * sitemap builder instance
     */
    public \Phad\SitemapBuilder $sitemap;
    /**
     * a router instance
     */
    public \Lia\Addon\Router $router;

    /**
     * Callback handlers. Key should be the handler name & value should be a callable
     */ 
    public $handlers = [
        /** should accept a single string representing the role (or roles) being checked. 
        * As a suggestion, roles should be like 'guest' or 'admin|moderator|vip' for multiple */
        'user_has_role'=>null,
        /** should accept a single ItemInfo object (stdClass) */
        'item_initialized'=>null,
    ];

    /** 
     * array of callables that return an array of rows.
     * Each callable should accept args `(DomNode, ItemInfo)` & return an array of rows.
     */
    public $data_loaders = [];

    /**
     * set false to stop phad from `exit`ing when calling `->redirect()` 
     * You can custom handle the `header()` call by creating a `header()` function in the `Phad` namespace. 
     *
     * See https://akrabat.com/replacing-a-built-in-php-function-when-testing-a-component/ to understand the `Phad\header()` thing 
     */
    public bool $exit_on_redirect = true;

    /**
     * true to always re-compile views
     */
    public bool $force_compile = false;
    /**
     * args to pass to every phad view
     */
    public $global_phad_args = [];
    /**
     * `key=>value` array of filters where `key` is the filter you write in the html & `value` is a callable
     * @feature(ValueFilter) Create a filter by setting `$phad->filters['filter_name'] = callable;`, then declare `<section prop="body" filter="filter_name"`></section>` to use it
     */
    public $filters = [
        // 'commonmark:markdownToHtml'=>'markdownFilter',
    ];

    /** Absolute path to a directory that contains phad items */
    public $item_dir;

    /** Absolute path to a directory to store cached files */
    public $cache_dir;

    /** Dir to write sitemap.xml file to */
    public $sitemap_dir;
 
    /** true to throw exception when query failes. false to silently fail & return false  */
    public bool $throw_on_query_failure = false;

    /** an object that is used to make phad work with liaison */
    public $integration;

    /** array of functions that handle access operations */
    public $access_handlers = [];

    /**
     * array of handlers for sitemap building
     */
    public $sitemap_handlers = [];

    /** array of validation functions
     * @key should correspond to a `validate="key"` attribute on an html node.
     * @value should be a function with the signature `function($property_value, $property_settings, &$errors): bool`
     */
    public $validators = [];


    public function __construct(){
        $this->handlers['item_initialized'] = function(){};
        if (class_exists('\League\CommonMark\CommonMarkConverter',true)){
            $this->filters['commonmark:markdownToHtml']=[$this,'filter_markdown'];
        }

    }

    public function __call($method,$args){
        if (empty($this->handlers[$method])){
            throw new \BadMethodCallException("Handler '$method' is not set on Phad. do \$phad->handlers['$method'] = function(...){};\n");
        }

        $callable = $this->handlers[$method];
        return $callable(...$args);

    }


    /**
     * Use the given handler for hooks phad requires you to handle
     */
    public function set_handler(\Phad\Handler $handler){
        $this->handlers['user_has_role'] = [$handler, 'user_has_role'];
        $this->handlers['can_read_row'] = [$handler, 'can_read_row'];
    }



    /**
     * @return an array of rows
     *
     * Options: args includes `['ItemName'=>$row]` or `['ItemNameList'=>[$row1,$row2,$r3,....]` or pass in args required by your data node to run a query
     */
    public function read_data($node, $ItemInfo){
        // var_dump($node);
        if ($node['type']=='default'){
            $name = $ItemInfo->name;
            if (isset($ItemInfo->args[$name]))return [$ItemInfo->args[$name]];
            else if (isset($ItemInfo->args[$name.'List'])) return $ItemInfo->args[$name.'List'];
            else if ($ItemInfo->type=='form'&&$ItemInfo->mode==\Phad\Blocks::FORM_SUBMIT){
                return [$_POST];
            } else if ($ItemInfo->type=='form'&&isset($_GET['id'])){
                // echo 'zeep';exit;

            }
            else if ($ItemInfo->type=='form'){
                // `object_from_row()` will then create `BlackHole` object
                return [['_object'=>'Phad\\BlackHole']];
            }
        } else if (isset($node['data_loader'])){
            $key = $node['data_loader'];
            if (!isset($this->data_loaders[$key]))throw new \Exception("There is no data loader for key '$key'. Set `\$phad->data_loaders['$key']` to a callable that accepts (DOMNode, ItemInfo) & returns an array of rows.");
            $rows = $this->data_loaders[$key]($node, $ItemInfo);

            $final_rows = [];
            foreach ($rows as $row){
                if ($this->can_read_row($row, $ItemInfo, $ItemInfo->name))$final_rows[] = $row;
            }
            return $final_rows;
        }

        $query = new \Phad\Query();
        $query->pdo = &$this->pdo;
        $query->throw_on_query_failure = &$this->throw_on_query_failure;

        $node = $this->modify_query_info($node);
        $rows = $query->get($ItemInfo->name, $node, $ItemInfo->args, $ItemInfo->type, $node);
        if ($rows===false){
            // echo 'no rows';
            // exit;
            return [];
        }

        $final_rows = [];
        foreach ($rows as $row){
            if ($this->can_read_row($row, $ItemInfo, $ItemInfo->name))$final_rows[] = $row;
        }
        return $final_rows;
    }

    /**
     * Modify a query before getting rows
     *
     * @param $query_info an array with keys sql, limit, orderby, where, etc... if 'sql' is set, the others are ignored. See \Phad\Query->buildSql() for more infromation.
     *
     * @override to provide custom query modifications
     *
     * @return array of query info
     */
    public function modify_query_info(array $query_info){
        return $query_info;
    }


    /**
     * @override
     */
    public function object_from_row(array $row, $ItemInfo){
        //@issue(jan 31, 2022) a jank "fix" bc i don't have union types in php 7.4 & the union would be for an `ArrayObject|array` 
        if (isset($row['_object'])&&$row['_object']=='Phad\\BlackHole')return new \Phad\BlackHole();
        return (object)$row;
    }



    public function has_item($name){
        return file_exists($this->item_dir.'/'.$name.'.php');
    }
    public function item($name, $args=[]){
        $args['phad'] = $args['phad'] ?? $this;
        $args['is_route'] = $args['is_route'] ?? false;
        foreach ($this->global_phad_args as $k=>$v){
            if (!isset($args[$k]))$args[$k] = $v;
        }
        $item = new \Phad\Item($name, $this->item_dir, $args);
        $item->force_compile = $this->force_compile;

        return $item;
    }

    /**
     * get an item instance from a file
     *
     * This does not set up any routing
     */
    public function item_from_file(string $file_path, array $args=[]){
        $args['phad'] = $args['phad'] ?? $this;
        foreach ($this->global_phad_args as $k=>$v){
            if (!isset($args[$k]))$args[$k] = $v;
        }
        // remove .php from the file path, for the item 'name'
        $item = new \Phad\Item(substr(basename($file_path),0,-4), dirname($file_path), $args);
        $item->templateFile = $file_path;

        return $item;
    }


    /**
     *
     * @param $filterName the name of the filter to pass `$value` through
     * @param $value the value you wish to modify
     *
     * @throws if `$filterName` is not set
     * @throws if `$filterName` points to a non-callable
     */
    public function filter(string $filterName, $value){
        //conditional namespacing would be nice, so the ns: prefix can be left off when there are no conflicts
        if (!isset($this->filters[$filterName])){
            throw new \Exception("Filter '{$filterName}' is not set.");
        }
        $filter = $this->filters[$filterName];
        if (!is_callable($filter)){
            throw new \Exception("Filter '{$filterName}' is not callable.");
        }
        $filtered = $filter($value);
        return $filtered;
    }

    /**
     * Apply commonmark conversion to the value, turning markdown into html
     * @param $markdown the value which is markdown and should become html
     */
    public function filter_markdown($markdown){
        $converter = new \League\CommonMark\CommonMarkConverter([
            'html_input' => 'strip',
            'allow_unsafe_links' => false,
        ]);

        if (method_exists($converter,'convert')){
            $html = $converter->convert($markdown);
        } else {
            $html = $converter->convertToHtml($markdown);
        }
        return $html;
    }

    /**
     * @override to customize how item rows are returned
     */
    public function get_rows($ItemInfo){
        $rows = [];
        foreach ($ItemInfo->rows as $row){
            if ($this->can_read_row($row, $ItemInfo, $ItemInfo->name)){
                $rows[] = $row;
            }
        }
        return $rows;
    }

    /** 
     * Just boilerplate to make phad easier to initialize
     */
    static public function main($options = [], $custom_phad=null){
        $class = static::class;
        $phad = $custom_phad ?? new $class();

        $phad->configs = $options;
        foreach ($options as $k=>$v){
            $phad->$k = $v;
        }

        $phad->sitemap = new \Phad\SitemapBuilder($phad->sitemap_dir);
            $phad->sitemap->cache_dir = &$phad->cache_dir;
            $phad->sitemap->pdo = &$phad->pdo;
            $phad->sitemap->throw_on_query_failure = &$phad->throw_on_query_failure;
            $phad->sitemap->router = &$phad->router;
            $phad->sitemap->handlers = &$phad->sitemap_handlers;
        // $phad->filter = new \Phad\Filter();
        // $phad->access = new \Phad\Access();
        //     $phad->access->pdo = &$phad->pdo;
        //     $phad->access->throw_on_query_failure = &$phad->throw_on_query_failure;
        //     $phad->access->user = &$phad->user;
        // $phad->stack = new \Phad\Stack();
        // $phad->form = new \Phad\FormValidator();
        //     $phad->form->validators = &$phad->validators;
        //     $phad->form->throw_submission_error = &$phad->throw_submission_error;
        // $phad->submitter = new \Phad\PDOSubmitter();
            // $phad->submitter->pdo = &$phad->pdo;
            // $phad->submitter->lildb = new \Tlf\LilDb($phad->pdo);
        $phad->integration = new \Phad\Integration();
            $phad->integration->phad = $phad;
            $phad->integration->force_compile = &$phad->force_compile;


        
        return $phad;
    }

    /**
     * Make a sitemap file from all views
     * @todo add caching of the sitemap file
     * @return path to the new sitemap file
     */
    public function create_sitemap_file(): string{
        if (file_exists($this->sitemap_dir.'/sitemap.xml')){
            unlink($this->sitemap_dir.'/sitemap.xml');
        }

        $items = $this->get_all_items();

        $sm_builder = $this->sitemap;

        $sm_list = $sm_builder->get_sitemap_list($items, $this);
        $sm_builder->make_sitemap($sm_list);

        return $this->sitemap_dir.'/sitemap.xml';
    }

    public function compile_all_items(){
        $items = $this->get_all_items();
        foreach ($items as $i){
            $item = $this->item($i,[]);
            $item->compile();
        }
    }


    /**
     *
     * call a method that's defined in strings like `call:check_is_2022` (would call `$this->access_handler['check_is_2022']` if it is set 
     *
     * @param $handler the name of the access_handler to call
     * @param ...$args a list of args to pass to the access_handler
     * @throws exception if access handler is not set
     * @todo rename access_handlers to call_handlers
     */
    public function call($handler, ...$args){
        if (!isset($this->access_handlers[$handler])){
            throw new \Exception("No access handler set for '$handler'. Do \$phad->access_handlers['$handler'] = `function(...\$args){}`\nIt should return true/false for access/candelete. Argslist varies by caller");
        }
        $callable = $this->access_handlers[$handler];
        // $count = count($Info->submit_errors);
        return $callable(...$args);
    }

    /**
     * parse a string like `print:woohoo;call:somethin;role:admin;`
     * @return array with key=function and value=args like `key=print` & `value=woohoo`
     */
    public function parse_functions(?string $call_string){
        if ($call_string===null)return [];
        $functions = explode(';', $call_string);
        if (count($functions)==1&&trim($functions[0])=='')return [];
        // print_r($functions);
        // var_dump($call_string);
        // exit;
        foreach ($functions as $f){
            $f = trim($f);
            if ($f=='')continue;
            $parts = explode(':', $f, 2);
            $method = $parts[0];

            $arg = $parts[1];
            $out[$method] = $arg;
        }

        return $out;
    }


    /**
     * Handle when no rows were loaded. This method is for overriding and does literally nothing otherwise.
     *
     * @param $ItemInfo an item info object
     * @output is optional and likely would contain some kind of error message.
     */
    public function no_rows_loaded(stdClass $ItemInfo){

    }

    /**
     * Handle when a node cannot be read
     *
     * @param $node the node info
     * @output is optional and may contain some kind of error output
     */
    public function read_node_failed(array $node){

    }

    /**
     * replace each `$value` with `htmlspecialchars($value)`
     * @param &$row a row of data, ideally prior to submission
     */
    public function sanitize_user_row(array &$row){
        foreach (array_keys($row) as $key){
            if (!is_numeric($key));
            $row[$key] = htmlspecialchars($row[$key]);
        }
    }
}