#!/usr/bin/env php '0;30', 'dark_gray' => '1;30', 'blue' => '0;34', 'light_blue' => '1;34', 'green' => '0;32', 'light_green' => '1;32', 'cyan' => '0;36', 'light_cyan' => '1;36', 'red' => '0;31', 'light_red' => '1;31', 'purple' => '0;35', 'light_purple' => '1;35', 'brown' => '0;33', 'yellow' => '1;33', 'light_gray' => '0;37', 'white' => '1;37', ]; /** * Reference of background colors by name * * @var array * * @source 2 8 Background color options */ public static $background = [ 'black' => '40', 'red' => '41', 'green' => '42', 'yellow' => '43', 'blue' => '44', 'magenta' => '45', 'cyan' => '46', 'light_gray' => '47', ]; /** * Reference of other console formatting / decoration by name * * @var array * * @source 2 5 Other decoration options */ public static $other = [ 'bold' => '1', 'dim' => '2', 'underline' => '4', 'blink' => '5', 'inverse' => '7', // Don't seem to work for the author at least: // 'italic' => '3', // 'blink_fast' => '6', // 'concealed' => '8', // 'strike' => '9', // 'double_underline' => '21', // 'frame' => '51', // 'encircled' => '52', // 'overlined' => '53', ]; }//end class }//end if // Note: leave the end tag for packaging ?> 'help', '?' => 'help', 'p' => 'prompt', 'x' => 'exit', 'q' => 'exit', 'quit' => 'exit', ]; /** * Default method to run on command launch if none specified * * - Must be one of the values specified in static $METHODS * * @var string */ protected static $DEFAULT_METHOD = "prompt"; /** * Methods that are OK to run as root without warning * * - Must be values specified in static $METHODS * * @var array */ protected static $ROOT_METHODS = []; /** * Config options that are hidden from help output * * - Add config values here that would not typically be overridden by a flag * - Cleans up help output and avoids confusion * - Must be values specified in static $METHODS * * @var array */ protected static $HIDDEN_CONFIG_OPTIONS = []; /** * Main Tool instance * * - Expected to be an instance of a class extending Console_Abstract * * @var Console_Abstract */ protected $main_tool; /** * Constructor * * @param Console_Abstract $main_tool The instance of the main tool class * - which should extend Console_Abstract. */ public function __construct(Console_Abstract $main_tool) { $this->setMainTool($main_tool); }//end __construct() /** * Set Main Tool - needed backreference for some functionality * * @param Console_Abstract $main_tool The instance of the main tool class - which should extend Console_Abstract. * * @return void */ public function setMainTool(Console_Abstract $main_tool) { $this->main_tool = $main_tool; }//end setMainTool() /** * Given input from user, try running the requested command / method * * - Parses out input (arg_list) into method and arguments * - Uses ancestor-merged $METHOD_ALIASES to allow shorter commands * - Restricts method to ancestor-merged $METHODS anything else gives an error * - Warns about running as root except for methods in ancestor-merged $ROOT_METHODS * - Runs initialization if $initial is true * * @param array $arg_list List of arguments from the user's input. * @param mixed $initial Whether this is the initial command run by the tool. * @param mixed $prompt_when_done Whether to show command prompt when done. * * @return void */ public function try_calling(array $arg_list, $initial = false, $prompt_when_done = false) { $this->log($arg_list); $method = array_shift($arg_list); $class = get_class($this); if (empty($method)) { $method = static::$DEFAULT_METHOD; } $aliases = static::getMergedProperty('METHOD_ALIASES'); if (isset($aliases[$method])) { $method = $aliases[$method]; } $this->method = $method; try { $valid_methods = static::getMergedProperty('METHODS'); if (!in_array($method, $valid_methods)) { if ($prompt_when_done) { $this->warn("Invalid method - $method"); $this->prompt(false, true); } else { $this->help(); $this->hr(); $this->error("Invalid method - $method"); } } $args = []; foreach ($arg_list as $_arg) { if (strpos($_arg, '--') === 0) { $arg = substr($_arg, 2); $arg_split = explode("=", $arg, 2); if (!isset($arg_split[1])) { $arg_split[1] = true; } $this->main_tool->configure($arg_split[0], $arg_split[1]); } else { $args[] = $_arg; } } // Check if running as root - if so, make sure that's OK if ($this->main_tool->running_as_root and !$this->main_tool->allow_root) { $root_methods = static::getMergedProperty('ROOT_METHODS'); if (!in_array($method, $root_methods)) { $this->error("Cowardly refusing to run as root. Use --allow-root to bypass this error.", 200); } // We're running as root // - Without explicitly allowing // - In a method that's OK to run as root // - This indicates a temporary escalation - eg. for install, update, etc. // - So, we should *not* create the config file in this scenario // - Or it will have the wrong permissions $this->config_ok_to_create = false; } if ($initial) { date_default_timezone_set($this->main_tool->timezone); $this->log('Determined home directory to be ' . $this->main_tool->home_dir); // Run an update check // auto:true, output:true if ($this->updateCheck(true, true)) { if ($method != 'update') { $this->sleep(3); } } } $call_info = "$class->$method(" . implode(",", $args) . ")"; $this->log("Calling $call_info"); $this->hrl(); try { call_user_func_array([$this, $method], $args); } catch (ArgumentCountError $e) { $this->_run_error($e, $method); } catch (InvalidArgumentException $e) { $this->_run_error($e, $method); } catch (Exception $e) { $this->_run_error($e, $method); } $this->hrl(); $this->log("$call_info complete"); } catch (Exception $e) { $this->error($e->getMessage()); }//end try if ($prompt_when_done) { $this->prompt(false, false); } }//end try_calling() /** * Show an error message and help output * * - Used for errors while trying to run a method, so incorrect usage is suspected * - Exits with a 500 error code * - Can be confusing during development - turn on verbose mode to throw the original * Exception as well for easier debugging. * * @param mixed $e The exception object. * @param string $method The method being called. * * @return void * @throws mixed Throws $e that was passed if running in verbose mode. */ protected function _run_error($e, string $method) { $class = get_class($e); $error = in_array($class, ['Exception', 'HJSONException']) ? $e->getMessage() : "Incorrect usage - see method help below. Run with --verbose to see full error message."; $this->error($error, false); $this->help($method); if ($this->verbose) { throw $e; } exit(500); }//end _run_error() /** * Help info for clear command * * @var mixed * * @internal */ protected $___clear = [ "Clear the screen", ]; /** * Method to clear the console screen * * - Most useful in built-in CLI / prompt interface * * @api * @return void */ public function clear() { $this->main_tool->clear(); }//end clear() /** * Help info for exit command * * @var mixed * * @internal */ protected $___exit = [ "Exit the command prompt", ]; /** * Method to exit the tool / script runtime * * - Most useful to exit built-in CLI / prompt interface * * @api * @return void */ public function exit() { exit(); }//end exit() /** * Help info for the help command itself * * @var mixed * * @internal */ protected $___help = [ "Shows help/usage information.", ["Method/option for specific help", "string"], ]; /** * Method to show help information * * - Shows list of available commands and descriptions * - Pass specific command to show more details for that command * * - Shows list of commonly used configuration options * (those not listed in ancestor-merged $HIDDEN_CONFIG_OPTIONS) * - If verbose mode is on, will list ALL configuration options * * @param string $specific A specific method or option to show detailed help for. * * @api * @return void */ public function help(string $specific = "") { // Specific help? $specific = trim($specific); if (!empty($specific)) { $this->_help_specific($specific); return; } $methods = static::getMergedProperty('METHODS'); sort($methods); $this->version(); $this->output("\nUSAGE:\n"); $this->output(static::SHORTNAME . " (argument1) (argument2) ... [options]\n"); $this->hr('-'); $this->output3col("METHOD", "INFO"); $this->hr('-'); foreach ($methods as $method) { $string = ""; $help_text = ""; $help = $this->_help_var($method, 'method'); $help_text = empty($help) ? "" : array_shift($help); $this->output3col($method, $help_text); } $this->hr('-'); $this->output("To get more help for a specific method: " . static::SHORTNAME . " help "); $this->output(""); $this->hr('-'); $this->output3col("OPTION", "TYPE", "INFO"); $this->hr('-'); $hidden_options = static::getMergedProperty('HIDDEN_CONFIG_OPTIONS'); foreach ($this->getPublicProperties() as $property) { if (!$this->verbose and in_array($property, $hidden_options)) { continue; } $property = str_replace('_', '-', $property); $help = $this->_help_var($property, 'option'); $type = ""; $info = ""; if ($help) { $help = $this->_help_param($help); $type = "($help[1])"; $info = $help[0]; } $this->output3col("--$property", $type, $info); } $this->hr('-'); $this->output("Use no- to set boolean option to false - eg. --no-stamp-lines"); if (!$this->verbose) { $this->output($this->colorize("Less common options are hidden. Use --verbose to show ALL options.", "yellow")); } }//end help() /** * Help info for prompt command * * @var mixed * * @internal */ protected $___prompt = [ "Show interactive prompt" ]; /** * Method to show interactive CLI prompt * * - Typically used as default command to run, so that * if no command passed, prompt is shown * * @param mixed $clear Pass truthy value to clear screen before showing prompt. * @param mixed $help Pass trhthy value to show help at start of prompt. * * @return void */ public function prompt($clear = false, $help = true) { if ($clear) { $this->clear(); } if ($help) { $this->hr(); $this->output("Enter 'help' to list valid commands"); } $this->hr(); $command_string = $this->input("cmd"); $arg_list = explode(" ", $command_string); $this->try_calling($arg_list, false, true); }//end prompt() /** * Helper method for 'help' command - shows help details for a specific subcommand * * @param string $specific A specific method or option to show detailed help for. * * @return void */ protected function _help_specific(string $specific) { $help = $this->_help_var($specific); if (empty($help)) { $this->error("No help found for '$specific'"); } $specific = str_replace('-', '_', $specific); if (isset($this->$specific)) { // Option info $help_param = $this->_help_param($help); $specific = str_replace('_', '-', $specific); $this->hr('-'); $this->output3col("OPTION", "(TYPE)", "INFO"); $this->hr('-'); $this->output3col("--$specific", "($help_param[1])", $help_param[0]); $this->hr('-'); } elseif (is_callable([$this, $specific])) { // Method Usage $help_text = array_shift($help); $usage = static::SHORTNAME . " $specific"; $params = $this->_getMethodParams($specific); foreach ($params as $p => $param) { $help_param = $this->_help_param($help[$p]); $param = $help_param['string'] ? "\"$param\"" : $param; $param = $help_param['optional'] ? "($param)" : $param; $usage .= " $param"; } $usage .= " [options]"; $this->output("USAGE:\n"); $this->output("$usage\n"); $this->hr('-'); $this->output3col("METHOD", "INFO"); $this->hr('-'); $this->output3col($specific, $help_text); $this->hr('-'); $this->br(); if (!empty($params)) { $this->hr('-'); $this->output3col("PARAMETER", "TYPE", "INFO"); $this->hr('-'); foreach ($params as $p => $param) { $help_param = $this->_help_param($help[$p]); $output = $help_param['optional'] ? "" : "*"; $output .= $param; $this->output3col($output, "($help_param[1])", $help_param[0]); } $this->hr('-'); $this->output("* Required parameter"); } }//end if }//end _help_specific() /** * Helper method for _help_specific - get the help var (parameter) for specific method or option * * @param string $specific A specific method or option to show detailed help for. * @param string $type Type to look for - 'method' or 'option' (will check both by default). * * @return mixed help information, or empty string if none found */ protected function _help_var(string $specific, string $type = "") { $help = ""; $specific = str_replace('-', '_', $specific); if ($type == 'method' or empty($type)) { $help_var = "___" . $specific; } if ($type == 'option' or (empty($type) and empty($this->$help_var))) { $help_var = "__" . $specific; } if (!empty($this->$help_var)) { $help = $this->$help_var; if (!is_array($help)) { $help = [$help]; } } return $help; }//end _help_var() /** * Clean up / standardize a help parameter - fill in defaults from defaults * * @param mixed $param The original parameter value to clean up. * * @return array The cleaned and standardized paramater information array. */ protected function _help_param($param): array { if (!is_array($param)) { $param = [$param]; } if (empty($param[1])) { $param[1] = "boolean"; } if (empty($param[2])) { $param[2] = "optional"; } $param['optional'] = ($param[2] == 'optional'); $param['required'] = !$param['optional']; $param['string'] = ($param[1] == 'string'); return $param; }//end _help_param() /** * Get static property by merging up with ancestor values * * - Elsewhere referred to as 'ancestor-merge' * - The value of the specified property (on the class and each ancestor) is expected to be an array. * * @param string $property The name of the property to merge. * * @return array The resulting merged value. */ protected static function getMergedProperty(string $property): array { $merged_array = []; $class = get_called_class(); while ($class and class_exists($class)) { if (isset($class::$$property)) { $merged_array = array_merge($merged_array, $class::$$property); } $parent_class = get_parent_class($class); if ($parent_class === $class) { break; } $class = $parent_class; } // If integer keys, then make sure array values are uniuqe if (is_int(array_key_first($merged_array))) { $merged_array = array_unique($merged_array); } return $merged_array; }//end getMergedProperty() /** * Merge arrays recursively, in a special way * * Primarily, we are expecting meaningful keys - eg. option arrays, commands/subcommands, etc. So, we: * - Start with array1 * - Check each key - if that key exists in array2, overwrite with array2's value, UNLESS: * - If both values are an array, merge the values instead - recursively * - Last, add keys that are in array2 only * * @param array $array1 Original array to merge values into - values may be overwritten by array2. * @param array $array2 Array to merge into original - values may overwrite array1. * * @return array The resulting merged array. */ protected function mergeArraysRecursively(array $array1, array $array2): array { $merged_array = []; foreach ($array1 as $key => $value1) { if (isset($array2[$key])) { if (is_array($array1[$key]) and is_array($array2[$key])) { $merged_array[$key] = $this->mergeArraysRecursively($array1[$key], $array2[$key]); } else { $merged_array[$key] = $array2[$key]; unset($array2[$key]); } } else { $merged_array[$key] = $value1; } } foreach ($array2 as $key => $value2) { $merged_array[$key] = $value2; } return $merged_array; }//end mergeArraysRecursively() /** * Magic handling for subcommands to call main command methods * * - Primarly used as an organization tool * - Allows us to keep some methods in console_abstract and still have them available in other places * - FWIW, not super happy with this approach, but it works for now * * @param string $method The method that is being called. * @param array $arguments The arguments being passed to the method. * * @throws Exception If the method can't be found on the "main_tool" instance. * @return mixed If able to call the method on the "main_tool" (instance of Console_Abstract) then, return the value from calling that method. */ public function __call(string $method, array $arguments = []) { $callable = [$this->main_tool, $method]; if (is_callable($callable)) { $this->main_tool->log("Attempting to call $method on Console_Abstract instance"); return call_user_func_array($callable, $arguments); } throw new Exception("Invalid class method '$method'"); }//end __call() }//end class }//end if // Note: leave the end tag for packaging ?> error("Option 'reload_function' is required"); } $this->reload_function = $options['reload_function']; if (isset($options['reload_data'])) { $this->reload_data = $options['reload_data']; } $this->commands = [ 'help' => [ 'description' => 'Help - list available commands', 'keys' => '?', 'callback' => [$this, 'visual_help'], ], 'reload' => [ 'description' => 'Reload - refresh this view', 'keys' => 'r', 'callback' => [$this, 'reload'], ], 'quit' => [ 'description' => 'Quit - exit this view', 'keys' => 'q', 'callback' => [$this, 'quit'], ], ]; if (isset($options['commands'])) { $this->commands = $this->mergeArraysRecursively($this->commands, $options['commands']); } $this->cleanCommandArray($this->commands); }//end __construct() /** * Clean an array of commands * * - Make sure keys are set properly as array of single keys * * @param array $commands Array of commands to be cleaned. Passed by reference. * * @return void */ protected function cleanCommandArray(array &$commands) { foreach ($commands as $command_slug => $command_details) { if (is_string($command_details['keys'])) { $command_details['keys'] = str_split($command_details['keys']); } if (!is_array($command_details['keys'])) { $this->error("Invalid command keys for '$command_slug'"); } if ( isset($command_details['callback']) and is_array($command_details['callback']) and isset($command_details['callback']['subcommands']) ) { $this->cleanCommandArray($command_details['callback']['subcommands']); } $commands[$command_slug] = $command_details; } }//end cleanCommandArray() /** * Prompt for input and run the requested command if valid * * - Expected to be called by a child class - eg. Command_Visual_List * * @param array $commands The commands to select from. * @param mixed $show_commands Whether to show the available commands. * * @return boolean Whether or not to continue the command prompt loop */ protected function promptAndRunCommand(array $commands, $show_commands = false): bool { if (!is_array($commands) or empty($commands)) { $this->error("Invalid commands passed - expecting array of command definitions"); } if ($show_commands) { foreach ($commands as $key => $details) { $name = $details['description']; $keys = $details['keys']; $this->output(str_pad(implode(",", $keys) . " ", 15, ".") . " " . $name); } } $input = $this->input(true, null, false, 'single', 'hide_input'); $matched = false; foreach ($commands as $command_slug => $command_details) { $command_name = $command_details['description']; $command_keys = $command_details['keys']; $command_callable = $command_details['callback']; if (in_array($input, $command_keys)) { $matched = true; if (! is_callable($command_callable)) { if (is_array($command_callable)) { if (isset($command_callable['subcommands'])) { while (true) { $this->clear(); $this->hr(); $this->output("$command_name:"); $this->hr(); $continue_loop = $this->promptAndRunCommand($command_callable['subcommands'], true); if ( $continue_loop === false or (isset($command_details['continue']) and $command_details['continue'] === false) ) { // Finish - but not a failure return true; } } } } $this->error("Uncallable method for $input", false, true); return true; }//end if $continue_loop = call_user_func($command_callable, $this); // Reload if set if (!empty($command_details['reload'])) { $this->reload($this); } if ( $continue_loop === false or (isset($command_details['continue']) and $command_details['continue'] === false) ) { return false; } return true; }//end if }//end foreach if (!$matched) { $this->log("Invalid input $input"); } return true; }//end promptAndRunCommand() /**************************************************** * BUILT-IN COMMANDS ***************************************************/ /** * Help for the visual interface * * - Lists all available commands to run in this area * * @param Command $instance Instance of command class passed for reference. * * @api * @return void */ public function visual_help(Command $instance) { $this->clear(); $this->hr(); $this->output("Available Commands:"); $this->hr(); foreach ($this->commands as $command_slug => $command_details) { $command_name = $command_details['description']; $command_keys = $command_details['keys']; $this->output(str_pad(implode(",", $command_keys) . " ", 15, ".") . " " . $command_name); } $this->hr(); $this->input("Hit any key to exit help", null, false, true); }//end visual_help() /** * Exit the visual interface * * - Will return to previous area (eg. main prompt or exit tool perhaps) * - Returns false statically to let the prompt loop know not to continue * * @param Command $instance Instance of command class passed for reference. * * @api * @return false */ public function quit(Command $instance) { return false; }//end quit() /** * Reload the visual interface * * - Calls the configured reload_function with optional reload_data if any * * @param Command $instance Instance of command class passed for reference. * * @api * @return mixed Result of reload function call - can vary based on context. */ public function reload(Command $instance) { return call_user_func($this->reload_function, $this->reload_data, $this); }//end reload() }//end class }//end if // Note: leave the end tag for packaging ?> setMainTool($main_tool); if (empty($list)) { $this->error("Empty list", false, true); return; } $this->list_original = $list; $this->list = $list; if (isset($options['multiselect'])) { $this->multiselect = $options['multiselect']; } if (isset($options['template'])) { $this->template = $options['template']; } $commands = [ 'filter' => [ 'description' => 'Filter the list', 'keys' => 'f', 'callback' => [ 'subcommands' => [ 'filter_by_text' => [ 'description' => 'Text/Regex Search', 'keys' => '/', 'callback' => [$this, 'filter_by_text'], 'continue' => false, ], 'filter_remove' => [ 'description' => 'Remove filters - go back to full list', 'keys' => 'r', 'callback' => [$this, 'filter_remove'], 'continue' => false, ], ], ], ], 'filter_by_text' => [ 'description' => 'Search list (filter by text entry)', 'keys' => '/', 'callback' => [$this, 'filter_by_text'], ], 'focus_up' => [ 'description' => 'Up - move focus up in the list', 'keys' => 'k', 'callback' => [$this, 'focus_up'], ], 'focus_down' => [ 'description' => 'Down - move focus down in the list', 'keys' => 'j', 'callback' => [$this, 'focus_down'], ], 'focus_top' => [ 'description' => 'Top - move focus to top of list', 'keys' => 'g', 'callback' => [$this, 'focus_top'], ], 'focus_bottom' => [ 'description' => 'Bottom - move focus to bottom of list', 'keys' => 'G', 'callback' => [$this, 'focus_bottom'], ], ]; if (isset($options['commands'])) { $commands = $this->mergeArraysRecursively($commands, $options['commands']); } $options['commands'] = $commands; parent::__construct($main_tool, $options); }//end __construct() /** * Run the listing subcommand - display the list * * @return void */ public function run() { $count = count($this->list); $content_to_display = []; $i = 0; foreach ($this->list as $key => $item) { // Prep output using template $output = $this->template; $key_start = strpos($output, '{_KEY}'); if ($key_start !== false) { $output = substr_replace($output, $key, $key_start, 6); } $value_start = strpos($output, '{_VALUE}'); if ($key_start !== false) { $output = substr_replace($output, $this->stringify($item), $value_start, 8); } // Swap out placeholder areas for dynamic item data $content = preg_replace_callback('/\{[^\}]+\}/', function ($matches) use ($item) { $value = ""; $format = false; $match = $matches[0]; $match = substr($match, 1, -1); $match_exploded = explode("|", $match); if (count($match_exploded) > 1) { $format = array_pop($match_exploded); } $key_string = array_shift($match_exploded); $keys = explode(":", $key_string); $target = $item; while (!empty($keys)) { $key = array_shift($keys); if (isset($target[$key])) { $target = $target[$key]; } else { $keys = []; } } if (is_string($target)) { $value = $target; } if (is_array($target)) { $value = implode(", ", $target); } // var_dump($value); if (!empty($format) and !empty($value)) { $value = sprintf($format, $value); } // var_dump($value); return $value; }, $output); if ($this->focus == $i) { $content = "[*] " . $content; $content = $this->colorize($content, 'blue', 'light_gray', ['bold']); } else { $content = "[ ] " . $content; } $content_to_display[] = $content; $i++; }//end foreach $this->clear(); $this->page_info = $this->paginate($content_to_display, [ 'starting_line' => $this->starting_line, ]); $continue_loop = $this->promptAndRunCommand($this->commands); if ($continue_loop !== false) { $this->log("Looping!"); $this->pause(); $this->run(); } }//end run() /**************************************************** * BUILT-IN COMMANDS ***************************************************/ /** * Reload the list interface * * - Calls parent reload method (see Command_Visual). * - Resets the list to value returned by reeload method. * * @param Command $instance Instance of command class passed for reference. * * @api * @return void */ public function reload(Command $instance) { $list = parent::reload($instance); $this->list_original = $list; $this->list = $list; }//end reload() /** * Remove filters and reset list to original state * * @api * @return void */ public function filter_remove() { $this->list = $this->list_original; $this->focus_top(); }//end filter_remove() // Filter - by text/regex (search) /** * Filter list by text or regex - eg. search * * @api * @return void */ public function filter_by_text() { while (true) { $this->clear(); $this->hr(); $this->output("Filter by Text:"); $this->output(" - Case insensive if search string is all lowercase"); $this->output(" - Start with / to use RegEx"); $this->hr(); $search_pattern = $this->input("Enter text", null, false); $current_list = $this->list; $filtered_list = []; $search_pattern = trim($search_pattern); if (empty($search_pattern)) { return; } $is_regex = (substr($search_pattern, 0, 1) == '/'); $case_insensitive = (!$is_regex and (strtolower($search_pattern) == $search_pattern)); foreach ($current_list as $item) { $json = json_encode($item); $match = false; if ($is_regex) { $match = preg_match($search_pattern, $json); } elseif ($case_insensitive) { $match = ( stripos($json, $search_pattern) !== false ); } else { $match = ( strpos($json, $search_pattern) !== false ); } if ($match) { $filtered_list[] = $item; } } if (!empty($filtered_list)) { // Results found - display as the new current list $this->list = $filtered_list; $this->focus_top(); return; } else { // No results - offer to try a new search $this->output("No Results found"); $new_search = $this->confirm("Try a new search?", "y", false, true); if (! $new_search) { return; } // Otherwise, will continue the loop } }//end while }//end filter_by_text() /** * Move line focus up - eg. scroll up * * @api * @return void */ public function focus_up() { if ($this->focus > 0) { $this->focus--; } $this->page_to_focus(); }//end focus_up() /** * Move line focus down - eg. scroll down * * @api * @return void */ public function focus_down() { $max_focus = (count($this->list) - 1); if ($this->focus < $max_focus) { $this->focus++; } $this->page_to_focus(); }//end focus_down() /** * Move line focus (scroll) to top of list * * @api * @return void */ public function focus_top() { $this->focus = 0; $this->page_to_focus(); }//end focus_top() /** * Move line focus (scroll) to bottom of list * * @api * @return void */ public function focus_bottom() { $max_focus = (count($this->list) - 1); $this->focus = $max_focus; $this->page_to_focus(); }//end focus_bottom() /**************************************************** * HELPER FUNCTIONS ***************************************************/ /** * Adjust starting_line based on set focus index * * @return void */ private function page_to_focus() { $focus = $this->focus + 1; if ($focus < $this->starting_line) { $this->starting_line = $focus; } if ($focus > $this->page_info['ending_line']) { $this->starting_line = ($focus - $this->page_info['page_length']) + 1; } }//end page_to_focus() /** * Get the key of the line in the list that currently has focus * * @return The focused line's key. */ public function getFocusedKey() { $list_keys = array_keys($this->list); return $list_keys[$this->focus]; }//end getFocusedKey() /** * Get the value of the line in the list that currently has focus * * @return The focused line's value. */ public function getFocusedValue() { $list_values = array_values($this->list); return $list_values[$this->focus]; }//end getFocusedValue() }//end class }//end if // Note: leave the end tag for packaging ?> escapee = [ '"' => '"', '\'' => '\'', "\\" => "\\", '/' => '/', 'b' => chr(8), 'f' => chr(12), 'n' => "\n", 'r' => "\r", 't' => "\t" ]; } public function parse($source, $options = []) { $this->keepWsc = $options && isset($options['keepWsc']) && $options['keepWsc']; $this->text_array = preg_split("//u", $source, -1, PREG_SPLIT_NO_EMPTY); $this->text_length_chars = count($this->text_array); $data = $this->rootValue(); if ($options && isset($options['assoc']) && $options['assoc']) { $data = json_decode(json_encode($data), true); } return $data; } private function resetAt() { $this->at = 0; $this->ch = ' '; } public function parseWsc($source, $options = []) { return $this->parse($source, array_merge($options, ['keepWsc' => true])); } private function isPunctuatorChar($c) { return $c === '{' || $c === '}' || $c === '[' || $c === ']' || $c === ',' || $c === ':'; } private function checkExit($result) { $this->white(); if ($this->ch !== null) { $this->error("Syntax error, found trailing characters!"); } return $result; } private function rootValue() { // Braces for the root object are optional $this->resetAt(); $this->white(); switch ($this->ch) { case '{': return $this->checkExit($this->object()); case '[': return $this->checkExit($this->_array()); } try { // assume we have a root object without braces return $this->checkExit($this->object(true)); } catch (HJSONException $e) { // test if we are dealing with a single JSON value instead (true/false/null/num/"") $this->resetAt(); try { return $this->checkExit($this->value()); } catch (HJSONException $e2) { throw $e; } // throw original error } } private function value() { $this->white(); switch ($this->ch) { case '{': return $this->object(); case '[': return $this->_array(); case '"': return $this->string('"'); case '\'': if ($this->peek(0) !== '\'' || $this->peek(1) !== '\'') { return $this->string('\''); } // Falls through on multiline strings default: return $this->tfnns(); } } private function string($quote) { // Parse a string value. $hex; $string = ''; $uffff; // When parsing for string values, we must look for " and \ characters. if ($this->ch === $quote) { while ($this->next() !== null) { if ($this->ch === $quote) { $this->next(); return $string; } if ($this->ch === "\\") { $this->next(); if ($this->ch === 'u') { $uffff = ''; for ($i = 0; $i < 4; $i++) { $uffff .= $this->next(); } if (!ctype_xdigit($uffff)) { $this->error("Bad \\u char"); } $string .= mb_convert_encoding(pack('H*', $uffff), 'UTF-8', 'UTF-16BE'); } elseif (@$this->escapee[$this->ch]) { $string .= $this->escapee[$this->ch]; } else { break; } } else { $string .= $this->ch; } } } $this->error("Bad string"); } private function _array() { // Parse an array value. // assumeing ch === '[' $array = []; $kw = null; $wat = null; if ($this->keepWsc) { $array['__WSC__'] = []; $kw = &$array['__WSC__']; } $this->next(); $wat = $this->at; $this->white(); if ($kw !== null) { $c = $this->getComment($wat); if (trim($c)) { $kw[] = $c; } } if ($this->ch === ']') { $this->next(); return $array; // empty array } while ($this->ch !== null) { $array[] = $this->value(); $wat = $this->at; $this->white(); // in Hjson the comma is optional and trailing commas are allowed if ($this->ch === ',') { $this->next(); $wat = $this->at; $this->white(); } if ($kw !== null) { $c = $this->getComment($wat); if (trim($c)) { $kw[] = $c; } } if ($this->ch === ']') { $this->next(); return $array; } $this->white(); } $this->error("End of input while parsing an array (did you forget a closing ']'?)"); } private function object($withoutBraces = false) { // Parse an object value. $key = null; $object = new \stdClass; $kw = null; $wat = null; if ($this->keepWsc) { $kw = new \stdClass; $kw->c = new \stdClass; $kw->o = []; $object->__WSC__ = $kw; if ($withoutBraces) { $kw->noRootBraces = true; } } if (!$withoutBraces) { // assuming ch === '{' $this->next(); $wat = $this->at; } else { $wat = 1; } $this->white(); if ($kw) { $this->pushWhite(" ", $kw, $wat); } if ($this->ch === '}' && !$withoutBraces) { $this->next(); return $object; // empty object } while ($this->ch !== null) { $key = $this->keyname(); $this->white(); $this->next(':'); // duplicate keys overwrite the previous value if ($key !== '') { $object->$key = $this->value(); } $wat = $this->at; $this->white(); // in Hjson the comma is optional and trailing commas are allowed if ($this->ch === ',') { $this->next(); $wat = $this->at; $this->white(); } if ($kw) { $this->pushWhite($key, $kw, $wat); } if ($this->ch === '}' && !$withoutBraces) { $this->next(); return $object; } $this->white(); } if ($withoutBraces) { return $object; } else { $this->error("End of input while parsing an object (did you forget a closing '}'?)"); } } private function pushWhite($key, &$kw, $wat) { $kw->c->$key = $this->getComment($wat); if (trim($key)) { $kw->o[] = $key; } } private function white() { while ($this->ch !== null) { // Skip whitespace. while ($this->ch && $this->ch <= ' ') { $this->next(); } // Hjson allows comments if ($this->ch === '#' || $this->ch === '/' && $this->peek(0) === '/') { while ($this->ch !== null && $this->ch !== "\n") { $this->next(); } } elseif ($this->ch === '/' && $this->peek(0) === '*') { $this->next(); $this->next(); while ($this->ch !== null && !($this->ch === '*' && $this->peek(0) === '/')) { $this->next(); } if ($this->ch !== null) { $this->next(); $this->next(); } } else { break; } } } private function error($m) { $col=0; $colBytes = 0; $line=1; // Start with where we're at now, count back to most recent line break // - to determine "column" of error hit $i = $this->at; while ($i > 0) { // Mimic old behavior with mb_substr if ($i >= $this->text_length_chars) { $ch = ""; } else { $ch = $this->text_array[$i]; } --$i; if ($ch === "\n") { break; } $col++; } // Count back line endings from there to determine line# of error hit for (; $i > 0; $i--) { if ($this->text_array[$i] === "\n") { $line++; } } throw new HJSONException("$m at line $line, $col >>>". implode(array_slice($this->text_array, $this->at - $col, 20)) ." ..."); } private function next($c = false) { // If a c parameter is provided, verify that it matches the current character. if ($c && $c !== $this->ch) { $this->error("Expected '$c' instead of '{$this->ch}'"); } // Get the next character. When there are no more characters, // return the empty string. $this->ch = ($this->text_length_chars > $this->at) ? $this->text_array[$this->at] : null; ++$this->at; return $this->ch; } /** * Peek at character at given offset from current "at" * - >=0 - ahead of "at" * - <0 = before "at" */ private function peek($offs) { $index = $this->at + $offs; // Mimic old behavior with mb_substr if ($index < 0) $index = 0; if ($index >= $this->text_length_chars) return ""; return $this->text_array[$index]; } private function skipIndent($indent) { $skip = $indent; while ($this->ch && $this->ch <= ' ' && $this->ch !== "\n" && $skip-- > 0) { $this->next(); } } private function mlString() { // Parse a multiline string value. $string = ''; $triple = 0; // we are at ''' +1 - get indent $indent = 0; while (true) { $c = $this->peek(-$indent-5); if ($c === null || $c === "\n") { break; } $indent++; } // skip white/to (newline) while ($this->ch !== null && $this->ch <= ' ' && $this->ch !== "\n") { $this->next(); } if ($this->ch === "\n") { $this->next(); $this->skipIndent($indent); } // When parsing multiline string values, we must look for ' characters. while (true) { if ($this->ch === null) { $this->error("Bad multiline string"); } elseif ($this->ch === '\'') { $triple++; $this->next(); if ($triple === 3) { if (substr($string, -1) === "\n") { $string = mb_substr($string, 0, -1); // remove last EOL } return $string; } else { continue; } } else { while ($triple > 0) { $string .= '\''; $triple--; } } if ($this->ch === "\n") { $string .= "\n"; $this->next(); $this->skipIndent($indent); } else { if ($this->ch !== "\r") { $string .= $this->ch; } $this->next(); } } } private function keyname() { // quotes for keys are optional in Hjson // unless they include {}[],: or whitespace. if ($this->ch === '"') { return $this->string('"'); } else if ($this->ch === '\'') { return $this->string('\''); } $name = ""; $start = $this->at; $space = -1; while (true) { if ($this->ch === ':') { if ($name === '') { $this->error("Found ':' but no key name (for an empty key name use quotes)"); } elseif ($space >=0 && $space !== mb_strlen($name)) { $this->at = $start + $space; $this->error("Found whitespace in your key name (use quotes to include)"); } return $name; } elseif ($this->ch <= ' ') { if (!$this->ch) { $this->error("Found EOF while looking for a key name (check your syntax)"); } elseif ($space < 0) { $space = mb_strlen($name); } } elseif ($this->isPunctuatorChar($this->ch)) { $this->error("Found '{$this->ch}' where a key name was expected (check your syntax or use quotes if the key name includes {}[],: or whitespace)"); } else { $name .= $this->ch; } $this->next(); } } private function tfnns() { // Hjson strings can be quoteless // returns string, true, false, or null. if ($this->isPunctuatorChar($this->ch)) { $this->error("Found a punctuator character '{$this->ch}' when expecting a quoteless string (check your syntax)"); } $value = $this->ch; while (true) { $isEol = $this->next() === null; if (mb_strlen($value) === 3 && $value === "'''") { return $this->mlString(); } $isEol = $isEol || $this->ch === "\r" || $this->ch === "\n"; if ($isEol || $this->ch === ',' || $this->ch === '}' || $this->ch === ']' || $this->ch === '#' || $this->ch === '/' && ($this->peek(0) === '/' || $this->peek(0) === '*') ) { $chf = $value[0]; switch ($chf) { case 'f': if (trim($value) === "false") { return false; } break; case 'n': if (trim($value) === "null") { return null; } break; case 't': if (trim($value) === "true") { return true; } break; default: if ($chf === '-' || $chf >= '0' && $chf <= '9') { $n = HJSONUtils::tryParseNumber($value); if ($n !== null) { return $n; } } } if ($isEol) { // remove any whitespace at the end (ignored in quoteless strings) return trim($value); } } $value .= $this->ch; } } private function getComment($wat) { $i; $wat--; // remove trailing whitespace for ($i = $this->at - 2; $i > $wat && $this->text_array[$i] <= ' ' && $this->text_array[$i] !== "\n"; $i--) { } // but only up to EOL if ($this->text_array[$i] === "\n") { $i--; } if ($this->text_array[$i] === "\r") { $i--; } $res = array_slice($this->text_array, $wat, $i-$wat+1); $res_len = count($res); for ($i = 0; $i < $res_len; $i++) { if ($res[$i] > ' ') { return $res; } } return ""; } } } /** * NOTE: this may return an empty string at the end of the array when the input * string ends with a newline character */ if (!function_exists("HJSON_mb_str_split")) { function HJSON_mb_str_split($string) { return preg_split('/(?meta = [ "\t" => "\\t", "\n" => "\\n", "\r" => "\\r", '"' => '\\"', '\'' => '\\\'', '\\' => "\\\\" ]; $this->meta[chr(8)] = '\\b'; $this->meta[chr(12)] = '\\f'; } public function stringify($value, $opt = []) { $this->eol = PHP_EOL; $this->indent = ' '; $this->keepWsc = false; $this->bracesSameLine = $this->defaultBracesSameLine; $this->quoteAlways = false; $this->forceKeyQuotes = false; $this->emitRootBraces = true; $space = null; if ($opt && is_array($opt)) { if (@$opt['eol'] === "\n" || @$opt['eol'] === "\r\n") { $this->eol = $opt['eol']; } $space = @$opt['space']; $this->keepWsc = @$opt['keepWsc']; $this->bracesSameLine = @$opt['bracesSameLine'] || $this->defaultBracesSameLine; $this->emitRootBraces = @$opt['emitRootBraces']; $this->quoteAlways = @$opt['quotes'] === 'always'; $this->forceKeyQuotes = @$opt['keyQuotes'] === 'always'; } // If the space parameter is a number, make an indent string containing that // many spaces. If it is a string, it will be used as the indent string. if (is_int($space)) { $this->indent = ''; for ($i = 0; $i < $space; $i++) { $this->indent .= ' '; } } elseif (is_string($space)) { $this->indent = $space; } // Return the result of stringifying the value. return $this->str($value, null, true, true); } public function stringifyWsc($value, $opt = []) { return $this->stringify($value, array_merge($opt, ['keepWsc' => true])); } private function isWhite($c) { return $c <= ' '; } private function quoteReplace($string) { mb_ereg_search_init($string, $this->needsEscape); $r = mb_ereg_search(); $chars = HJSON_mb_str_split($string); $chars = array_map(function ($char) { if (preg_match($this->needsEscape, $char)) { $a = $char; $c = @$this->meta[$a] ?: null; if (gettype($c) === 'string') { return $c; } else { return $char; } } else { return $char; } }, $chars); return implode('', $chars); } private function quote($string = null, $gap = null, $hasComment = null, $isRootObject = null) { if (!$string) { return '""'; } // Check if we can insert this string without quotes // see hjson syntax (must not parse as true, false, null or number) if ($this->quoteAlways || $hasComment || preg_match($this->needsQuotes, $string) || HJSONUtils::tryParseNumber($string, true) !== null || preg_match($this->startsWithKeyword, $string)) { // If the string contains no control characters, no quote characters, and no // backslash characters, then we can safely slap some quotes around it. // Otherwise we first check if the string can be expressed in multiline // format or we must replace the offending characters with safe escape // sequences. if (!preg_match($this->needsEscape, $string)) { return '"' . $string . '"'; } elseif (!preg_match($this->needsEscapeML, $string) && !$isRootObject) { return $this->mlString($string, $gap); } else { return '"' . $this->quoteReplace($string) . '"'; } } else { // return without quotes return $string; } } private function mlString($string, $gap) { // wrap the string into the ''' (multiline) format $a = explode("\n", mb_ereg_replace("\r", "", $string)); $gap .= $this->indent; if (count($a) === 1) { // The string contains only a single line. We still use the multiline // format as it avoids escaping the \ character (e.g. when used in a // regex). return "'''" . $a[0] . "'''"; } else { $res = $this->eol . $gap . "'''"; for ($i = 0; $i < count($a); $i++) { $res .= $this->eol; if ($a[$i]) { $res .= $gap . $a[$i]; } } return $res . $this->eol . $gap . "'''"; } } private function quoteName($name) { if (!$name) { return '""'; } // Check if we can insert this name without quotes if (preg_match($this->needsEscapeName, $name)) { return '"' . (preg_match($this->needsEscape, $name) ? $this->quoteReplace($name) : $name) . '"'; } else { // return without quotes return $name; } } private function str($value, $hasComment = null, $noIndent = null, $isRootObject = null) { // Produce a string from value. $startsWithNL = function ($str) { return $str && $str[$str[0] === "\r" ? 1 : 0] === "\n"; }; $testWsc = function ($str) use ($startsWithNL) { return $str && !$startsWithNL($str); }; $wsc = function ($str) { if (!$str) { return ""; } for ($i = 0; $i < mb_strlen($str); $i++) { $c = $str[$i]; if ($c === "\n" || $c === '#' || $c === '/' && ($str[$i+1] === '/' || $str[$i+1] === '*')) { break; } if ($c > ' ') { return ' # ' . $str; } } return $str; }; // What happens next depends on the value's type. switch (gettype($value)) { case 'string': $str = $this->quote($value, $this->gap, $hasComment, $isRootObject); return $str; case 'integer': case 'double': return is_numeric($value) ? str_replace('E', 'e', "$value") : 'null'; case 'boolean': return $value ? 'true' : 'false'; case 'NULL': return 'null'; case 'object': case 'array': $isArray = is_array($value); $isAssocArray = function (array $arr) { if (array() === $arr) { return false; } return array_keys($arr) !== range(0, count($arr) - 1); }; if ($isArray && $isAssocArray($value)) { $value = (object) $value; $isArray = false; } $kw = null; $kwl = null; // whitespace & comments if ($this->keepWsc) { if ($isArray) { $kw = @$value['__WSC__']; } else { $kw = @$value->__WSC__; } } $showBraces = $isArray || !$isRootObject || ($kw ? !@$kw->noRootBraces : $this->emitRootBraces); // Make an array to hold the partial results of stringifying this object value. $mind = $this->gap; if ($showBraces) { $this->gap .= $this->indent; } $eolMind = $this->eol . $mind; $eolGap = $this->eol . $this->gap; $prefix = $noIndent || $this->bracesSameLine ? '' : $eolMind; $partial = []; $k; $v; // key, value if ($isArray) { // The value is an array. Stringify every element. Use null as a placeholder // for non-JSON values. $length = count($value); if (array_key_exists('__WSC__', $value)) { $length--; } for ($i = 0; $i < $length; $i++) { if ($kw) { $partial[] = $wsc(@$kw[$i]) . $eolGap; } $str = $this->str($value[$i], $kw ? $testWsc(@$kw[$i+1]) : false, true); $partial[] = $str !== null ? $str : 'null'; } if ($kw) { $partial[] = $wsc(@$kw[$i]) . $eolMind; } // Join all of the elements together, separated with newline, and wrap them in // brackets. if ($kw) { $v = $prefix . '[' . implode('', $partial) . ']'; } elseif (count($partial) === 0) { $v = '[]'; } else { $v = $prefix . '[' . $eolGap . implode($eolGap, $partial) . $eolMind . ']'; } } else { // Otherwise, iterate through all of the keys in the object. if ($kw) { $emptyKey = " "; $kwl = $wsc($kw->c->$emptyKey); $keys = $kw->o; foreach ($value as $k => $vvv) { $keys[] = $k; } $keys = array_unique($keys); for ($i = 0, $length = count($keys); $i < $length; $i++) { $k = $keys[$i]; if ($k === '__WSC__') { continue; } if ($showBraces || $i>0 || $kwl) { $partial[] = $kwl . $eolGap; } $kwl = $wsc($kw->c->$k); $v = $this->str($value->$k, $testWsc($kwl)); if ($v !== null) { $partial[] = $this->quoteName($k) . ($startsWithNL($v) ? ':' : ': ') . $v; } } if ($showBraces || $kwl) { $partial[] = $kwl . $eolMind; } } else { foreach ($value as $k => $vvv) { $v = $this->str($vvv); if ($v !== null) { $partial[] = $this->quoteName($k) . ($startsWithNL($v) ? ':' : ': ') . $v; } } } // Join all of the member texts together, separated with newlines if (count($partial) === 0) { $v = '{}'; } elseif ($showBraces) { // and wrap them in braces if ($kw) { $v = $prefix . '{' . implode('', $partial) . '}'; } else { $v = $prefix . '{' . $eolGap . implode($eolGap, $partial) . $eolMind . '}'; } } else { $v = implode($kw ? '' : $eolGap, $partial); } } $this->gap = $mind; return $v; } } } } if (!class_exists("HJSONUtils")) { class HJSONUtils { public static function tryParseNumber($text, $stopAtNext = null) { // Parse a number value. $number = null; $string = ''; $leadingZeros = 0; $testLeading = true; $at = 0; $ch = null; $next = function () use ($text, &$ch, &$at) { $ch = mb_strlen($text) > $at ? $text[$at] : null; $at++; return $ch; }; $next(); if ($ch === '-') { $string = '-'; $next(); } while ($ch !== null && $ch >= '0' && $ch <= '9') { if ($testLeading) { if ($ch == '0') { $leadingZeros++; } else { $testLeading = false; } } $string .= $ch; $next(); } if ($testLeading) { $leadingZeros--; // single 0 is allowed } if ($ch === '.') { $string .= '.'; while ($next() !== null && $ch >= '0' && $ch <= '9') { $string .= $ch; } } if ($ch === 'e' || $ch === 'E') { $string .= $ch; $next(); if ($ch === '-' || $ch === '+') { $string .= $ch; $next(); } while ($ch !== null && $ch >= '0' && $ch <= '9') { $string .= $ch; $next(); } } // skip white/to (newline) while ($ch !== null && $ch <= ' ') { $next(); } if ($stopAtNext) { // end scan if we find a control character like ,}] or a comment if ($ch === ',' || $ch === '}' || $ch === ']' || $ch === '#' || $ch === '/' && ($text[$at] === '/' || $text[$at] === '*')) { $ch = null; } } $number = $string; if (is_numeric($string)) { $number = 0+$string; } if ($ch !== null || $leadingZeros || !is_numeric($number)) { return null; } else { return $number; } } } } // Note: leave the end tag for packaging ?> textElements($text); # convert to markup $markup = $this->elements($Elements); # trim line breaks $markup = trim($markup, "\n"); return $markup; } protected function textElements($text) { # make sure no definitions are set $this->DefinitionData = array(); # standardize line breaks $text = str_replace(array("\r\n", "\r"), "\n", $text); # remove surrounding line breaks $text = trim($text, "\n"); # split text into lines $lines = explode("\n", $text); # iterate through lines to identify blocks return $this->linesElements($lines); } # # Setters # function setBreaksEnabled($breaksEnabled) { $this->breaksEnabled = $breaksEnabled; return $this; } protected $breaksEnabled; function setMarkupEscaped($markupEscaped) { $this->markupEscaped = $markupEscaped; return $this; } protected $markupEscaped; function setUrlsLinked($urlsLinked) { $this->urlsLinked = $urlsLinked; return $this; } protected $urlsLinked = true; function setSafeMode($safeMode) { $this->safeMode = (bool) $safeMode; return $this; } protected $safeMode; function setStrictMode($strictMode) { $this->strictMode = (bool) $strictMode; return $this; } protected $strictMode; protected $safeLinksWhitelist = array( 'http://', 'https://', 'ftp://', 'ftps://', 'mailto:', 'tel:', 'data:image/png;base64,', 'data:image/gif;base64,', 'data:image/jpeg;base64,', 'irc:', 'ircs:', 'git:', 'ssh:', 'news:', 'steam:', ); # # Lines # protected $BlockTypes = array( '#' => array('Header'), '*' => array('Rule', 'List'), '+' => array('List'), '-' => array('SetextHeader', 'Table', 'Rule', 'List'), '0' => array('List'), '1' => array('List'), '2' => array('List'), '3' => array('List'), '4' => array('List'), '5' => array('List'), '6' => array('List'), '7' => array('List'), '8' => array('List'), '9' => array('List'), ':' => array('Table'), '<' => array('Comment', 'Markup'), '=' => array('SetextHeader'), '>' => array('Quote'), '[' => array('Reference'), '_' => array('Rule'), '`' => array('FencedCode'), '|' => array('Table'), '~' => array('FencedCode'), ); # ~ protected $unmarkedBlockTypes = array( 'Code', ); # # Blocks # protected function lines(array $lines) { return $this->elements($this->linesElements($lines)); } protected function linesElements(array $lines) { $Elements = array(); $CurrentBlock = null; foreach ($lines as $line) { if (chop($line) === '') { if (isset($CurrentBlock)) { $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted']) ? $CurrentBlock['interrupted'] + 1 : 1 ); } continue; } while (($beforeTab = strstr($line, "\t", true)) !== false) { $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4; $line = $beforeTab . str_repeat(' ', $shortage) . substr($line, strlen($beforeTab) + 1) ; } $indent = strspn($line, ' '); $text = $indent > 0 ? substr($line, $indent) : $line; # ~ $Line = array('body' => $line, 'indent' => $indent, 'text' => $text); # ~ if (isset($CurrentBlock['continuable'])) { $methodName = 'block' . $CurrentBlock['type'] . 'Continue'; $Block = $this->$methodName($Line, $CurrentBlock); if (isset($Block)) { $CurrentBlock = $Block; continue; } else { if ($this->isBlockCompletable($CurrentBlock['type'])) { $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; $CurrentBlock = $this->$methodName($CurrentBlock); } } } # ~ $marker = $text[0]; # ~ $blockTypes = $this->unmarkedBlockTypes; if (isset($this->BlockTypes[$marker])) { foreach ($this->BlockTypes[$marker] as $blockType) { $blockTypes [] = $blockType; } } # # ~ foreach ($blockTypes as $blockType) { $Block = $this->{"block$blockType"}($Line, $CurrentBlock); if (isset($Block)) { $Block['type'] = $blockType; if (! isset($Block['identified'])) { if (isset($CurrentBlock)) { $Elements[] = $this->extractElement($CurrentBlock); } $Block['identified'] = true; } if ($this->isBlockContinuable($blockType)) { $Block['continuable'] = true; } $CurrentBlock = $Block; continue 2; } } # ~ if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') { $Block = $this->paragraphContinue($Line, $CurrentBlock); } if (isset($Block)) { $CurrentBlock = $Block; } else { if (isset($CurrentBlock)) { $Elements[] = $this->extractElement($CurrentBlock); } $CurrentBlock = $this->paragraph($Line); $CurrentBlock['identified'] = true; } } # ~ if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) { $methodName = 'block' . $CurrentBlock['type'] . 'Complete'; $CurrentBlock = $this->$methodName($CurrentBlock); } # ~ if (isset($CurrentBlock)) { $Elements[] = $this->extractElement($CurrentBlock); } # ~ return $Elements; } protected function extractElement(array $Component) { if (! isset($Component['element'])) { if (isset($Component['markup'])) { $Component['element'] = array('rawHtml' => $Component['markup']); } elseif (isset($Component['hidden'])) { $Component['element'] = array(); } } return $Component['element']; } protected function isBlockContinuable($Type) { return method_exists($this, 'block' . $Type . 'Continue'); } protected function isBlockCompletable($Type) { return method_exists($this, 'block' . $Type . 'Complete'); } # # Code protected function blockCode($Line, $Block = null) { if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) { return; } if ($Line['indent'] >= 4) { $text = substr($Line['body'], 4); $Block = array( 'element' => array( 'name' => 'pre', 'element' => array( 'name' => 'code', 'text' => $text, ), ), ); return $Block; } } protected function blockCodeContinue($Line, $Block) { if ($Line['indent'] >= 4) { if (isset($Block['interrupted'])) { $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); unset($Block['interrupted']); } $Block['element']['element']['text'] .= "\n"; $text = substr($Line['body'], 4); $Block['element']['element']['text'] .= $text; return $Block; } } protected function blockCodeComplete($Block) { return $Block; } # # Comment protected function blockComment($Line) { if ($this->markupEscaped or $this->safeMode) { return; } if (strpos($Line['text'], '') !== false) { $Block['closed'] = true; } return $Block; } } protected function blockCommentContinue($Line, array $Block) { if (isset($Block['closed'])) { return; } $Block['element']['rawHtml'] .= "\n" . $Line['body']; if (strpos($Line['text'], '-->') !== false) { $Block['closed'] = true; } return $Block; } # # Fenced Code protected function blockFencedCode($Line) { $marker = $Line['text'][0]; $openerLength = strspn($Line['text'], $marker); if ($openerLength < 3) { return; } $infostring = trim(substr($Line['text'], $openerLength), "\t "); if (strpos($infostring, '`') !== false) { return; } $Element = array( 'name' => 'code', 'text' => '', ); if ($infostring !== '') { /** * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes * Every HTML element may have a class attribute specified. * The attribute, if specified, must have a value that is a set * of space-separated tokens representing the various classes * that the element belongs to. * [...] * The space characters, for the purposes of this specification, * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab), * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and * U+000D CARRIAGE RETURN (CR). */ $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r")); $Element['attributes'] = array('class' => "language-$language"); } $Block = array( 'char' => $marker, 'openerLength' => $openerLength, 'element' => array( 'name' => 'pre', 'element' => $Element, ), ); return $Block; } protected function blockFencedCodeContinue($Line, $Block) { if (isset($Block['complete'])) { return; } if (isset($Block['interrupted'])) { $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']); unset($Block['interrupted']); } if ( ($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength'] and chop(substr($Line['text'], $len), ' ') === '' ) { $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1); $Block['complete'] = true; return $Block; } $Block['element']['element']['text'] .= "\n" . $Line['body']; return $Block; } protected function blockFencedCodeComplete($Block) { return $Block; } # # Header protected function blockHeader($Line) { $level = strspn($Line['text'], '#'); if ($level > 6) { return; } $text = trim($Line['text'], '#'); if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') { return; } $text = trim($text, ' '); $Block = array( 'element' => array( 'name' => 'h' . $level, 'handler' => array( 'function' => 'lineElements', 'argument' => $text, 'destination' => 'elements', ) ), ); return $Block; } # # List protected function blockList($Line, array $CurrentBlock = null) { list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]'); if (preg_match('/^(' . $pattern . '([ ]++|$))(.*+)/', $Line['text'], $matches)) { $contentIndent = strlen($matches[2]); if ($contentIndent >= 5) { $contentIndent -= 1; $matches[1] = substr($matches[1], 0, -$contentIndent); $matches[3] = str_repeat(' ', $contentIndent) . $matches[3]; } elseif ($contentIndent === 0) { $matches[1] .= ' '; } $markerWithoutWhitespace = strstr($matches[1], ' ', true); $Block = array( 'indent' => $Line['indent'], 'pattern' => $pattern, 'data' => array( 'type' => $name, 'marker' => $matches[1], 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)), ), 'element' => array( 'name' => $name, 'elements' => array(), ), ); $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/'); if ($name === 'ol') { $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0'; if ($listStart !== '1') { if ( isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph' and ! isset($CurrentBlock['interrupted']) ) { return; } $Block['element']['attributes'] = array('start' => $listStart); } } $Block['li'] = array( 'name' => 'li', 'handler' => array( 'function' => 'li', 'argument' => !empty($matches[3]) ? array($matches[3]) : array(), 'destination' => 'elements' ) ); $Block['element']['elements'] [] = & $Block['li']; return $Block; } } protected function blockListContinue($Line, array $Block) { if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) { return null; } $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker'])); if ( $Line['indent'] < $requiredIndent and ( ( $Block['data']['type'] === 'ol' and preg_match('/^[0-9]++' . $Block['data']['markerTypeRegex'] . '(?:[ ]++(.*)|$)/', $Line['text'], $matches) ) or ( $Block['data']['type'] === 'ul' and preg_match('/^' . $Block['data']['markerTypeRegex'] . '(?:[ ]++(.*)|$)/', $Line['text'], $matches) ) ) ) { if (isset($Block['interrupted'])) { $Block['li']['handler']['argument'] [] = ''; $Block['loose'] = true; unset($Block['interrupted']); } unset($Block['li']); $text = isset($matches[1]) ? $matches[1] : ''; $Block['indent'] = $Line['indent']; $Block['li'] = array( 'name' => 'li', 'handler' => array( 'function' => 'li', 'argument' => array($text), 'destination' => 'elements' ) ); $Block['element']['elements'] [] = & $Block['li']; return $Block; } elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) { return null; } if ($Line['text'][0] === '[' and $this->blockReference($Line)) { return $Block; } if ($Line['indent'] >= $requiredIndent) { if (isset($Block['interrupted'])) { $Block['li']['handler']['argument'] [] = ''; $Block['loose'] = true; unset($Block['interrupted']); } $text = substr($Line['body'], $requiredIndent); $Block['li']['handler']['argument'] [] = $text; return $Block; } if (! isset($Block['interrupted'])) { $text = preg_replace('/^[ ]{0,' . $requiredIndent . '}+/', '', $Line['body']); $Block['li']['handler']['argument'] [] = $text; return $Block; } } protected function blockListComplete(array $Block) { if (isset($Block['loose'])) { foreach ($Block['element']['elements'] as &$li) { if (end($li['handler']['argument']) !== '') { $li['handler']['argument'] [] = ''; } } } return $Block; } # # Quote protected function blockQuote($Line) { if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { $Block = array( 'element' => array( 'name' => 'blockquote', 'handler' => array( 'function' => 'linesElements', 'argument' => (array) $matches[1], 'destination' => 'elements', ) ), ); return $Block; } } protected function blockQuoteContinue($Line, array $Block) { if (isset($Block['interrupted'])) { return; } if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) { $Block['element']['handler']['argument'] [] = $matches[1]; return $Block; } if (! isset($Block['interrupted'])) { $Block['element']['handler']['argument'] [] = $Line['text']; return $Block; } } # # Rule protected function blockRule($Line) { $marker = $Line['text'][0]; if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') { $Block = array( 'element' => array( 'name' => 'hr', ), ); return $Block; } } # # Setext protected function blockSetextHeader($Line, array $Block = null) { if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { return; } if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') { $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2'; return $Block; } } # # Markup protected function blockMarkup($Line) { if ($this->markupEscaped or $this->safeMode) { return; } if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+' . $this->regexHtmlAttribute . ')*+[ ]*+(\/)?>/', $Line['text'], $matches)) { $element = strtolower($matches[1]); if (in_array($element, $this->textLevelElements)) { return; } $Block = array( 'name' => $matches[1], 'element' => array( 'rawHtml' => $Line['text'], 'autobreak' => true, ), ); return $Block; } } protected function blockMarkupContinue($Line, array $Block) { if (isset($Block['closed']) or isset($Block['interrupted'])) { return; } $Block['element']['rawHtml'] .= "\n" . $Line['body']; return $Block; } # # Reference protected function blockReference($Line) { if ( strpos($Line['text'], ']') !== false and preg_match('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches) ) { $id = strtolower($matches[1]); $Data = array( 'url' => $matches[2], 'title' => isset($matches[3]) ? $matches[3] : null, ); $this->DefinitionData['Reference'][$id] = $Data; $Block = array( 'element' => array(), ); return $Block; } } # # Table protected function blockTable($Line, array $Block = null) { if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) { return; } if ( strpos($Block['element']['handler']['argument'], '|') === false and strpos($Line['text'], '|') === false and strpos($Line['text'], ':') === false or strpos($Block['element']['handler']['argument'], "\n") !== false ) { return; } if (chop($Line['text'], ' -:|') !== '') { return; } $alignments = array(); $divider = $Line['text']; $divider = trim($divider); $divider = trim($divider, '|'); $dividerCells = explode('|', $divider); foreach ($dividerCells as $dividerCell) { $dividerCell = trim($dividerCell); if ($dividerCell === '') { return; } $alignment = null; if ($dividerCell[0] === ':') { $alignment = 'left'; } if (substr($dividerCell, - 1) === ':') { $alignment = $alignment === 'left' ? 'center' : 'right'; } $alignments [] = $alignment; } # ~ $HeaderElements = array(); $header = $Block['element']['handler']['argument']; $header = trim($header); $header = trim($header, '|'); $headerCells = explode('|', $header); if (count($headerCells) !== count($alignments)) { return; } foreach ($headerCells as $index => $headerCell) { $headerCell = trim($headerCell); $HeaderElement = array( 'name' => 'th', 'handler' => array( 'function' => 'lineElements', 'argument' => $headerCell, 'destination' => 'elements', ) ); if (isset($alignments[$index])) { $alignment = $alignments[$index]; $HeaderElement['attributes'] = array( 'style' => "text-align: $alignment;", ); } $HeaderElements [] = $HeaderElement; } # ~ $Block = array( 'alignments' => $alignments, 'identified' => true, 'element' => array( 'name' => 'table', 'elements' => array(), ), ); $Block['element']['elements'] [] = array( 'name' => 'thead', ); $Block['element']['elements'] [] = array( 'name' => 'tbody', 'elements' => array(), ); $Block['element']['elements'][0]['elements'] [] = array( 'name' => 'tr', 'elements' => $HeaderElements, ); return $Block; } protected function blockTableContinue($Line, array $Block) { if (isset($Block['interrupted'])) { return; } if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) { $Elements = array(); $row = $Line['text']; $row = trim($row); $row = trim($row, '|'); preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches); $cells = array_slice($matches[0], 0, count($Block['alignments'])); foreach ($cells as $index => $cell) { $cell = trim($cell); $Element = array( 'name' => 'td', 'handler' => array( 'function' => 'lineElements', 'argument' => $cell, 'destination' => 'elements', ) ); if (isset($Block['alignments'][$index])) { $Element['attributes'] = array( 'style' => 'text-align: ' . $Block['alignments'][$index] . ';', ); } $Elements [] = $Element; } $Element = array( 'name' => 'tr', 'elements' => $Elements, ); $Block['element']['elements'][1]['elements'] [] = $Element; return $Block; } } # # ~ # protected function paragraph($Line) { return array( 'type' => 'Paragraph', 'element' => array( 'name' => 'p', 'handler' => array( 'function' => 'lineElements', 'argument' => $Line['text'], 'destination' => 'elements', ), ), ); } protected function paragraphContinue($Line, array $Block) { if (isset($Block['interrupted'])) { return; } $Block['element']['handler']['argument'] .= "\n" . $Line['text']; return $Block; } # # Inline Elements # protected $InlineTypes = array( '!' => array('Image'), '&' => array('SpecialCharacter'), '*' => array('Emphasis'), ':' => array('Url'), '<' => array('UrlTag', 'EmailTag', 'Markup'), '[' => array('Link'), '_' => array('Emphasis'), '`' => array('Code'), '~' => array('Strikethrough'), '\\' => array('EscapeSequence'), ); # ~ protected $inlineMarkerList = '!*_&[:<`~\\'; # # ~ # public function line($text, $nonNestables = array()) { return $this->elements($this->lineElements($text, $nonNestables)); } protected function lineElements($text, $nonNestables = array()) { # standardize line breaks $text = str_replace(array("\r\n", "\r"), "\n", $text); $Elements = array(); $nonNestables = (empty($nonNestables) ? array() : array_combine($nonNestables, $nonNestables) ); # $excerpt is based on the first occurrence of a marker while ($excerpt = strpbrk($text, $this->inlineMarkerList)) { $marker = $excerpt[0]; $markerPosition = strlen($text) - strlen($excerpt); $Excerpt = array('text' => $excerpt, 'context' => $text); foreach ($this->InlineTypes[$marker] as $inlineType) { # check to see if the current inline type is nestable in the current context if (isset($nonNestables[$inlineType])) { continue; } $Inline = $this->{"inline$inlineType"}($Excerpt); if (! isset($Inline)) { continue; } # makes sure that the inline belongs to "our" marker if (isset($Inline['position']) and $Inline['position'] > $markerPosition) { continue; } # sets a default inline position if (! isset($Inline['position'])) { $Inline['position'] = $markerPosition; } # cause the new element to 'inherit' our non nestables $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables']) ? array_merge($Inline['element']['nonNestables'], $nonNestables) : $nonNestables ; # the text that comes before the inline $unmarkedText = substr($text, 0, $Inline['position']); # compile the unmarked text $InlineText = $this->inlineText($unmarkedText); $Elements[] = $InlineText['element']; # compile the inline $Elements[] = $this->extractElement($Inline); # remove the examined text $text = substr($text, $Inline['position'] + $Inline['extent']); continue 2; } # the marker does not belong to an inline $unmarkedText = substr($text, 0, $markerPosition + 1); $InlineText = $this->inlineText($unmarkedText); $Elements[] = $InlineText['element']; $text = substr($text, $markerPosition + 1); } $InlineText = $this->inlineText($text); $Elements[] = $InlineText['element']; foreach ($Elements as &$Element) { if (! isset($Element['autobreak'])) { $Element['autobreak'] = false; } } return $Elements; } # # ~ # protected function inlineText($text) { $Inline = array( 'extent' => strlen($text), 'element' => array(), ); $Inline['element']['elements'] = self::pregReplaceElements( $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/', array( array('name' => 'br'), array('text' => "\n"), ), $text ); return $Inline; } protected function inlineCode($Excerpt) { $marker = $Excerpt['text'][0]; if (preg_match('/^([' . $marker . ']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]), 'element' => array( 'name' => 'code', 'text' => $text, ), ); } } protected function inlineEmailTag($Excerpt) { $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'; $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@' . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*'; if ( strpos($Excerpt['text'], '>') !== false and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches) ) { $url = $matches[1]; if (! isset($matches[2])) { $url = "mailto:$url"; } return array( 'extent' => strlen($matches[0]), 'element' => array( 'name' => 'a', 'text' => $matches[1], 'attributes' => array( 'href' => $url, ), ), ); } } protected function inlineEmphasis($Excerpt) { if (! isset($Excerpt['text'][1])) { return; } $marker = $Excerpt['text'][0]; if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) { $emphasis = 'strong'; } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) { $emphasis = 'em'; } else { return; } return array( 'extent' => strlen($matches[0]), 'element' => array( 'name' => $emphasis, 'handler' => array( 'function' => 'lineElements', 'argument' => $matches[1], 'destination' => 'elements', ) ), ); } protected function inlineEscapeSequence($Excerpt) { if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) { return array( 'element' => array('rawHtml' => $Excerpt['text'][1]), 'extent' => 2, ); } } protected function inlineImage($Excerpt) { if (! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') { return; } $Excerpt['text'] = substr($Excerpt['text'], 1); $Link = $this->inlineLink($Excerpt); if ($Link === null) { return; } $Inline = array( 'extent' => $Link['extent'] + 1, 'element' => array( 'name' => 'img', 'attributes' => array( 'src' => $Link['element']['attributes']['href'], 'alt' => $Link['element']['handler']['argument'], ), 'autobreak' => true, ), ); $Inline['element']['attributes'] += $Link['element']['attributes']; unset($Inline['element']['attributes']['href']); return $Inline; } protected function inlineLink($Excerpt) { $Element = array( 'name' => 'a', 'handler' => array( 'function' => 'lineElements', 'argument' => null, 'destination' => 'elements', ), 'nonNestables' => array('Url', 'Link'), 'attributes' => array( 'href' => null, 'title' => null, ), ); $extent = 0; $remainder = $Excerpt['text']; if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) { $Element['handler']['argument'] = $matches[1]; $extent += strlen($matches[0]); $remainder = substr($remainder, $extent); } else { return; } if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) { $Element['attributes']['href'] = $matches[1]; if (isset($matches[2])) { $Element['attributes']['title'] = substr($matches[2], 1, - 1); } $extent += strlen($matches[0]); } else { if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) { $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument']; $definition = strtolower($definition); $extent += strlen($matches[0]); } else { $definition = strtolower($Element['handler']['argument']); } if (! isset($this->DefinitionData['Reference'][$definition])) { return; } $Definition = $this->DefinitionData['Reference'][$definition]; $Element['attributes']['href'] = $Definition['url']; $Element['attributes']['title'] = $Definition['title']; } return array( 'extent' => $extent, 'element' => $Element, ); } protected function inlineMarkup($Excerpt) { if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) { return; } if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) { return array( 'element' => array('rawHtml' => $matches[0]), 'extent' => strlen($matches[0]), ); } if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) { return array( 'element' => array('rawHtml' => $matches[0]), 'extent' => strlen($matches[0]), ); } if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+' . $this->regexHtmlAttribute . ')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) { return array( 'element' => array('rawHtml' => $matches[0]), 'extent' => strlen($matches[0]), ); } } protected function inlineSpecialCharacter($Excerpt) { if ( substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches) ) { return array( 'element' => array('rawHtml' => '&' . $matches[1] . ';'), 'extent' => strlen($matches[0]), ); } return; } protected function inlineStrikethrough($Excerpt) { if (! isset($Excerpt['text'][1])) { return; } if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) { return array( 'extent' => strlen($matches[0]), 'element' => array( 'name' => 'del', 'handler' => array( 'function' => 'lineElements', 'argument' => $matches[1], 'destination' => 'elements', ) ), ); } } protected function inlineUrl($Excerpt) { if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') { return; } if ( strpos($Excerpt['context'], 'http') !== false and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE) ) { $url = $matches[0][0]; $Inline = array( 'extent' => strlen($matches[0][0]), 'position' => $matches[0][1], 'element' => array( 'name' => 'a', 'text' => $url, 'attributes' => array( 'href' => $url, ), ), ); return $Inline; } } protected function inlineUrlTag($Excerpt) { if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) { $url = $matches[1]; return array( 'extent' => strlen($matches[0]), 'element' => array( 'name' => 'a', 'text' => $url, 'attributes' => array( 'href' => $url, ), ), ); } } # ~ protected function unmarkedText($text) { $Inline = $this->inlineText($text); return $this->element($Inline['element']); } # # Handlers # protected function handle(array $Element) { if (isset($Element['handler'])) { if (!isset($Element['nonNestables'])) { $Element['nonNestables'] = array(); } if (is_string($Element['handler'])) { $function = $Element['handler']; $argument = $Element['text']; unset($Element['text']); $destination = 'rawHtml'; } else { $function = $Element['handler']['function']; $argument = $Element['handler']['argument']; $destination = $Element['handler']['destination']; } $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']); if ($destination === 'handler') { $Element = $this->handle($Element); } unset($Element['handler']); } return $Element; } protected function handleElementRecursive(array $Element) { return $this->elementApplyRecursive(array($this, 'handle'), $Element); } protected function handleElementsRecursive(array $Elements) { return $this->elementsApplyRecursive(array($this, 'handle'), $Elements); } protected function elementApplyRecursive($closure, array $Element) { $Element = call_user_func($closure, $Element); if (isset($Element['elements'])) { $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']); } elseif (isset($Element['element'])) { $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']); } return $Element; } protected function elementApplyRecursiveDepthFirst($closure, array $Element) { if (isset($Element['elements'])) { $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']); } elseif (isset($Element['element'])) { $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']); } $Element = call_user_func($closure, $Element); return $Element; } protected function elementsApplyRecursive($closure, array $Elements) { foreach ($Elements as &$Element) { $Element = $this->elementApplyRecursive($closure, $Element); } return $Elements; } protected function elementsApplyRecursiveDepthFirst($closure, array $Elements) { foreach ($Elements as &$Element) { $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element); } return $Elements; } protected function element(array $Element) { if ($this->safeMode) { $Element = $this->sanitiseElement($Element); } # identity map if element has no handler $Element = $this->handle($Element); $hasName = isset($Element['name']); $markup = ''; if ($hasName) { $markup .= '<' . $Element['name']; if (isset($Element['attributes'])) { foreach ($Element['attributes'] as $name => $value) { if ($value === null) { continue; } $markup .= " $name=\"" . self::escape($value) . '"'; } } } $permitRawHtml = false; if (isset($Element['text'])) { $text = $Element['text']; } // very strongly consider an alternative if you're writing an // extension elseif (isset($Element['rawHtml'])) { $text = $Element['rawHtml']; $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode']; $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode; } $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']); if ($hasContent) { $markup .= $hasName ? '>' : ''; if (isset($Element['elements'])) { $markup .= $this->elements($Element['elements']); } elseif (isset($Element['element'])) { $markup .= $this->element($Element['element']); } else { if (!$permitRawHtml) { $markup .= self::escape($text, true); } else { $markup .= $text; } } $markup .= $hasName ? '' : ''; } elseif ($hasName) { $markup .= ' />'; } return $markup; } protected function elements(array $Elements) { $markup = ''; $autoBreak = true; foreach ($Elements as $Element) { if (empty($Element)) { continue; } $autoBreakNext = (isset($Element['autobreak']) ? $Element['autobreak'] : isset($Element['name']) ); // (autobreak === false) covers both sides of an element $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext; $markup .= ($autoBreak ? "\n" : '') . $this->element($Element); $autoBreak = $autoBreakNext; } $markup .= $autoBreak ? "\n" : ''; return $markup; } # ~ protected function li($lines) { $Elements = $this->linesElements($lines); if ( ! in_array('', $lines) and isset($Elements[0]) and isset($Elements[0]['name']) and $Elements[0]['name'] === 'p' ) { unset($Elements[0]['name']); } return $Elements; } # # AST Convenience # /** * Replace occurrences $regexp with $Elements in $text. Return an array of * elements representing the replacement. */ protected static function pregReplaceElements($regexp, $Elements, $text) { $newElements = array(); while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) { $offset = $matches[0][1]; $before = substr($text, 0, $offset); $after = substr($text, $offset + strlen($matches[0][0])); $newElements[] = array('text' => $before); foreach ($Elements as $Element) { $newElements[] = $Element; } $text = $after; } $newElements[] = array('text' => $text); return $newElements; } # # Deprecated Methods # function parse($text) { $markup = $this->text($text); return $markup; } protected function sanitiseElement(array $Element) { static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/'; static $safeUrlNameToAtt = array( 'a' => 'href', 'img' => 'src', ); if (! isset($Element['name'])) { unset($Element['attributes']); return $Element; } if (isset($safeUrlNameToAtt[$Element['name']])) { $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]); } if (! empty($Element['attributes'])) { foreach ($Element['attributes'] as $att => $val) { # filter out badly parsed attribute if (! preg_match($goodAttribute, $att)) { unset($Element['attributes'][$att]); } # dump onevent attribute elseif (self::striAtStart($att, 'on')) { unset($Element['attributes'][$att]); } } } return $Element; } protected function filterUnsafeUrlInAttribute(array $Element, $attribute) { foreach ($this->safeLinksWhitelist as $scheme) { if (self::striAtStart($Element['attributes'][$attribute], $scheme)) { return $Element; } } $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]); return $Element; } # # Static Methods # protected static function escape($text, $allowQuotes = false) { return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8'); } protected static function striAtStart($string, $needle) { $len = strlen($needle); if ($len > strlen($string)) { return false; } else { return strtolower(substr($string, 0, $len)) === strtolower($needle); } } static function instance($name = 'default') { if (isset(self::$instances[$name])) { return self::$instances[$name]; } $instance = new static(); self::$instances[$name] = $instance; return $instance; } private static $instances = array(); # # Fields # protected $DefinitionData; # # Read-Only protected $specialCharacters = array( '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~' ); protected $StrongRegex = array( '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s', '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us', ); protected $EmRegex = array( '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s', '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us', ); protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+'; protected $voidElements = array( 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', ); protected $textLevelElements = array( 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont', 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing', 'i', 'rp', 'del', 'code', 'strike', 'marquee', 'q', 'rt', 'ins', 'font', 'strong', 's', 'tt', 'kbd', 'mark', 'u', 'xm', 'sub', 'nobr', 'sup', 'ruby', 'var', 'span', 'wbr', 'time', ); } } // Note: leave the end tag for packaging ?> /dev/null 2>&1 &'; /** * Help info for $cache_lifetime * * @var mixed * * @internal */ protected $__cache_lifetime = ["Default time to cache data in seconds"]; /** * Default lifetime of cached data in seconds - when to expire * * - Defaults to 86400 (24 hours) * * @var integer * @api */ public $cache_lifetime = 86400; /** * Help info for $editor_exec * * @var mixed * * @internal */ protected $__editor_exec = ["Command to open file in editor - %s for filepath placeholder via sprintf"]; /** * Editor executable - command to be run when opening a new file * * - %s is the filepath placeholder * - Defaults to vim in *insert* mode * * @var string */ protected $editor_exec = '/usr/bin/vim -c "startinsert" %s > `tty`'; /** * Help info for $editor_modify_exec * * @var mixed * * @internal */ protected $__editor_modify_exec = ["Command to open file in editor to review/modify existing text - %s for filepath placeholder via sprintf"]; /** * Editor executable - command to be run when opening a file for *modification* * * - Eg. existing file that is being modified * - %s is the filepath placeholder * - Defaults to vim in *normal* mode - preferred for modification * * @var string */ protected $editor_modify_exec = '/usr/bin/vim %s > `tty`'; /** * Help info for $install_path * * @var mixed * * @internal */ protected $__install_path = ["Install path of this tool", "string"]; /** * Install path for packaged tool executables * * @var string * @api */ public $install_path = DS . "usr" . DS . "local" . DS . "bin"; /** * Help info for $livefilter * * @var mixed * * @internal */ protected $__livefilter = ["Status of livefilter for select interface - false/disabled, true/enabled, or autoenter", "string"]; /** * Status of livefilter for select interface - false/disabled, true/enabled, or autoenter * * @var mixed */ public $livefilter = 'enabled'; /** * Help info for $ssl_check * * @var mixed * * @internal */ protected $__ssl_check = "Whether to check SSL certificates with curl"; /** * Whether to check SSL certificates on network connections * * - Defaults to true * * @var boolean * @api */ public $ssl_check = true; /** * Help info for $stamp_lines * * @var mixed * * @internal */ protected $__stamp_lines = "Stamp / prefix output lines with the date and time"; /** * Whether to prefix output lines with date and time * * - Defaults to false * * @var boolean * @api */ public $stamp_lines = false; /** * Help info for $step * * @var mixed * * @internal */ protected $__step = "Enable stepping/pause points for debugging"; /** * Whether to enable stepping / pause points for debugging * * - Defaults to false * * @var boolean * @api */ public $step = false; /** * Help info for $timezone * * @var mixed * * @internal */ protected $__timezone = ["Timezone - from http://php.net/manual/en/timezones.", "string"]; /** * Timezone - from http://php.net/manual/en/timezones. * * - Defaults to "US/Eastern" * * @var string * @api */ public $timezone = "America/New_York"; /** * Help info for $update_auto * * @var mixed * * @internal */ protected $__update_auto = ["How often to automatically check for an update (seconds, 0 to disable)", "int"]; /** * How often (in seconds) to automatically check for an update * * - Defaults to 86400 (24 hours) * - Set to 0 to disable updates * * @var integer * @api */ public $update_auto = 86400; /** * Help info for $update_last_check * * @var mixed * * @internal */ protected $__update_last_check = ["Formatted timestap of last update check", "string"]; /** * Timestamp of last update check * * - Not typically set manually * - Stored in config for easy reference and simplicity * - Defaults to "" - no update check completed yet * * @var string * @api */ public $update_last_check = ""; /** * Help info for $update_version_url * * @var mixed * * @internal */ protected $__update_version_url = ["URL to check for latest version number info", "string"]; /** * The URL to check for updates * * - Empty string will disable checking for updates * - The tool child class itself should set a default * - Common choice would be to use raw URL of Github readme file * - Set in config to set up a custom update methodology or disable updates * * @var string * @see PCon::update_version_url for an example setting * @api */ public $update_version_url = ""; /** * Help info for $update_check_hash * * @var mixed * * @internal */ protected $__update_check_hash = ["Whether to check hash of download when updating", "binary"]; /** * Whether to check the hash when downloading updates * * - Defaults to true * * @var boolean * @api */ public $update_check_hash = true; /** * Help info for $verbose * * @var mixed * * @internal */ protected $__verbose = "Enable verbose output"; /** * Whether to show log messages - verbose output * * - Defaults to false * * @var boolean * @api */ public $verbose = false; /** * Help info for $__WSC__ * * @var mixed * * @internal */ protected $____WSC__ = "HJSON Data for config file"; /** * HJSON Data for the config file * * @var array * @api */ public $__WSC__ = null; /** * Config directory * * @var string */ protected $config_dir = null; /** * Config file * * @var string */ protected $config_file = null; /** * Home directory * * @var string */ protected $home_dir = null; /** * Config initialized flag * * @var boolean */ protected $config_initialized = false; /** * Config data to be saved * * @var array */ protected $config_to_save = null; /** * Config is OK to create * * @var boolean */ protected $config_ok_to_create = true; /** * Timestamp when the tool was initilized - eg. when constructor ran * * @var string */ protected $run_stamp = ''; /** * The method being called * * - set by Command::try_calling * * @var string */ protected $method = ''; /** * The user that initially logged in to the current session * * - as reported by logname * * @var string */ protected $logged_in_user = ''; /** * The currently active user * * - as reported by whoami * * @var string */ protected $current_user = ''; /** * Whether the user is logged in as root * * - Ie. is logged_in_user === 'root' * * @var boolean */ protected $logged_in_as_root = false; /** * Whether user is currently root * * - Ie. current_user === 'root' * * @var boolean */ protected $running_as_root = false; /** * Whether tool is running on a Windows operating system * * @var boolean */ protected $is_windows = false; /** * The minimum PHP major version supported by this tool * * @var integer */ protected $minimum_php_version = "8.0"; /** * The minimum PHP major version supported by the "livefilter" feature * * @var integer */ protected $minimum_php_version_livefilter = "8.0"; /** * Update behavior - either "DOWNLOAD" or custom text * * - If set to "DOWNLOAD" then the update will download if available * - Otherwise, whatever text is set here will show as a message - ie. instructions * on how to update the tool manually. * - Defaults to 'DOWNLOAD' as that is what most tools will use * - However, as an example, PCon::update_behavior instructs users to pull the git repository to update it * * @var string */ protected $update_behavior = 'DOWNLOAD'; /** * The standard/default pattern to identify the latest version and URL * within the text found at $this->update_version_url. * * - By default, group 1 is the version - see $this->update_version_pattern * - By default, group 2 is the download URL - see $this->pdate_download_pattern * - Defaults to look for a string like: * Download Latest Version (1.1.1): * https://example.com * * @var string */ protected $update_pattern_standard = "~ download\ latest\ version \s* \( \s* ( [\d.]+ ) \s* \) \s* : \s* ( \S* ) \s*$ ~ixm"; /** * The standard/default pattern to identify the hash of the latest version download * within the text found at $this->update_version_url. * * - By default, group 1 is the algorithm - see $this->update_hash_algorithm_pattern * - By default, group 2 is the hash - see $this->update_hash_pattern * - Defaults to look for a string like: * Latest Version Hash (md5): * hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh * * @var string */ protected $hash_pattern_standard = "~ latest\ version\ hash \s* \( \s* ( .+ ) \s* \) \s* : \s* ([0-9a-f]+) ~ixm"; /** * Instructions to find update version at $this->update_version_url. * * - First element is pattern * - Defaults to true to use $update_pattern_standard * - Second element is match index * - Defaults to 1 to use first match group as version * * @var array */ protected $update_version_pattern = [ true, 1 ]; /** * Instructions to find update download URL at $this->update_version_url. * * - First element is pattern * - Defaults to true to use $update_pattern_standard * - Second element is match index * - Defaults to 2 to use second match group as version * * @var array */ protected $update_download_pattern = [ true, 2 ]; /** * Instructions to find download hash algorithm at $this->update_version_url. * * - First element is pattern * - Defaults to true to use $hash_pattern_standard * - Second element is match index * - Defaults to 1 to use first match group as version * * @var array */ protected $update_hash_algorithm_pattern = [ true, 1 ]; /** * Instructions to find download hash at $this->update_version_url. * * - First element is pattern * - Defaults to true to use $hash_pattern_standard * - Second element is match index * - Defaults to 2 to use second match group as version * * @var array */ protected $update_hash_pattern = [ true, 2 ]; /** * Whether an update exists or not - to avoid multiple checks * * @var boolean * * @internal */ protected $update_exists = null; /** * Latest version available for update * * @var string * * @internal */ protected $update_version = "0"; /** * URL of latest version available for update * * @var string * * @internal */ protected $update_url = ""; /** * Hash algorithm to use for packaging and update verification * * - Defaults to md5 * * @var string */ protected $update_hash_algorithm = "md5"; /** * Hash of latest version available for update * * @var string * * @internal */ protected $update_hash = ""; /** * Constructor * * - Sets default timezone for date functions to use * - Sets run_stamp * - Determines runtime details - user, OS * - Calls parent (Command) constructor */ public function __construct() { $this->checkRequirements(); date_default_timezone_set($this->timezone); $this->run_stamp = $this->stamp(); exec('logname', $logged_in_user, $return); if ($return == 0 and ! empty($logged_in_user)) { $this->logged_in_user = trim(implode($logged_in_user)); } $this->logged_in_as_root = ($this->logged_in_user === 'root'); exec('whoami', $current_user, $return); if ($return == 0 and ! empty($current_user)) { $this->current_user = trim(implode($current_user)); } $this->running_as_root = ($this->current_user === 'root'); $this->is_windows = (strtolower(substr(PHP_OS, 0, 3)) === 'win'); parent::__construct($this); }//end __construct() /** * Check requirements * * - Eg. PHP Version & Modules * - Extend in child if needed and pass problems to parent * * @param array $problems Existing problems passed by child class. * * @return void */ protected function checkRequirements(array $problems = []) { $this->log("PHP Version: " . PHP_VERSION); $this->log("OS: " . PHP_OS); $this->log("Windows: " . ($this->is_windows ? "Yes" : "No")); $major_problem = false; if (version_compare(PHP_VERSION, $this->minimum_php_version) < 0) { $problems[] = "This tool is not well tested below PHP " . $this->minimum_php_version . "\n - Please upgrade to PHP " . $this->minimum_php_version . " or higher"; } if (! function_exists('curl_version')) { $problems[] = "This tool requires curl - please install https://www.php.net/manual/en/book.curl.php - specific install steps vary by OS"; $major_problem = true; } if (! function_exists('mb_strlen')) { $problems[] = "This tool requires mbstring - please install https://www.php.net/manual/en/book.mbstring.php - specific install steps vary by OS"; $major_problem = true; } if (! empty($problems)) { $this->error("There are some problems with requirements: \n - " . implode("\n - ", $problems), $major_problem, true); } }//end checkRequirements() /** * Check requirements that need to be informed by config values * * - Extend in child if needed and pass problems to parent * * @param array $problems Existing problems passed by child class. * * @return void */ protected function checkRequirementsAfterConfigLoad(array $problems = []) { $major_problem = false; if ($this->livefilter !== "disabled" && $this->livefilter !== false) { if (version_compare(PHP_VERSION, $this->minimum_php_version_livefilter) < 0) { $problems[] = "Livefilter does not work well below PHP " . $this->minimum_php_version_livefilter . "\n - Either upgrade to PHP " . $this->minimum_php_version_livefilter . " or higher" . "\n - Or set livefilter to 'disabled' in " . $this->config_file; } } if ($this->livefilter && $this->is_windows) { $problems[] = "Livefilter is not supported on Windows"; $major_problem = true; } if (! empty($problems)) { $this->error("There are some problems with requirements: \n - " . implode("\n - ", $problems), $major_problem, true); } }//end checkRequirementsAfterConfigLoad() /** * Run - parse args and run method specified * * - Entry point for the tool - child class will run this * - Eg. see Pcon class * * @param array $arg_list Array of args passed via command line (Ie. built-in $argv). * * @return void */ public static function run(array $arg_list = []) { $class = get_called_class(); $script = array_shift($arg_list); $instance = new $class(); try { $instance->_startup($arg_list); $instance->initConfig(); $instance->checkRequirementsAfterConfigLoad(); $instance->try_calling($arg_list, true); $instance->_shutdown($arg_list); } catch (Exception $e) { $instance->error($e->getMessage()); } }//end run() /** * Help info for backup method * * @var mixed * * @internal */ protected $___backup = [ "Backup a file or files to the configured backup folder", ["Paths to back up", "string", "required"], ["Whether to output when backup is complete"] ]; /** * Method to backup files used by the tool * * @param mixed $files File(s) to back up. * @param boolean $output Whether to output information while running. * * @return boolean Whether backup was successful. * @api */ public function backup($files, bool $output = true): bool { $success = true; $files = $this->prepArg($files, []); if (empty($this->backup_dir)) { $this->warn('Backups are disabled - no backup_dir specified in config', true); return false; } if (! is_dir($this->backup_dir)) { mkdir($this->backup_dir, 0755, true); } foreach ($files as $file) { $this->output("Backing up $file...", false); if (! is_file($file)) { $this->br(); $this->warn("$file does not exist - skipping", true); continue; } $backup_file = $this->backup_dir . DS . basename($file) . '-' . $this->stamp() . '.bak'; $this->log(" - copying to $backup_file"); // Back up target $success = ($success and copy($file, $backup_file)); if ($success) { $this->output('successful'); } else { $this->br(); $this->warn("Failed to back up $file", true); continue; } }//end foreach // Clean up old backups - keep backup_age_limit days worth $this->exec("find \"{$this->backup_dir}\" -mtime +{$this->backup_age_limit} -type f -delete"); return $success; }//end backup() /** * Help info for eval_file method * * @var mixed * * @internal */ protected $___eval_file = [ "Evaluate a php script file, which will have access to all internal methods via '\$this'", ["File to evaluate", "string", "required"] ]; /** * Evaluate a script file in the tool environment. * * - Use this to write scripts that can use the tool's methods * * @param string $file Path to the script file to run. * @param mixed ...$evaluation_arguments Arguments to pass to the script file being run. * * @return void * @api */ public function eval_file(string $file, ...$evaluation_arguments) { if (! is_file($file)) { $this->error("File does not exist, check the path: $file"); } if (! is_readable($file)) { $this->error("File is not readable, check permissions: $file"); } require_once $file; }//end eval_file() /** * Help info for install method * * @var mixed * * @internal */ protected $___install = [ "Install a packaged PHP console tool", ["Install path", "string"], ]; /** * Install the packaged tool. * * @param string $install_path Path to which to install the tool. Defaults to configured install path. * * @return void * @api */ public function install(string $install_path = null) { if (! defined('PACKAGED') or ! PACKAGED) { $this->error('Only packaged tools may be installed - package first using PCon (https://cmp.onl/tjNJ)'); } $install_path = $this->prepArg($install_path, null); if (empty($install_path)) { $install_path = $this->install_path; } if ($this->is_windows) { $this->warn( "Since you appear to be running on Windows, you will very likely need to modify your install path" . "\n - The current setting is: " . $install_path . "\n - The desired setting will vay based on your environment, but you'll probably want to use a directory that's in your PATH" . "\n - For example, if you're using Git Bash, you may want to use: C:\Program Files\Git\usr\local\bin as your install path " . "\n Enter 'y' if the install path is correct and you are ready to install" . "\n Enter 'n' to halt now so you can edit 'install_path' in your config file (" . $this->getConfigFile() . ")", true ); } if (! is_dir($install_path)) { $this->warn("Install path ($install_path) does not exist and will be created", true); $success = mkdir($install_path, 0755, true); if (! $success) { $this->error("Failed to create install path ($install_path) - may need higher privileges (eg. sudo or run as admin)"); } } $tool_path = __FILE__; $filename = basename($tool_path); $install_tool_path = $install_path . DS . $filename; if (file_exists($install_tool_path)) { $this->warn("This will overwrite the existing executable ($install_tool_path)", true); } $success = rename($tool_path, $install_tool_path); if (! $success) { $this->error("Install failed - may need higher privileges (eg. sudo or run as admin)"); } $this->configure('install_path', $install_path, true); $this->saveConfig(); $this->log("Install completed to $install_tool_path with no errors"); }//end install() /** * Help info for update method * * @var mixed * * @internal */ protected $___update = [ "Update an installed PHP console tool" ]; /** * Update the tool - check for an update and install if available * * @return void * @api */ public function update() { // Make sure update is available // - Not automatic, Show output if (! $this->updateCheck(false, true)) { return; } // Check prescribed behavior if ($this->update_behavior != 'DOWNLOAD') { $this->output($this->update_behavior); return; } if (! defined('PACKAGED') or ! PACKAGED) { $this->error('Only packaged tools may be updated - package first using PCon (https://cmp.onl/tjNJ), then install'); } // Check install path valid $this_filename = basename(__FILE__); $config_install_tool_path = $this->install_path . DS . $this_filename; if ($config_install_tool_path != __FILE__) { $this->warn( "Install path mismatch.\n" . " - Current tool path: " . __FILE__ . "\n" . " - Configured install path: " . $config_install_tool_path . "\n" . "Update will be installed to " . $config_install_tool_path, true ); } // Create install path if needed if (! is_dir($this->install_path)) { $this->warn("Install path ($this->install_path) does not exist and will be created", true); $success = mkdir($this->install_path, 0755, true); if (! $success) { $this->error("Failed to create install path ($this->install_path) - may need higher privileges (eg. sudo or run as admin)"); } } $this->log('Downloading update to temp file, from ' . $this->update_url); $temp_dir = sys_get_temp_dir(); $temp_path = $temp_dir . DS . $this_filename . time(); if (is_file($temp_path)) { $success = unlink($temp_path); if (! $success) { $this->error("Failed to delete existing temp file ($temp_path) - may need higher privileges (eg. sudo or run as admin)"); } } $curl = $this->getCurl($this->update_url, true); $updated_contents = $this->execCurl($curl); if (empty($updated_contents)) { $this->error("Download failed - no contents at " . $this->update_url); } $success = file_put_contents($temp_path, $updated_contents); if (! $success) { $this->error("Failed to write to temp file ($temp_path) - may need higher privileges (eg. sudo or run as admin)"); } if ($this->update_check_hash) { $this->log('Checking hash of downloaded file (' . $this->update_hash_algorithm . ')'); $download_hash = hash_file($this->update_hash_algorithm, $temp_path); if ($download_hash != $this->update_hash) { $this->log('Download Hash: ' . $download_hash); $this->log('Update Hash: ' . $this->update_hash); unlink($temp_path); $this->error("Hash of downloaded file is incorrect; check download source"); } } $this->log('Installing downloaded file'); $success = rename($temp_path, $config_install_tool_path); $success = $success and chmod($config_install_tool_path, 0755); if (! $success) { $this->error("Update failed - may need higher privileges (eg. sudo or run as admin)"); } $this->output('Update complete'); }//end update() /** * Help info for version method * * @var mixed * * @internal */ protected $___version = [ "Output version information" ]; /** * Show the current version of the running/local tool. * * @param boolean $output Whether to output information while running. * * @return mixed The version string if output is false, otherwise false. * @api */ public function version(bool $output = true) { $class = get_called_class(); $version_string = $class::SHORTNAME . ' version ' . $class::VERSION; if ($output) { $this->output($version_string); return false; } else { return $version_string; } }//end version() /** * Check for an update, and parse out all relevant information if one exists * * @param boolean $auto Whether this is an automatic check or triggered intentionally. * @param boolean $output Whether to output information while running.. * * @return boolean True if newer version exists. False if: * - no new version or * - if auto, but auto check is disabled or * - if auto, but not yet time to check or * - if update is disabled */ protected function updateCheck(bool $auto = true, bool $output = false): bool { $this->log("Running update check"); if (empty($this->update_version_url)) { if (($output and ! $auto) or $this->verbose) { $this->output("Update is disabled - update_version_url is empty"); } // update disabled return false; } if (is_null($this->update_exists)) { $now = time(); // If this is an automatic check, make sure it's time to check again if ($auto) { $this->log("Designated as auto-update"); // If disabled, return false if ($this->update_auto <= 0) { $this->log("Auto-update is disabled - update_auto <= 0"); // auto-update disabled return false; } // If we haven't checked before, we'll check now // Otherwise... if (! empty($this->update_last_check)) { $last_check = strtotime($this->update_last_check); // Make sure last check was a valid time if (empty($last_check) or $last_check < 0) { $this->error('Issue with update_last_check value (' . $this->update_last_check . ')'); } // Has it been long enough? If not, we'll return false $seconds_since_last_check = $now - $last_check; if ($seconds_since_last_check < $this->update_auto) { $this->log("Only $seconds_since_last_check seconds since last check. Configured auto-update is " . $this->update_auto . " seconds"); // not yet time to check return false; } } }//end if // curl, get contents at config url $curl = $this->getCurl($this->update_version_url, true); $update_contents = $this->execCurl($curl); // look for version match if ($this->update_version_pattern[0] === true) { $this->update_version_pattern[0] = $this->update_pattern_standard; } if (! preg_match($this->update_version_pattern[0], $update_contents, $match)) { $this->log($update_contents); $this->log($this->update_version_pattern[0]); $this->error('Issue with update version check - pattern not found at ' . $this->update_version_url, null, true); return false; } $index = $this->update_version_pattern[1]; $this->update_version = $match[$index]; // check if remote version is newer than installed $class = get_called_class(); $this->update_exists = version_compare($class::VERSION, $this->update_version, '<'); if ($output or $this->verbose) { if ($this->update_exists) { $this->hr('>'); $this->output("An update is available: version " . $this->update_version . " (currently installed version is " . $class::VERSION . ")"); if ($this->method != 'update') { $this->output(" - Run 'update' to install latest version."); $this->output(" - See 'help update' for more information."); } $this->hr('>'); } else { $this->output("Already at latest version (" . $class::VERSION . ")"); } } // look for download match if ($this->update_download_pattern[0] === true) { $this->update_download_pattern[0] = $this->update_pattern_standard; } if (! preg_match($this->update_download_pattern[0], $update_contents, $match)) { $this->error('Issue with update download check - pattern not found at ' . $this->update_version_url, null, true); return false; } $index = $this->update_download_pattern[1]; $this->update_url = $match[$index]; if ($this->update_check_hash) { // look for hash algorithm match if ($this->update_hash_algorithm_pattern[0] === true) { $this->update_hash_algorithm_pattern[0] = $this->hash_pattern_standard; } if (! preg_match($this->update_hash_algorithm_pattern[0], $update_contents, $match)) { $this->error('Issue with update hash algorithm check - pattern not found at ' . $this->update_version_url); } $index = $this->update_hash_algorithm_pattern[1]; $this->update_hash_algorithm = $match[$index]; // look for hash match if ($this->update_hash_pattern[0] === true) { $this->update_hash_pattern[0] = $this->hash_pattern_standard; } if (! preg_match($this->update_hash_pattern[0], $update_contents, $match)) { $this->error('Issue with update hash check - pattern not found at ' . $this->update_version_url); } $index = $this->update_hash_pattern[1]; $this->update_hash = $match[$index]; }//end if $this->configure('update_last_check', gmdate('Y-m-d H:i:s T', $now), true); $this->saveConfig(); }//end if $this->log(" -- update_exists: " . $this->update_exists); $this->log(" -- update_version: " . $this->update_version); $this->log(" -- update_url: " . $this->update_url); $this->log(" -- update_hash_algorithm: " . $this->update_hash_algorithm); $this->log(" -- update_hash: " . $this->update_hash); return $this->update_exists; }//end updateCheck() /** * Clear - clear the CLI output * * - Provides the functionality for Command::clear() * * @return void * @api */ public function clear() { system('clear'); }//end clear() /** * Exec - run bash command * * - run a command * - return the output as a string * * @param string $command The bash command to be run. * @param boolean $error Whether to show an error if return code indicates error - otherwise, will show a warning. * * @return string Output resulting from the command run. */ public function exec(string $command, bool $error = false): string { $this->log("exec: $command"); exec($command, $output, $return); $output = empty($output) ? "" : "\n\t" . implode("\n\t", $output); if ($return) { $output = empty($output) ? "Return Code: " . $return : $output; if ($error) { $this->error($output); } else { $this->warn($output); } } $this->log($output); return $output; }//end exec() /** * Error output. Shows a message with ERROR prefix and either exits with the specified error code or prompts whether to continue. * * - 100 - expected error - eg. aborted due to user input * - 200 - safety / caution error (eg. running as root) * - 500 - misc. error * * @param mixed $data Error message/data to output. * @param mixed $code Error code to exit with - false = no exit. * @param boolean $prompt_to_continue Whether to prompt/ask user whether to continue. * * @return void */ public function error($data, $code = 500, bool $prompt_to_continue = false) { $this->br(); $this->hr('!'); $this->output('ERROR: ', false); $this->output($data); $this->hr('!'); if ($code) { exit($code === true ? 500 : $code); } if ($prompt_to_continue) { $yn = $this->input("Continue? (y/n)", 'n', false, true); if (! in_array($yn, ['y', 'Y'])) { $this->error('Aborted', 100); } } }//end error() /** * Warn output. Shows a message with WARNING prefix and optionally prompts whether to continue. * * @param mixed $data Error message/data to output. * @param boolean $prompt_to_continue Whether to prompt/ask user whether to continue. * * @return void */ public function warn($data, bool $prompt_to_continue = false) { $this->br(); $this->hr('*'); $this->output('WARNING: ', false); $this->output($data, true, false); $this->hr('*'); if ($prompt_to_continue) { $this->log("Getting input to continue"); $yn = $this->input("Continue? (y/n)", 'n', false, true); if (! in_array($yn, ['y', 'Y'])) { $this->error('Aborted', 100); } } }//end warn() /** * Log output - outputs data if $this->verbose is true - otherwise, does nothing. * * @param mixed $data Message/data to output. * * @return void */ public function log($data) { if (! $this->verbose) { return; } $this->output($data); }//end log() /** * Output data to console. * * @param mixed $data Message/data to output. * @param boolean $line_ending Whether to output a line ending. * @param boolean $stamp_lines Whether to prefix each line with a timestamp. * * @return void */ public function output($data, bool $line_ending = true, bool $stamp_lines = null) { $data = $this->stringify($data); $stamp_lines = is_null($stamp_lines) ? $this->stamp_lines : $stamp_lines; if ($stamp_lines) { echo $this->stamp() . ' ... '; } echo $data . ($line_ending ? "\n" : ""); }//end output() /** * Output a progress bar to the console. * * - If $this-verbose is set to true, then this shows text progress instead * - $count/$total $description * * @param integer $count The index of current progress - eg. current item index - start with 0. * @param integer $total The total amount of progress to be worked through - eg. total number of items. * @param string $description The description to be shown for verbose output. * * @return void */ public function outputProgress(int $count, int $total, string $description = "remaining") { if (! $this->verbose && $total > 0) { if ($count > 0) { // Set cursor to first column echo chr(27) . "[0G"; // Set cursor up 2 lines echo chr(27) . "[2A"; } $full_width = $this->getTerminalWidth(); $pad = $full_width - 1; $bar_count = floor(($count * $pad) / $total); $output = "["; $output = str_pad($output, $bar_count, "|"); $output = str_pad($output, $pad, " "); $output .= "]"; $this->output($output); $this->output(str_pad("$count/$total", $full_width, " ", STR_PAD_LEFT)); } else { $this->output("$count/$total $description"); } }//end outputProgress() /** * Stringify some data for output. Processes differently depending on the type of data. * * @param mixed $data Message/data to output. * * @return string The stringified data - ready for output. */ public function stringify($data): string { if (is_object($data) or is_array($data)) { $data = print_r($data, true); } elseif (is_bool($data)) { $data = $data ? "(Bool) True" : "(Bool) False"; } elseif (is_null($data)) { $data = "(NULL)"; } elseif (is_int($data)) { $data = "(int) $data"; } elseif (! is_string($data)) { ob_start(); var_dump($data); $data = ob_get_clean(); } // Trimming breaks areas where we *want* extra white space // - must be done explicitly instead, or modify to pass in as an option maybe... // $data = trim($data, " \t\n\r\0\x0B"); return $data; }//end stringify() /** * Colorize/decorate/format a string for output to console. * * @param string $string The string to be colorized. * @param mixed $foreground The foreground color(s)/decoration(s) to use. * @param mixed $background The background color(s)/decoration(s) to use. * @param mixed $other Other color(s)/decoration(s) to use. * * @uses CONSOLE_COLORS::$foreground * @uses CONSOLE_COLORS::$background * @uses CONSOLE_COLORS::$other * * @return string The colorized / decorated string, ready for output to console. */ public function colorize(string $string, $foreground = null, $background = null, $other = []): string { if (empty($foreground) and empty($background) and empty($other)) { return $string; } $colored_string = ""; $colored = false; foreach (['foreground', 'background', 'other'] as $type) { if (! is_null($$type)) { if (! is_array($$type)) { $$type = [$$type]; } foreach ($$type as $value_name) { if (isset(CONSOLE_COLORS::${$type}[$value_name])) { $colored_string .= "\033[" . CONSOLE_COLORS::${$type}[$value_name] . "m"; $colored = true; } else { $this->warn("Invalid '$type' color specification - " . $value_name); } } } } $colored_string .= $string; if ($colored) { $colored_string .= "\033[0m"; } return $colored_string; }//end colorize() /** * Output up to 3 columns of text - ie. used by help output * * @param string $col1 Text to output in first column. * @param string $col2 Text to output in second column. * @param string $col3 Text to output in third column. * * @uses Console_Abstract::COL1_WIDTH * @uses Console_Abstract::COL2_WIDTH * * @return void */ public function output3col(string $col1, string $col2 = null, string $col3 = null) { $full_width = $this->getTerminalWidth(); $col1_width = floor(($full_width * static::COL1_WIDTH) / 100); $col2_width = floor(($full_width * static::COL2_WIDTH) / 100); $string = str_pad($col1, $col1_width, " "); if (! is_null($col2)) { $string .= "| " . $col2; } if (! is_null($col3)) { $string = str_pad($string, $col2_width, " ") . "| " . $col3; } $string = str_pad("| $string", $full_width - 1) . "|"; $this->output($string); }//end output3col() /** * Output a line break * * - basically output an empty line, which will automatically include a newline/break * * @return void */ public function br() { $this->output(''); }//end br() /** * Log a line break * * - Same as br() - but only output if $this->verbose is true * * @return void */ public function brl() { if (! $this->verbose) { return; } $this->br; }//end brl() /** * Output a horizonal rule / line - filling the width of the terminal * * @param string $c The character to use to create the line. * @param string $prefix A prefix string to output before the line. * * @return void */ public function hr(string $c = '=', string $prefix = "") { // Maybe adjust width - if stamping lines $adjust = 0; if ($this->stamp_lines) { $stamp = $this->stamp() . ' ... '; $adjust = strlen($stamp); } $string = str_pad($prefix, $this->getTerminalWidth() - $adjust, $c); $this->output($string); }//end hr() /** * Log a horizonal rule / line - filling the width of the terminal * * - Same as hr() - but only output if $this->verbose is true * * @param string $c The character to use to create the line. * @param string $prefix A prefix string to output before the line. * * @return void */ public function hrl(string $c = '=', string $prefix = "") { if (! $this->verbose) { return; } $this->hr($c, $prefix); }//end hrl() /** * Pause during output for debugging/stepthrough * * - Only pauses if $this->step is set to true * - Will pause and wait for user to hit enter * - If user enters 'finish' (case-insensitive) $this->step will be set to false * and the program will finish execution normally * * @param string $message Message to show before pausing. * * @return void */ public function pause(string $message = "[ ENTER TO STEP | 'FINISH' TO CONTINUE ]") { if (! $this->step) { return; } $this->hr(); $this->output($message); $this->hr(); $line = $this->input(); if (strtolower(trim($line)) == 'finish') { $this->step = false; } }//end pause() /** * Sleep for the set time, with countdown * * @param integer $seconds Number of seconds to wait. * @param string $message Formatted string to show with %d for the number of seconds. * * @return void */ public function sleep(int $seconds = 3, string $message = "Continuing in %d...") { $seconds = (int)$seconds; $max_pad = 0; while ($seconds > 0) { $output = sprintf($message, $seconds); $pad = strlen($output); if ($pad < $max_pad) { $output = str_pad($output, $max_pad); } else { $max_pad = $pad; } echo $output; sleep(1); $seconds -= 1; echo "\r"; } echo str_pad("", $max_pad); echo "\n"; }//end sleep() /** * Get selection from list via CLI input * * @param array $list List of items to select from. * @param mixed $message Message to show, prompting input - defaults to false, no message. * @param integer $default Default selection index if no input - defaults to 0 - first item. * @param boolean $q_to_quit Add a 'q' option to the list to quite - defaults to true. * @param array $preselects Pre-selected values - eg. could have been passed in as arguments to CLI. * Passed by reference so they can be passed through a chain of selections and/or narrowed-down lists. * Defaults to empty array - no preselections. * @param mixed $livefilter Whether to filter the list while typing - falls back to configuration if not set. * * @return string The value of the item in the list that was selected. */ public function select(array $list, $message = false, int $default = 0, bool $q_to_quit = true, array &$preselects = [], $livefilter = null): string { // Fall back to configuration if not specified if (is_null($livefilter)) { $livefilter = $this->livefilter; } if ($livefilter !== "disabled" && $livefilter !== false) { return $this->liveSelect($list, $message, $default, $q_to_quit, $preselects, $livefilter); }//end if /* * Otherwise, fall back to normal select, ie. not livefilter */ // Display the list with indexes $list = array_values($list); foreach ($list as $i => $item) { $this->output("$i. $item"); } // Maybe show q - Quit option if ($q_to_quit) { $this->output("q. Quit and exit"); } $max = count($list) - 1; $index = -1; $entry = false; // Continually prompt for input until we get a valid entry while ($index < 0 or $index > $max) { // Warn if input was not in list if ($entry !== false) { $this->warn("Invalid selection $entry"); } if (empty($preselects)) { // Prompt for human input entry $this->output("Enter number or part of selection"); $entry = $this->input($message, $default); } else { // If some pre-selection was passed in, shift it off as the entry $entry = array_shift($preselects); } // Maybe process q - Quit option if ($q_to_quit and (strtolower(trim($entry)) == 'q')) { $this->warn('Selection Exited'); exit; } // For non-numeric entries, find matching item(s) if (! is_numeric($entry)) { $filtered_items = []; // Look for list item containing the entry (case-insensitive) foreach ($list as $item) { if (stripos($item, $entry) !== false) { $filtered_items[] = $item; } } if (count($filtered_items) == 1) { // Single match? Return it return $filtered_items[0]; } elseif (! empty($filtered_items)) { // Multiple matches? New select to narrow down further return $this->select($filtered_items, $message, 0, $q_to_quit, $preselects); } } // Make sure it's really a good entry // Eg. avoid 1.2 => 1 or j => 0 // - which would result in unwanted behavior for bad entries $index = (int) $entry; if ((string) $entry !== (string) $index) { $index = -1; } }//end while return $list[$index]; }//end select() /** * Get selection from list via CLI input - using live filtering UX * * @param array $list List of items to select from. * @param mixed $message Message to show, prompting input - defaults to false, no message. * @param integer $default Default selection index - defaults to 0 - first item. * @param boolean $q_to_quit Add a 'q' option to the list to quite - defaults to true. * @param array $preselects Pre-selected values - eg. could have been passed in as arguments to CLI. * Passed by reference so they can be passed through a chain of selections and/or narrowed-down lists. * Defaults to empty array - no preselections. * @param mixed $livefilter Livefilter behavior. * * @return string The value of the item in the list that was selected. */ public function liveSelect(array $list, $message = false, int $default = 0, bool $q_to_quit = true, array &$preselects = [], $livefilter = "enabled"): string { $preselected = false; $entry = ""; $error = ""; // Get preselected entry if passed if (!empty($preselects)) { $preselected = true; $entry = array_shift($preselects); } if ($message) { if ($message === true) { $message = ""; } if (! is_null($default)) { $message .= " ($default)"; } $message .= ": "; $message = $this->colorize($message, null, null, 'bold'); } // Maximum display height - leave a bit of breathing room at the bottom // 1 space for hr // 1 space for error message // 1 space for prompt itself // 1 space after prompt $list_height = $this->getTerminalHeight() - 4; $list_count = count($list); if ($list_count < $list_height) { $list_height = $list_count; } $show_help = false; $list = array_values($list); $back_is_option = in_array(static::BACK_OPTION, $list, true); while (true) { $output = []; $single_filtered_item = false; $filtered_items = []; foreach ($list as $i => $item) { $item_simple = preg_replace('/[^a-z0-9]+/i', '', $item); $entry_simple = preg_replace('/[^a-z0-9]+/i', '', $entry); if ( $entry === "" || stripos($item, $entry) !== false || stripos($item_simple, $entry_simple) !== false || is_numeric($entry) && stripos($i, $entry) !== false ) { $filtered_items[$i] = $item; } } $filtered_default = ! isset($filtered_items[$default]); if (empty($filtered_items)) { $error .= "[NO MATCHES - press D to delete all input or X to backspace]"; } elseif (count($filtered_items) === 1) { $single_filtered_item = true; } // Auto-enter once filtered down to one option // - if 'autoenter' configured // - or, if this was a preselected entry if ( $single_filtered_item && ( $livefilter === 'autoenter' || $preselected ) ) { break; } // If not a perfect match, no longer consider it preselected // (so after typing more no autoenter unless configured) $preselected = false; // Display help info & prompt $this->clear(); $output[] = "Type [lowercase only] to filter options. Press ? to toggle help."; if ($show_help) { if ($q_to_quit) { $output[] = " - Q ........................ Quit"; } $output[] = " - X or [Backspace] ......... Backspace"; $output[] = " - D ........................ Delete/clear all input"; $output[] = " - G/E/M or [Enter] twice ... Select top/bolded option"; $output[] = " - [Enter] once ............. Continue/select single remaining input"; $output[] = " - ? ........................ Toggle help"; } foreach ($output as $line) { $this->output($line); } $output_lines = count($output); $this->hr(); $output_lines++; if ($message) { $this->output($message); $output_lines++; } // Display the list with indexes, with the top/default highlighted $f = 0; foreach ($filtered_items as $i => $item) { // If there are too many items and we are at height limit // - cut off with room for [more] note if ( $output_lines >= ($list_height - 1) && count($filtered_items) > $list_height ) { $output_lines++; $this->output('... [MORE BELOW IN LIST - TYPE TO FILTER] ...'); break; } $bold = null; $color = $single_filtered_item ? 'green' : null; $hint = ""; $_item_is_default = $filtered_default ? $f === 0 : $i === $default; if ($_item_is_default) { $bold = 'bold'; if (substr($entry, -1) === " ") { $color = 'green'; $hint = $this->colorize(" [Hit Enter Again to Select]", 'blue'); } } $this->output($this->colorize("$i. $item", $color, null, $bold) . $hint); $output_lines++; $color = null; $f++; }//end foreach $this->hr(); // Clear the line for the prompt echo str_pad(" ", $this->getTerminalWidth()); // Set cursor to first column echo chr(27) . "[0G"; // Output the prompt & entry so far $error = $this->colorize($error, 'red'); $ready_for_enter = ""; if ($single_filtered_item) { $ready_for_enter = $this->colorize("Press [Enter] or [Space] to proceed with highlighted item", "blue"); } echo "$error $ready_for_enter\n"; echo "> $entry"; $error = ""; $char = $this->input(false, null, false, 'single', 'single_hide', false); // For some reason, both space & enter come through as a new line if ($char === "\n") { // If there's only one item, OR they hit it twice, treat this as Enter if ($single_filtered_item) { // to return first filtered item break; } if ($single_filtered_item || substr($entry, -1) === " ") { if (! $filtered_default) { return $list[$default]; } if (!empty($filtered_items)) { return array_shift($filtered_items); } } // Otherwise treat it as space $char = " "; } else { $char = trim($char); } // Quit - if it's an option if ($char === 'Q' && $q_to_quit) { $this->warn('Selection Exited'); exit; // Back - if it's an option } elseif ($char === 'B' && $back_is_option) { return static::BACK_OPTION; // Clear all input } elseif ($char === 'D') { $entry = ""; // Backspace one character } elseif (in_array($char, ['X', ""])) { $entry = substr($entry, 0, -1); // Enter/Continue - return current top item } elseif (in_array($char, ['G', "E", "M"])) { // To return first filtered item break; // Toggle help } elseif (in_array($char, ["?"])) { $show_help = ! $show_help; // Invalid keys (any other uppercase letter) } elseif (preg_match('/[A-Z]/', $char)) { $error .= "[INVALID KEY - lowercase only]"; } else { $entry = "$entry$char"; }//end if }//end while $this->clear(); // Return first filtered item foreach ($filtered_items as $s => $selected) { // Return the top item in the filtered list return $selected; } }//end liveSelect() /** * Get a confirmation from the user (yes/no prompt) * * - Gets input from user and returns true if it's 'y' or 'Y' - otherwise, false * * @param mixed $message Message to show before prompting user for input. * @param string $default Default value if nothing entered by user. Defaults to 'y'. * @param boolean $required Whether input is required before continuing. Defaults to false. * @param boolean $single Whether to prompt for a single character from the user - eg. they don't have to hit enter. Defaults to true. * @param boolean $single_hide Whether to hide the user's input when prompting for a single character. Defaults to false. * * @uses Console_Abstract::input() * * @return boolean Whether yes was entered by the user. */ public function confirm($message, string $default = 'y', bool $required = false, bool $single = true, bool $single_hide = false): bool { $yn = $this->input($message, $default, $required, $single, $single_hide); $this->br(); // True if first letter of response is y or Y return strtolower(substr($yn, 0, 1)) == 'y'; }//end confirm() /** * Edit some text in external editor * * @param string $text The starting text to be edited. Defaults to "". * @param string $filename The name of the temporary file to save when editing. If null, filename will be generated with timestamp. * @param boolean $modify Whether to use $this->editor_modify_exec vs. $this->editor_exec. Defaults to false. * * @return string The edited contents of the file. */ public function edit(string $text = "", string $filename = null, bool $modify = false): string { if (is_null($filename)) { $filename = "edit_" . date("YmdHis") . ".txt"; } $filepath = $this->setTempContents($filename, $text); $command = sprintf(($modify ? $this->editor_modify_exec : $this->editor_exec), $filepath); $this->exec($command, true); return $this->getTempContents($filename); }//end edit() /** * Get input from the user via CLI * * @param mixed $message Message to show before prompting user for input. * @param string $default Default value if nothing entered by user. * @param mixed $required Whether input is required before continuing. Defaults to false. * @param mixed $single Whether to prompt for a single character from the user - eg. they don't have to hit enter. Defaults to false. * @param mixed $single_hide Whether to hide the user's input when prompting for a single character. Defaults to false. * @param mixed $trim Whether to trim the user's input before returning. Defaults to true. * * @return string The text input from the user. */ public function input($message = false, string $default = null, $required = false, $single = false, $single_hide = false, $trim = true): string { if ($message) { if ($message === true) { $message = ""; } if (! is_null($default)) { $message .= " ($default)"; } $message .= ": "; } while (true) { if ($message) { $this->output($message, false); } if ($single) { $single_hide = $single_hide ? ' -s' : ''; if ($this->is_windows) { $line = `bash -c "read$single_hide -n1 CHAR && echo \$CHAR"`; } else { $line = `bash -c 'read$single_hide -n1 CHAR && echo \$CHAR'`; } // Single char entry doesn't result in a line break on its own // - unless the character entered was 'enter' if ("\n" !== $line) { $this->br(); } } else { $handle = $this->getCliInputHandle(); $line = fgets($handle); } if ($trim) { $line = trim($line); } // Entered input - return if ($line !== "") { return $line; } // Input not required? Return default if (! $required) { return is_null($default) ? "" : $default; } // otherwise, warn, loop and try again $this->warn("Input required - please try again"); }//end while }//end input() /** * Get a formatted timestamp - to use as logging prefix, for example. * * @return string Current timestamp */ public function stamp(): string { return date('Y-m-d_H.i.s'); }//end stamp() /** * Get the config directory * * - hidden folder (. prefix) * - named based on tool shortname * - in home folder * * @return string Full path to config directory. */ public function getConfigDir(): string { if (is_null($this->config_dir)) { $this->config_dir = $this->getHomeDir() . DS . '.' . static::SHORTNAME; } return $this->config_dir; }//end getConfigDir() /** * Get the main/default config file * * - config.hjson (HJSON - https://hjson.github.io/) * - in config directory * * @uses Console_Abstract::getConfigDir() * * @return string Full path to config file. */ public function getConfigFile(): string { if (is_null($this->config_file)) { $config_dir = $this->getConfigDir(); $this->config_file = $config_dir . DS . 'config.hjson'; } return $this->config_file; }//end getConfigFile() /** * Shorten 1 or more paths, using "~" to indicate user's home directory when present. * * @return mixed Shortened path(s) using "~" if applicable. */ public function shortenPath(mixed $path_argument): mixed { $is_array = is_array($path_argument); $paths = $is_array ? $path_argument : [$path_argument]; $home = $this->getHomeDir(); foreach ($paths as $i => $path) { if (strpos($path, $home) === 0) { $paths[$i] = substr_replace($path, '~', 0, strlen($home)); } } return $is_array ? $paths : $paths[0]; } /** * Interpret 1 or more paths, allowing for "~" to indicate the * current user's home directory * * @return mixed Full paths with interpretation as needed */ public function interpretPath(mixed $path_argument): mixed { $is_array = is_array($path_argument); $paths = $is_array ? $path_argument : [$path_argument]; $home = $this->getHomeDir(); foreach ($paths as $i => $path) { if (substr($path, 0, 1) === '~') { $paths[$i] = $home . substr($path, 1); } } return $is_array ? $paths : $paths[0]; } /** * Get the user's home directory * * - Attempts to handle situation where user is running via sudo * and still get the logged-in user's home directory instead of root * * @return string Full path to home directory. */ public function getHomeDir(): string { if (is_null($this->home_dir)) { $return_error = false; $sudo_user = ""; if ($this->running_as_root) { // Check if run via sudo vs. natively running as root exec('echo "$SUDO_USER"', $output, $return_error); if (! $return_error and ! empty($output)) { $sudo_user = trim(array_pop($output)); } } // Not running as root via sudo if (empty($sudo_user)) { // Windows doesn't have 'HOME' set necessarily if (empty($_SERVER['HOME'])) { $this->home_dir = $_SERVER['HOMEDRIVE'] . $_SERVER['HOMEPATH']; // Simplest and most typical - get home dir from env vars. } else { $this->home_dir = $_SERVER['HOME']; } // Running as root via sudo - get home dir of sudo user (if not root) } else { exec('echo ~' . $sudo_user, $output, $return_error); if (! $return_error and ! empty($output)) { $this->home_dir = trim(array_pop($output)); } } if (empty($this->home_dir)) { $this->error('Something odd about this environment... can\'t figure out your home directory; please submit an issue with details about your environment'); } elseif (! is_dir($this->home_dir)) { $this->error('Something odd about this environment... home directory looks like "' . $this->home_dir . '" but that is not a directory; please submit an issue with details about your environment'); } }//end if return $this->home_dir; }//end getHomeDir() /** * Initialize config file * * - Load config if file already exists * - Create config file if it doesn't yet exist * * @uses Console_Abstract::configure() * * @return boolean Whether the config was successfully saved. */ public function initConfig(): bool { $config_file = $this->getConfigFile(); $this->backup_dir = $this->getConfigDir() . DS . 'backups'; try { // Move old json file to hjson if needed if (! is_file($config_file)) { $old_json_config_file = str_ireplace('.hjson', '.json', $config_file); if (is_file($old_json_config_file)) { if (! rename($old_json_config_file, $config_file)) { $this->warn("Old json config file found, but couldn't rename it.\nTo keep your config settings, move '$old_json_config_file' to '$config_file'.\nIf you continue now, a new config file will be created with default values at '$config_file'.", true); } } } // Loading specific config values from file if (is_file($config_file)) { // $this->log("Loading config file - $config_file"); $json = file_get_contents($config_file); $config = $this->json_decode($json, true); if (empty($config)) { $this->error("Likely syntax error: $config_file"); } foreach ($config as $key => $value) { $this->configure($key, $value); } } /* * Setting config to save, based on current values * - This adds any new config with default values to the config file * - This also enables the initial config file creation with all default values */ $this->config_to_save = []; foreach ($this->getPublicProperties() as $property) { $this->config_to_save[$property] = $this->$property; } ksort($this->config_to_save); $this->config_initialized = true; $this->saveConfig(); } catch (Exception $e) { // Notify user $this->warn('ISSUE WITH CONFIG INIT: ' . $e->getMessage(), true); return false; }//end try return true; }//end initConfig() /** * Save config values to file on demand * * @return boolean Whether the config was successfully saved. */ public function saveConfig(): bool { if (! $this->config_initialized) { $this->warn('Config not initialized, refusing to save', true); return false; } $config_dir = $this->getConfigDir(); $config_file = $this->getConfigFile(); try { if (! is_dir($config_dir)) { // $this->log("Creating directory - $config_dir"); mkdir($config_dir, 0755); } // Update comments in config data $this->config_to_save['__WSC__'] = []; $this->config_to_save['__WSC__']['c'] = []; $this->config_to_save['__WSC__']['o'] = []; $this->config_to_save['__WSC__']['c'][" "] = "\n /**\n * " . $this->version(false) . " configuration\n */\n"; foreach ($this->config_to_save as $key => $value) { // While looping, update paths that we encounter $path_config_options = static::getMergedProperty('PATH_CONFIG_OPTIONS'); if (in_array($key, $path_config_options)) { $this->config_to_save[$key] = $this->shortenPath($value); } if ($key != '__WSC__') { $value = ''; $help = $this->_help_var($key, 'option'); if (!empty($help)) { $help = $this->_help_param($help); $type = $help[1]; $info = $help[0]; $value = " // ($type) $info"; } } $this->config_to_save['__WSC__']['c'][$key] = $value; } // Rewrite config file if (is_file($config_file) || $this->config_ok_to_create) { $json = $this->json_encode($this->config_to_save); $success = file_put_contents($config_file, $json); if (! $success) { $this->error("Failed to write to config file ($config_file) - check permissions"); } } else { $this->log("Config file ($config_file) does not exist and is not being created due to run state", true); } // Fix permissions if needed if ($this->running_as_root and ! $this->logged_in_as_root) { $success = true; $success = ($success and chown($config_dir, $this->logged_in_user)); $success = ($success and chown($config_file, $this->logged_in_user)); if (! $success) { $this->warn("There may have been an issue setting correct permissions on the config directory ($config_dir) or file ($config_file). Review these permissions manually.", true); } } } catch (Exception $e) { // Notify user $this->output('NOTICE: ' . $e->getMessage()); return false; }//end try return true; }//end saveConfig() /** * Takes an argument which may have come from the shell and prepares it for use * * - Trims strings * - Optionally parses into a specified type, ie. array or boolean * * @param mixed $value The value to prepare. * @param mixed $default The default to return if $value is empty. * If this is an array, then force_type is auto-set to 'array'. * @param string $force_type Optional type to parse from value. * - 'array': split on commas and/or wrap to force value to be an array. * - 'boolean': parse value as boolean (ie. 1/true/yes => true, otherwise false). * - Note: defaults to 'array' if $default is an array. * @param boolean $trim Whether to trim whitespace from the value(s). Defaults to true. * * @return mixed The prepared result. */ public function prepArg($value, $default, string $force_type = null, bool $trim = true) { $a = func_num_args(); if ($a < 2) { $this->error('prepArg requires value & default'); } if (is_null($force_type)) { if (is_array($default)) { $force_type = 'array'; } } if ($force_type == 'bool') { $force_type = 'boolean'; } // For backwards compatibility if ($force_type === true) { $force_type = 'array'; } // Default? if (empty($value)) { $value = $default; } // Change to array if needed if (is_string($value) and $force_type == 'array') { $value = explode(",", $value); } // Trim if ($trim) { if (is_string($value)) { $value = trim($value); } elseif (is_array($value)) { if (isset($value[0]) and is_string($value[0])) { $value = array_map('trim', $value); } } } if ($force_type == 'boolean') { $value = in_array($value, [true, 'true', 'yes', '1', 1]); } return $value; }//end prepArg() /** * Open a URL in the browser * * @param string $url The URL to open. * * @uses Console_Abstract::browser_exec * @uses Console_Abstract::exec() * * @return void */ public function openInBrowser(string $url) { $command = sprintf($this->browser_exec, $url); $this->exec($command, true); }//end openInBrowser() /** * Configure a property (if public and therefore configurable - otherwise gives a notice) * * - First ensures the passed key is a public property * - If public, sets the property to the passed value * and adds the data to $this->config_to_save * - If not public, shows a notice * * @param string $key The property to set. Can be passed in either snake_case or kebab-case. * @param mixed $value The value to set the propery. * @param boolean $save_value Whether to save the value - ie. add it to config_to_save. * * @return void */ public function configure(string $key, $value, bool $save_value = false) { $key = str_replace('-', '_', $key); if (substr($key, 0, 3) == 'no_' and $value === true) { $key = substr($key, 3); $value = false; } $public_properties = $this->getPublicProperties(); if (in_array($key, $public_properties)) { $path_config_options = static::getMergedProperty('PATH_CONFIG_OPTIONS'); if (in_array($key, $path_config_options)) { $value = $this->interpretPath($value); } $this->{$key} = $value; if ($save_value) { $this->config_to_save[$key] = $value; } } else { $this->output("NOTICE: invalid config key - $key"); } }//end configure() /** * Get an initialied curl handle for a given URL, with our preferred defaults. * * @param string $url The URL to hit. * @param boolean $fresh_no_cache Whether to force this to be a fresh request / disable caching. * * @uses Console_Abstract::ssl_check to determine if curl should verify peer/host SSLs. Warns if not verifying. * * @return CurlHandle The initialized curl handle (at least in later PHP versions). */ public function getCurl(string $url, bool $fresh_no_cache = false) { if (! $this->ssl_check) { $this->warn("Initializing unsafe connection to $url (no SSL check, as configured)", true); } $curl = curl_init(); curl_setopt_array($curl, [ CURLOPT_URL => $url, CURLOPT_HEADER => false, CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 0, CURLOPT_TIMEOUT => 180, CURLOPT_FOLLOWLOCATION => true, CURLOPT_SSL_VERIFYHOST => ($this->ssl_check ? 2 : 0), CURLOPT_SSL_VERIFYPEER => ($this->ssl_check ? 2 : 0), ]); if ($fresh_no_cache) { curl_setopt($curl, CURLOPT_FRESH_CONNECT, true); } return $curl; }//end getCurl() /** * Execute a curl request, do some basic error processing, and return the response. * * @param CurlHandle $curl The curl handle to execute. Type behaves differently in 7.4. * * @return string The response from the curl request. */ public function execCurl($curl): string { $response = curl_exec($curl); if (empty($response)) { $number = curl_errno($curl); // Was there an error? if ($number) { $message = curl_error($curl); if (stripos($message, 'ssl') !== false) { $message .= "\n\nFor some SSL issues, try downloading the latest CA bundle and pointing your PHP.ini to that (https://curl.haxx.se/docs/caextract.html)"; $message .= "\n\nAlthough risky and not recommended, you can also consider re-running your command with the --no-ssl-check flag"; } $this->warn($message, true); } } return $response; }//end execCurl() /** * Update query arguments on a curl handle - soft merge (default) or overwrite existing query arguments. * * @param CurlHandle $curl The curl handle to be updated. * @param array $args The new query arguments to set. * @param boolean $overwrite Whether to overwrite existing query args. Defaults to false. * * @return CurlHandle The upated curl handle. */ public function updateCurlArgs(CurlHandle $curl, array $args, bool $overwrite = false): CurlHandle { // Get info from previous curl $curl_info = curl_getinfo($curl); // Parse out URL and query params $url = $curl_info['url']; $url_parsed = parse_url($url); if (empty($url_parsed['query'])) { $query = []; } else { parse_str($url_parsed['query'], $query); } // Set new args foreach ($args as $key => $value) { if (! isset($query[$key]) or $overwrite) { $query[$key] = $value; } } // Build new URL $new_url = $url_parsed['scheme'] . "://" . $url_parsed['host'] . $url_parsed['path'] . "?" . http_build_query($query); $this->log($new_url); curl_setopt($curl, CURLOPT_URL, $new_url); return $curl; }//end updateCurlArgs() /** * Get the contents of a specified cache file, if it has not expired. * * @param mixed $subpath The path to the cache file within the tool's cache folder. * @param integer $expiration The expiration lifetime of the cache file in seconds. * * @return mixed The contents of the cache file, or false if file expired, does not exist, or can't be read. */ public function getCacheContents($subpath, int $expiration = null) { $expiration = $expiration ?? $this->cache_lifetime; $config_dir = $this->getConfigDir(); $cache_dir = $config_dir . DS . 'cache'; $subpath = is_array($subpath) ? implode(DS, $subpath) : $subpath; $cache_file = $cache_dir . DS . $subpath; $contents = false; if (is_file($cache_file)) { $this->log("Cache file exists ($cache_file) - checking age"); $cache_modified = filemtime($cache_file); $now = time(); $cache_age = $now - $cache_modified; if ($cache_age < $expiration) { $this->log("New enough - reading from cache file ($cache_file)"); $contents = file_get_contents($cache_file); if ($contents === false) { $this->warn("Failed to read cache file ($cache_file) - possible permissions issue", true); } } } return $contents; }//end getCacheContents() /** * Set the contents of a specified cache file. * * @param mixed $subpath The path to the cache file within the tool's cache folder. * @param string $contents The contents to write to the cache file. * * @return mixed The path to the new cache file, or false if failed to write. */ public function setCacheContents($subpath, string $contents) { $config_dir = $this->getConfigDir(); $cache_dir = $config_dir . DS . 'cache'; $subpath = is_array($subpath) ? implode(DS, $subpath) : $subpath; $cache_file = $cache_dir . DS . $subpath; $cache_dir = dirname($cache_file); if (! is_dir($cache_dir)) { $success = mkdir($cache_dir, 0755, true); if (! $success) { $this->error("Unable to create new directory - $cache_dir"); } } $written = file_put_contents($cache_file, $contents); if ($written === false) { $this->warn("Failed to write to cache file ($cache_file) - possible permissions issue", true); return false; } return $cache_file; }//end setCacheContents() /** * Get the contents of a specified temp file * * @param mixed $subpath The path to the temp file within the tool's temp folder. * * @return mixed The contents of the temp file, or false if file does not exist or can't be read. */ public function getTempContents($subpath) { $config_dir = $this->getConfigDir(); $temp_dir = $config_dir . DS . 'temp'; $subpath = is_array($subpath) ? implode(DS, $subpath) : $subpath; $temp_file = $temp_dir . DS . $subpath; $contents = false; if (is_file($temp_file)) { $this->log("Temp file exists ($temp_file) - reading from temp file"); $contents = file_get_contents($temp_file); if ($contents === false) { $this->warn("Failed to read temp file ($temp_file) - possible permissions issue", true); } } return $contents; }//end getTempContents() /** * Set the contents of a specified temp file. * * @param mixed $subpath The path to the temp file within the tool's temp folder. * @param string $contents The contents to write to the temp file. * * @return mixed The path to the new temp file, or false if failed to write. */ public function setTempContents($subpath, string $contents) { $config_dir = $this->getConfigDir(); $temp_dir = $config_dir . DS . 'temp'; $subpath = is_array($subpath) ? implode(DS, $subpath) : $subpath; $temp_file = $temp_dir . DS . $subpath; $temp_dir = dirname($temp_file); if (! is_dir($temp_dir)) { mkdir($temp_dir, 0755, true); } $written = file_put_contents($temp_file, $contents); if ($written === false) { $this->warn("Failed to write to temp file ($temp_file) - possible permissions issue", true); return false; } return $temp_file; }//end setTempContents() /** * Paginate some content for display on terminal * * @param mixed $content Content to be displayed - string or array of lines. * @param array $options Options for pagination - array with kesy: * - 'starting_line' Starting line number for pagination / vertical scrolling. * - 'starting_column' Current starting column (for scrolling if wrap is off). NOT YET IMPLEMENTED. * - 'wrap' Whether to wrap lines that are too long for screen width. * - 'line_buffer' Number of lines to allow space for outside of output. * - 'output' Whether to output directly to screen. * - 'include_page_info' Whether to include pagination info in output. * - 'fill_height' Whether to fill the entire screen height - buffer with empty lines. * * @return array Details for pagination - array with keys: * - 'output' The output to display on the screen currently. * - 'starting_line' The number of the current starting line. * - 'page_length' The length of the output being displayed. * - 'ending_line' THe number of the last line being displayed. */ public function paginate($content, array $options = []): array { $options = array_merge([ 'starting_line' => 1, 'starting_column' => 1, 'wrap' => false, 'line_buffer' => 1, 'output' => true, 'include_page_info' => true, 'fill_height' => true, ], $options); // Split into lines if needed if (is_string($content)) { $content = preg_split("/\r\n|\n|\r/", $content); } $max_width = $this->getTerminalWidth(); $max_height = $this->getTerminalHeight(); $max_height = $max_height - $options['line_buffer']; // for start/end line breaks $max_height = $max_height - 2; if ($options['include_page_info']) { // for page info and extra line break $max_height = $max_height - 2; } if (! is_array($content)) { $content = explode("\n"); } $content = array_values($content); // Pre-wrap lines if specified, to make sure pagination works based on real number of lines if ($options['wrap']) { $wrapped_content = []; foreach ($content as $line) { $line_length = strlen($line); while ($line_length > $max_width) { $wrapped_content[] = substr($line, 0, $max_width); $line = substr($line, $max_width); $line_length = strlen($line); } $wrapped_content[] = $line; } $content = $wrapped_content; } $content_length = count($content); $are_prev = false; if ($options['starting_line'] > 1) { $are_prev = true; } $are_next = false; if (($options['starting_line'] + $max_height - 1) < $content_length) { $are_next = true; } $height = 0; $output = []; // Starting line break if ($are_prev) { $output[] = $this->colorize(str_pad("==[MORE ABOVE]", $max_width, "="), 'green', null, 'bold'); } else { $output[] = str_pad("", $max_width, "="); } $l = $options['starting_line'] - 1; $final_line = $options['starting_line']; while ($height < $max_height) { if ($l < ($content_length)) { $final_line = $l + 1; $line = $content[$l]; } else { if ($options['fill_height']) { $line = ""; } else { break; } } if (! is_string($line)) { $this->error("Bad type for line $l of content - string expected"); } if (strlen($line) > $max_width) { if ($options['wrap']) { $this->error("Something wrong with the following line - wrap was on, but it's still too long", false); $this->output($line); $this->error("Aborting"); } $line = substr($line, 0, $max_width); } $output[] = $line; $l++; $height++; }//end while // Ending line break if ($are_next) { $output[] = $this->colorize(str_pad("==[MORE BELOW]", $max_width, "="), 'green', null, 'bold'); } else { $output[] = str_pad("", $max_width, "="); } if ($options['include_page_info']) { $output[] = str_pad($options['starting_line'] . " - " . $final_line . " of " . $content_length . " items", $max_width, " "); $output[] = str_pad("", $max_width, "="); } $output = implode("\n", $output); if ($options['output']) { echo $output; } return [ 'output' => $output, 'starting_line' => $options['starting_line'], 'page_length' => $max_height, 'ending_line' => min(($options['starting_line'] + $max_height) - 1, $content_length), ]; }//end paginate() /** * Get parameters for a given method - for help display * * @param string $method Method for which to get parameters. * * @return array The parameter names. */ protected function _getMethodParams(string $method): array { $r = new ReflectionObject($this); $rm = $r->getMethod($method); $params = []; foreach ($rm->getParameters() as $param) { $params[] = $param->name; } return $params; }//end _getMethodParams() /** * Cached value for getPublicProperties() * * @var array */ protected $_public_properties = null; /** * Get all public properties for the tool. These are the properties that can * be set via flags and configuration file. * * @return array List of all public property names. */ public function getPublicProperties(): array { if (is_null($this->_public_properties)) { $this->_public_properties = []; $reflection = new ReflectionObject($this); foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $prop) { $this->_public_properties[] = $prop->getName(); } sort($this->_public_properties); } return $this->_public_properties; }//end getPublicProperties() /** * Cached value for getCliInputHandle() * * @var resource */ protected $_cli_input_handle = null; /** * Get a CLI Input handle resource - to read input from user * * @return resource CLI Input handle */ protected function getCliInputHandle() { if (is_null($this->_cli_input_handle)) { $this->_cli_input_handle = fopen("php://stdin", "r"); } return $this->_cli_input_handle; }//end getCliInputHandle() /** * Close the CLI Input handle if it has been opened * * @used-by Console_Abstract::_shutdown() * * @return void */ protected function closeCliInputHandle() { if (! is_null($this->_cli_input_handle)) { fclose($this->_cli_input_handle); } }//end closeCliInputHandle() /** * Cached value for getTerminalHeight() * * @var integer */ protected $_terminal_height = null; /** * Get the current height of the terminal screen for output * * @param mixed $fresh Whether to get height fresh (vs. reading cached value). Defaults to false. * * @return integer The height of the terminal output screen. */ public function getTerminalHeight($fresh = false): int { if ($fresh or empty($this->_terminal_height)) { exec("tput lines", $output, $return); if ( $return or empty($output) or empty($output[0]) or ! is_numeric($output[0]) ) { $this->_terminal_height = static::DEFAULT_HEIGHT; } else { $this->_terminal_height = (int)$output[0]; } } return $this->_terminal_height; }//end getTerminalHeight() /** * Cached value for getTerminalWidth() * * @var integer */ protected $_terminal_width = null; /** * Get the current width of the terminal screen for output * * @param mixed $fresh Whether to get width fresh (vs. reading cached value). Defaults to false. * * @return integer The width of the terminal output screen. */ public function getTerminalWidth($fresh = false): int { if ($fresh or empty($this->_terminal_width)) { exec("tput cols", $output, $return); if ( $return or empty($output) or empty($output[0]) or ! is_numeric($output[0]) ) { $this->_terminal_width = static::DEFAULT_WIDTH; } else { $this->_terminal_width = (int)$output[0]; } } return $this->_terminal_width; }//end getTerminalWidth() /** * Parse HTML for output to terminal * * Supports: * - Bold * - Italic (showing as dim) * - Links (Underlined with link in parentheses) * - Unordered Lists ( - ) * - Ordered Lists ( 1. ) * - Hierarchical lists (via indentation) * * Not Yet Supported: * - Text colors * - Underline styles * - Indentation styles * - Less commonly supported terminal styles * * Runs recursively to parse out all elements. * * @param mixed $dom HTML string or DOM object to be parsed. * @param integer $depth The current depth of parsing - for hierarchical elements - eg. lists. * @param string $prefix The current prefix to use - eg. for lists. * * @return string The processed output, ready for terminal. */ public function parseHtmlForTerminal($dom, int $depth = 0, string $prefix = ""): string { $output = ""; if (is_string($dom)) { $dom = trim($dom); if (empty($dom)) { return $dom; } $tmp = new DOMDocument(); if (! @$tmp->loadHTML(mb_convert_encoding($dom, 'HTML-ENTITIES', 'UTF-8'))) { return $dom; } $dom = $tmp; } if (! is_object($dom) or ! in_array(get_class($dom), ["DOMDocumentType", "DOMDocument", "DOMElement"])) { $type = is_object($dom) ? get_class($dom) : gettype($dom); $this->error("Invalid type passed to parseHtmlForTerminal - $type"); } $li_index = 0; foreach ($dom->childNodes as $child_index => $node) { // output for this child $_output = ""; // prefix for this child's children - start with current prefix, passed to function $_prefix = $prefix; // suffix for end of this child $_suffix = ""; // Note coloring if needed $color_foreground = null; $color_background = null; $color_other = null; switch ($node->nodeName) { case 'a': $color_other = 'underline'; $href = trim($node->getAttribute('href')); $content = trim($node->nodeValue); if (strtolower(trim($href, "/")) != strtolower(trim($content, "/"))) { $_suffix = " [$href]"; } break; case 'br': case 'p': $_output .= "\n" . $_prefix; break; case 'b': case 'strong': $color_other = 'bold'; break; case 'em': $color_other = 'dim'; break; case 'ol': case 'ul': $_output .= "\n"; break; case 'li': // Output number for ol child, otherwise, default to "-" $list_char = " - "; if ($dom->nodeName == "ol") { $list_char = " " . ($li_index + 1) . ". "; } $_output .= $_prefix . $list_char; // Update prefix for child elements $_prefix = $_prefix . str_pad("", strlen($list_char)); $_suffix = "\n"; $li_index++; break; case '#text': $_output .= $node->nodeValue; break; default: break; }//end switch if ($node->hasChildNodes()) { $_output .= $this->parseHtmlForTerminal($node, $depth + 1, $_prefix); } // Decorate the output as needed $_output = $this->colorize($_output, $color_foreground, $color_background, $color_other); $output .= $_output . $_suffix; }//end foreach // $output = str_replace("\u{00a0}", " ", $output); $output = str_replace("\r", "\n", $output); $output = preg_replace('/\n(\s*\n){2,}/', "\n\n", $output); return htmlspecialchars_decode($output); }//end parseHtmlForTerminal() /** * Parse Markdown to HTML * * - uses Parsedown * - uses some defaults based on common use * - alternatively, can call Parsedown directly - ie. if other options are needed * * @param string $text The markdown to be parsed. * * @return string The resulting HTML. */ public function parseMarkdownToHtml(string $text): string { $html = Parsedown::instance() ->setBreaksEnabled(true) ->setMarkupEscaped(true) ->setUrlsLinked(false) ->text($text); return $html; }//end parseMarkdownToHtml() /** * Decode JSON - supports HJSON as well * * @param string $json The raw JSON/HJSON string to be interpreted. * @param mixed $options Options to pass through to HJSON. Also supports 'true' for associative array, to match json_decode builtin. * * @throws Exception If there is an error parsing the JSON. * * @return mixed The decoded data - typically object or array. */ public function json_decode(string $json, $options = []) { $this->log("Running json_decode on console_abstract"); // mimic json_decode behavior if ($options === true) { $options = ['assoc' => true]; } // default to preserve comments and whitespace if (! isset($options['keepWsc'])) { $options['keepWsc'] = true; } $parser = new HJSONParser(); try { $data = $parser->parse($json, $options); } catch (Exception $e) { throw new Exception("While parsing JSON:\n" . $e->getMessage()); } $this->_json_cleanup($data); return $data; }//end json_decode() /** * Encode data as HJSON * * @param mixed $data The data to be encoded. * @param array $options Options to pass through to HJSON. * * @return string The encoded HJSON string. */ public function json_encode($data, array $options = []): string { $this->log("Running json_encode on console_abstract"); $options = array_merge([ 'keepWsc' => true, 'bracesSameLine' => true, 'quotes' => 'always', 'space' => 4, 'eol' => PHP_EOL, ], $options); // default to preserve comments and whitespace if (! isset($options['keepWsc'])) { $options['keepWsc'] = true; } if (empty($options['keepWsc'])) { unset($data['__WSC__']); } else { if (! empty($data['__WSC__'])) { $data['__WSC__'] = (object)$data['__WSC__']; if (! empty($data['__WSC__']->c)) { $data['__WSC__']->c = (object)$data['__WSC__']->c; } } } $this->_json_cleanup($data); $stringifier = new HJSONStringifier(); $json = $stringifier->stringify($data, $options); return $json; }//end json_encode() /** * Clean up data after decoding from, or before encoding as HJSON * * @param mixed $data The data to clean up - passed by reference. * * @return void Updates $data directly by reference. */ protected function _json_cleanup(&$data) { if (is_iterable($data)) { foreach ($data as $key => &$value) { if (is_object($value)) { unset($value->__WSC__); } if (is_array($value)) { unset($value['__WSC__']); } $this->_json_cleanup($value); } } }//end _json_cleanup() /** * Explicitly throws an error for methods called that don't exist. * * This is to prevent an infinite loop, since some classes have * a magic __call method that tries methods on Console_Abstract * * @param string $method The method that is being called. * @param array $arguments The arguments being passed to the method. * * @throws Exception Errors every time to prevent infinite loop. * * @return mixed Doesn't really return anything - always throws error. */ public function __call(string $method, array $arguments = []) { throw new Exception("Invalid method '$method'"); return false; }//end __call() /** * Startup logic - extend from child/tool if needed. * * @param array $arg_list The arguments passed to the tool. * * @return void */ protected function _startup(array $arg_list) { // Nothing to do by default }//end _startup() /** * Shutdown logic - extend from child/tool if needed. * * @param array $arg_list The arguments passed to the tool. * * @return void */ protected function _shutdown(array $arg_list) { $this->closeCliInputHandle(); }//end _shutdown() }//end class }//end if // For working unpackaged if (! empty($src_includes) and is_array($src_includes)) { foreach ($src_includes as $src_include) { require $src_include; } } // Note: leave the end tag for packaging ?> outputProgress(0, $number_of_todos, "todos"); for ($i=1; $i<=$number_of_todos; $i++) { $created_at = date("Y-m-d H:i:s"); $this->post('list/'.$list_id.'/task', [ 'name' => "Test Task $i - $created_at", 'content' => "Test Task Content for task $i created at $created_at", ], false); $this->outputProgress($i, $number_of_todos, "todos created"); // For rate limit of 100 requests per minute // => 1 request every 0.6 seconds // => sleep 0.6 seconds = 600000 microseconds between each request usleep(600000); } $this->output("Done!"); } protected $___get = [ "GET data from the ClickUp API. Refer to https://clickup.com/api/", ["Endpoint slug, eg. 'projects'", "string"], ["Fields to output in results - comma separated, false to output nothing, * to show all", "string"], ["Whether to return headers", "boolean"], ["Whether to output progress", "boolean"], ]; public function get($endpoint, $output=true, $return_headers=false, $output_progress=false) { // Clean up endpoint $endpoint = trim($endpoint, " \t\n\r\0\x0B/"); // Check for valid cached result if cache is enabled $body = ""; if ($this->api_cache and !$return_headers) { $this->log("Cache is enabled - checking..."); $body = $this->getAPICacheContents($endpoint); if (!empty($body)) { $body_decoded = json_decode($body); if (empty($body_decoded) and !is_array($body_decoded)) { $this->warn("Invalid cached data - will try a fresh call", true); $body=""; } else { $body = $body_decoded; } } } else { $this->log("Cache is disabled"); } if (empty($body) and !is_array($body)) { $this->log("Absent cache data, running fresh API request"); // Get API curl object for endpoint $ch = $this->getAPICurl($endpoint, $output_progress); // Execute and check results list($body, $headers) = $this->runAPICurl($ch, null, [], $output_progress); // Cache results $body_json = json_encode($body, JSON_PRETTY_PRINT); $this->setAPICacheContents($endpoint, $body_json); } if ($output) { if (empty($body)) { $this->output('No data in response.'); } else { $this->output($body); } } if ($return_headers) { return [$body, $headers]; } return $body; } protected $___post = [ "POST data to the ClickUp API. Refer to https://clickup.com/api/", ["Endpoint slug, eg. 'projects'", "string"], ["JSON (or HJSON) body to send", "string"], ["Fields to output in results - comma separated, false to output nothing, * to show all", "string"], ["Whether to return headers", "boolean"], ["Whether to output progress", "boolean"], ]; public function post($endpoint, $body_json=null, $output=true, $return_headers=false, $output_progress=false) { return $this->_sendData('POST', $endpoint, $body_json, $output, $return_headers, $output_progress); } /** * Send data to API via specified method */ protected function _sendData($method, $endpoint, $body_json=null, $output=true, $return_headers=false, $output_progress=false) { // Clean up endpoint $endpoint = trim($endpoint, " \t\n\r\0\x0B/"); // Check JSON if (is_null($body_json)) { $this->error("JSON body to send is required"); } if (is_string($body_json)) { // Allow Human JSON to be passed in - more forgiving $body = $this->json_decode($body_json, ['keepWsc'=>false]); if (empty($body)) { $this->error("Invalid JSON body - likely syntax error. Make sure to use \"s and escape them as needed."); } } else { $body = $body_json; } // Wrap in data key if needed if (!isset($body)) { $data = $body; $body = new StdClass(); $body = $data; } $body_json = json_encode($body); // Get API curl object for endpoint $ch = $this->getAPICurl($endpoint, $output_progress); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_POSTFIELDS => $body_json, ]); // Execute and check results list($body, $headers) = $this->runAPICurl($ch, null, [], $output_progress); if ($output) { if (empty($body)) { $this->output('No data in response.'); } else { $this->output($body); } } if ($return_headers) { return [$body, $headers]; } return $body; } /** * Prep Curl object to hit ClickUp API * - endpoint should be api endpoint to hit */ protected function getAPICurl($endpoint, $output_progress=false) { $this->setupAPI(); $url = self::API_URL . '/' . $endpoint; if ($output_progress) { $this->output("Running API request to **".$url."**"); } $ch = $this->getCurl($url); curl_setopt_array($ch, [ CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 1800, CURLOPT_HTTPHEADER => array( 'Accept: application/json', 'Content-Type: application/json', 'Authorization: ' . $this->api_key, ), ]); return $ch; } /** * Get link for a single API result object */ public function getResultLink($item, $type='') { $app_url = self::APP_URL; $item_id = null; if (is_object($item)) { if (empty($type)) { $type = empty($item->resource_type) ? "" : $item->resource_type; } $item_id = $item->gid; } else { $item_id = $item; } if ($type=='project') { return $app_url . "/0/" . $item_id; } if ($type=='task' && isset($item->permalink_url)) { return $item->permalink_url; } return "NO LINK"; } /** * Get results from pre-prepared curl object * - Handle errors * - Parse results */ protected function runAPICurl($ch, $close=true, $recurrance=[], $output_progress=false) { if (!is_array($recurrance)) $recurrance=[]; $recurrance = array_merge([ 'recurring' => false, 'complete' => 0, 'total' => 1, ], $recurrance); // Prep to receive headers $headers = []; curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use (&$headers) { $len = strlen($header); $header = explode(':', $header, 2); if (count($header) < 2) { return $len; } $headers[strtolower(trim($header[0]))][] = trim($header[1]); return $len; } ); if ($output_progress and !$recurrance['recurring']) $this->outputProgress($recurrance['complete'], $recurrance['total'], "initial request"); // Execute $body = $this->execCurl($ch); // Get response code $response_code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); // Make sure valid response if ( empty($body) ) { $this->error("Request Error: " . curl_error($ch), false); $this->warn("Request may have failed", true); } if ( $response_code < 200 or $response_code > 299 ) { $this->error("Response: $response_code", false); $this->error($body, false); $this->warn("Request may have failed", true); } // Process response $body_decoded = json_decode($body); if (empty($body_decoded) and !is_array($body_decoded)) { $this->error("Invalid response", false); $this->error($response_code, false); $this->error($body, false); $this->warn("Request may have failed", true); } $body = $body_decoded; if ($close) { curl_close($ch); } return [$body, $headers]; } /** * Set up ClickUp API data * - prompt for any missing data and save to config */ protected function setupAPI() { $api_key = $this->api_key; if (empty($api_key)) { $api_key = $this->input("Enter ClickUp API Token (from https://app.clickup.com/settings/apps)", null, true); $api_key = trim($api_key); $this->configure('api_key', $api_key, true); } $this->saveConfig(); } /** * Get API cache contents */ protected function getAPICacheContents($endpoint) { return $this->getCacheContents( $this->getAPICachePath($endpoint), $this->api_cache_lifetime ); } /** * Set API cache contents */ protected function setAPICacheContents($endpoint, $contents) { return $this->setCacheContents( $this->getAPICachePath($endpoint), $contents ); } /** * Get cache path for a given endpont */ protected function getAPICachePath($endpoint) { $cache_path = ['clickup-api']; $url_slug = preg_replace("/[^0-9a-z_]+/", "-", self::API_URL); $cache_path[]= $url_slug; $endpoint_array = explode("/", $endpoint . ".json"); $cache_path = array_merge($cache_path, $endpoint_array); return $cache_path; } }//end class if (empty($__no_direct_run__)) { // Kick it all off Pcucli::run($argv); } // Note: leave the end tag for packaging ?>