From 5563ce3b1ea677995b560e9e24df102d4f5eec64 Mon Sep 17 00:00:00 2001 From: Gudule Lapointe Date: Sat, 15 Mar 2025 12:46:33 +0100 Subject: [PATCH] merge templates/ and classes/ from 3.x --- classes/class-exception.php | 60 ++++ classes/class-form.php | 486 ++++++++++++++++++++++++++ classes/class-grid.php | 290 +++++++++++++++ classes/class-ini.php | 94 +++++ classes/class-locale.php | 97 +++++ classes/class-page.php | 51 +++ classes/init.php | 680 ++++++++++++++++++++++++++++++++++++ css/bootstrap.css | 0 css/form.css | 5 + js/form.js | 0 templates/bootstrap.css | 41 +++ templates/bootstrap.js | 17 + templates/template-page.php | 74 ++++ templates/templates.php | 130 +++++++ 14 files changed, 2025 insertions(+) create mode 100644 classes/class-exception.php create mode 100644 classes/class-form.php create mode 100644 classes/class-grid.php create mode 100644 classes/class-ini.php create mode 100644 classes/class-locale.php create mode 100644 classes/class-page.php create mode 100644 classes/init.php create mode 100644 css/bootstrap.css create mode 100644 css/form.css create mode 100644 js/form.js create mode 100644 templates/bootstrap.css create mode 100644 templates/bootstrap.js create mode 100644 templates/template-page.php create mode 100644 templates/templates.php diff --git a/classes/class-exception.php b/classes/class-exception.php new file mode 100644 index 0000000..195adb1 --- /dev/null +++ b/classes/class-exception.php @@ -0,0 +1,60 @@ +__toString() ); + } + + // Disabled custom string representation of the exception, it is worst than the default one. + // public function __toString() { + // $prefix = ''; + // // return __CLASS__ . ": [{$this->code}]: {$this->message}\n"; + // $message = strip_tags( $this->message ); + // $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); + // if( ! empty( $trace[1] ) ) { + // $class = $trace[1]['class'] ?? ''; + // $function = $trace[1]['function'] ?? ''; + // } + // if( ! empty( trim ( $class . $function ) ) ) { + // $prefix .= '(' . ( empty( $class ) ? '' : $class . '::' ) . $function . ') '; + // } + // return $prefix . $message; + // } +} + +/** + * This is temporary, it's misleading to replace Errors with Exceptions,but it's a way + * to make sure I can replace all new Error() calls with new OpenSim_Exception() calls. + * + * I pledge to check soon, but I spend too much time finetuning the Error catchers, so I + * want to keep a way to switch back fast if needed. + * + * TODO: + * - Test again every use case where OpenSim_Error is used, make sure interrupts happen as expected. + * - Replace all OpenSim_Error calls with OpenSim_Exception. + * - Remove this class. + */ +class OpenSim_Error extends OpenSim_Exception { + + public function __construct( $message, $code = 0, Exception $previous = null ) { + parent::__construct( $message, $code, $previous ); + // error_log( $this->__toString() ); + } +} diff --git a/classes/class-form.php b/classes/class-form.php new file mode 100644 index 0000000..e8a0d6a --- /dev/null +++ b/classes/class-form.php @@ -0,0 +1,486 @@ + unique id + * 'html' => html code + * 'callback' => callback to call to process form + * ) + * - render_form() return the form html code + * - get_values() return an array of field_id => value pairs + * - get_fields() return the array of defined fields + * - process() process the form callback + * + * @package magicoli/opensim-helpers +**/ + +require_once( dirname(__DIR__) . '/classes/init.php' ); + +class OpenSim_Form { + private $form_id; + private $fields = array(); + private $callback; + private static $forms = array(); + private $errors; + private $html; + private $completed; + private $multistep; + public $tasks; + + public function __construct($args = array(), $step = 0) { + if( is_string( $args )) { + // If only a string is passed, consider it as form_id for a pending form + $args = array( 'form_id' => $args ); + } + if (!is_array($args)) { + error_log(__METHOD__ . ' invalid argument type ' . gettype($args)); + throw new InvalidArgumentException('Invalid argument type: ' . gettype($args)); + } + + $args = OpenSim::parse_args($args, array( + 'form_id' => uniqid('form-', true), + 'fields' => array(), + 'callback' => null, + 'multistep' => false, + )); + $this->form_id = $args['form_id']; + $this->multistep = $args['multistep']; + $this->steps = $args['steps'] ?? false; + $this->callback = $args['callback']; + $this->add_fields($args['fields']); + + $this->completed = $_SESSION[$this->form_id]['completed'] ?? $this->completed; + self::$forms[$this->form_id] = $this; + // $this->refresh_steps(); + } + + /** + * Static factory method to register an instance of OpenSim_Form. + * Handles exceptions internally to avoid requiring try-catch blocks during instantiation. + * + * @param array $args Arguments for form initialization. + * @param int $step Optional step parameter. + * @return OpenSim_Form|false Returns an instance of OpenSim_Form on success, or false on failure. + */ + public static function register($args = array(), $step = 0) { + try { + return new self($args, $step); + } catch (InvalidArgumentException $e) { + error_log($e->getMessage()); + OpenSim::notify_error($e->getMessage() ); + return false; + } + } + + public function add_steps($steps) { + if( empty( $steps )) { + return false; + } + $this->steps = OpenSim::parse_args( $steps, $this->steps ); + } + + public function add_fields( $fields) { + if( empty( $fields )) { + return; + } + $this->fields = OpenSim::parse_args( $fields, $this->fields ); + $this->get_next_step(); + } + + public function task_error( $field_id, $message, $type = 'warning' ) { + $this->errors[$field_id] = array( + 'message' => $message ?? 'Error', + 'type' => empty( $type ) ? 'warning' : $type, + ); + } + + public function render_form() { + if( ! empty( $this->html )) { + return $this->html; + } + if( $this->multistep ) { + $this->refresh_steps(); + $fields = $this->get_step_fields() ?? array(); + if( ! is_array( $fields )) { + error_log( __METHOD__ . ' wrong field format ' ); + return false; + } + } else { + $fields = $this->fields; + } + + $form_id = $this->form_id; + + // Update Reset button to remain a submit button and bypass validation + $reset_button = ( empty($_SESSION[$form_id])) ? '' : sprintf( + '', + _( 'Reset Form' ) + ); + + if( empty( $fields ) && empty( $reset_button ) ) { + error_log( __METHOD__ . ' called with empty fields' ); + return false; + } + + $html = ''; + $fields = empty($fields) ? array() : $fields; + foreach ( $fields as $field => $data ) { + $add_class = ''; + $add_attrs = ''; + if( ! empty( $this->errors[$field] ) ) { + $field_error = $this->errors[$field]; + $data['help'] = OpenSim::error_html( $field_error, 'warning' ) . ( $data['help'] ?? '' ); + if( $field_error['type'] == 'danger' ) { + $add_class .= ' is-invalid'; + } + } + if( $data['type'] == 'plaintext' ) { + $data['type'] = 'text'; + $add_class .= ' form-control-plaintext'; + } + if( isset( $data['readonly'] ) && $data['readonly'] ) { + $add_class .= ' text-muted'; + $add_attrs .= ' readonly'; + } + $add_attrs .= isset( $data['disabled'] ) && $data['disabled'] ? ' disabled' : ''; + $add_attrs .= isset( $data['required'] ) && $data['required'] ? ' required' : ''; + // $placeholder = isset( $data['placeholder'] ) ? $data['placeholder'] : ''; + + $html .= sprintf( + '
+ + + %s +
', + $field, + $data['label'], + $data['type'], + $field, + $add_attrs . $add_class, + $_POST[$field] ?? $data['value'] ?? $data['default'] ?? '', + $data['placeholder'] ?? '', + $add_attrs, + $data['help'] ?? '' + ); + } + + $submit = empty( $html ) ? '' : sprintf( + '', + _('Submit') + ); + + $buttons = sprintf( + '' + . '' + . '
%s
', + $this->form_id, + $this->next_step_key ?? '', + $reset_button . $submit + ); + $html = sprintf( + '
%s
', + $this->form_id, + $_SERVER['PHP_SELF'], + $html . $buttons + ); + + OpenSim::enqueue_script( 'form', 'js/form.js' ); + OpenSim::enqueue_style( 'form', 'css/form.css' ); + + $this->html = $html; + return $html; + } + + public function process() { + if( empty( $_POST )) { + // Only init values if form is not submitted + return $this->get_values(); + } + if (is_callable($this->callback)) { + return call_user_func($this->callback, $this->get_values()); + } else { + if (is_array($this->callback)) { + $callback_name = get_class($this->callback[0]) . '::' . $this->callback[1]; + } else { + $callback_name = $this->callback; + } + error_log( $callback_name . ' is not callable from ' . __METHOD__ ); + return false; + } + } + + /** + * Get values from fields definition and post. + * + * TODO: make sure values are not replaced with post values before this step, + * although it doesn't hurt with the current usage, it might be useful to + * compare old and new value in the process() method called later. + */ + public function get_values() { + $form_id = $this->form_id; + $values = array(); + if( $this->multistep ) { + $fields = $this->get_step_fields() ?? array(); + if( ! is_array( $fields )) { + error_log( __METHOD__ . ' wrong field format ' ); + return false; + } + } else { + $fields = $this->fields; + } + // error_log( 'Fields: ' . print_r( $fields, true ) ); + foreach( $fields as $key => $field ) { + $values[$key] = $_POST[$key] ?? $_SESSION[$form_id][$key] ?? $field['value'] ?? null; + } + return $values; + } + + private function get_step_fields() { + if( ! isset( $this->next_step_key ) || ! isset( $this->fields[$this->next_step_key] ) ) { + return array(); + } + return $this->fields[$this->next_step_key]; + } + + // Get defined fields + public function get_fields() { + return $this->fields; + } + + public function get_form( $form_id ) { + if( empty( $form_id )) { + return false; + } + return isset( self::$forms[$form_id] ) ? self::$forms[$form_id] : false; + } + + public function get_forms() { + return self::$forms ?? false; + } + + public function get_next_step() { + if( empty( $this->steps )) { + return false; + } + $steps = $this->steps; + $current_step = array_search($this->completed, array_keys($steps)); + if( empty( $this->completed ) ) { + $next_step_key = key($steps); + $next_step_label = $steps[$next_step_key]; + } else { + $next_step_key = array_keys($steps)[$current_step + 1] ?? null; + if( empty($steps[$next_step_key])) { + $next_step_key='completed'; + $next_step_label = _('Completed'); + } else { + $next_step_label = $steps[$next_step_key] ?? null; + } + } + // $this->next_step = $next_step_label; + $this->next_step_key = $next_step_key; + $this->tasks = $this->steps[$next_step_key]['tasks'] ?? false; + return $this->next_step_key; + } + /** + * Use the value of $this->complete as last completed step, get the next step and + * build a navigation html. + */ + private function refresh_steps() { + if( empty( $this->steps )) { + return false; + } + + $steps = $this->steps; + if( ! empty($_POST['form_id']) ) { + $form_id = $_POST['form_id']; + $form = self::$forms[$form_id]; + if( $form ) { + $form->process(); + } else { + error_log( __METHOD__ . ' Form ' . $form_id . ' is not registered' ); + return false; + } + } + $next_step_key = $this->get_next_step(); + + // Set progression table + $progress = array(); + $status = 'completed'; + foreach( $steps as $key => $step ) { + if( $key == $next_step_key ) { + $progress[$key] = 'active'; + $status = ''; + } else { + $progress[$key] = $status; + } + } + $this->progression = $progress; + } + + /** + * Build HTML progress bar with bootstrap classes + */ + public function render_progress() { + $this->refresh_steps(); + + if( empty( $this->steps )) { + return false; + } + + $steps = $this->steps; + $progress = $this->progression; + + $status = 'completed'; + $html = ''; + return $html; + } + + public function validate_file( $field_id, $file_path, $strict = false ) { + if( empty( $file_path ) ) { + if( $strict ) { + $message = sprintf( _('File %s is required'), $field_id ); + $this->task_error( $field_id, $message, 'danger' ); + return false; + } + // if not strict, allow it to be empty + return true; + } + if( ! file_exists( $file_path )) { + $message = sprintf( _('File %s not found'), $file_path ); + $this->task_error( $field_id, $message, 'danger' ); + return false; + } + return true; + } + + /** + * Make sure the file is a valid .ini file + */ + public function is_valid_ini_file( $field_id, $file_path, $strict = false ) { + if( ! $this->validate_file( $field_id, $file_path, true )) { + $this->task_error( $field_id, _('File not found'), 'danger' ); + return false; + } + + // If strict, use parse_ini_file + if( $strict ) { + $ini = parse_ini_file( $file_path ); + if( empty( $ini )) { + $message = sprintf( _('File %s does not comply with .ini standards.'), $file_path ); + $this->task_error( $field_id, $message, 'danger' ); + // throw new Exception( $message ); + return false; + } + return true; + } + + // If not strict, a light check is enough + // + // OpenSim uses some non-standard formatting that are not supported by parse_ini_file. + // Ignore comments and empty lines, then make sure the remaining contains + // only key = value pairs or [sections] + $ini = file_get_contents( $file_path ); + $lines = explode( "\n", $ini ); + $valid = true; + + // Filter out comments and empty lines + $lines = array_filter( array_map( 'trim', $lines ), function( $line ) { + return ! empty( $line ) && ! preg_match( '/^\s*;/', $line ); + }); + + // Filter out valid lines, leaving only invalid ones + $valid = array_filter( array_map( function( $line ) { + return preg_match( '/^\[.*\]$|.*=.*$/', $line ) ? false : $line; + }, $lines )); + + if( ! empty( $valid )) { + $message = sprintf( _('File %s is not a valid .ini file'), $file_path ); + error_log( $message . ', found invalid lines: ' . print_r( $valid, true ) ); + $this->task_error( $field_id, $message, 'danger' ); + return false; + } + return true; + } + + /** + * Make sure the file is a valid Robust.ini file + */ + public function is_robust_ini_file( $field_id, $file_path ) { + if( ! $this->is_valid_ini_file( $field_id, $file_path )) { + throw new Exception( _('Not a valid ini file') ); + // return false; + } + + $required_sections = array( + 'DatabaseService', + 'GridInfoService', + 'LoginService', + ); + + // Check if all required sections are present, with array_map (without parse_ini_file) + $ini = file_get_contents( $file_path ); + $lines = explode( "\n", $ini ); + $sections = array_map( function( $line ) { + if( preg_match( '/^\[(.*)\]\s*$/', $line, $matches )) { + return $matches[1]; + } + return false; + }, $lines ); + + $missing = array_diff( $required_sections, $sections ); + if( ! empty( $missing )) { + $message = sprintf( _('Not a valid Robust config file.'), $file_path, implode( ', ', $missing )); + $this->task_error( $field_id, $message, 'danger' ); + $message = sprintf( _('%s is missing required sections: %s'), $file_path, '' ); + throw new Exception( $message ); + } + + return true; + } + + public function complete( $step ) { + $this->completed = $step; + $_SESSION[$this->form_id]['completed'] = $step; + $this->refresh_steps(); + } +} diff --git a/classes/class-grid.php b/classes/class-grid.php new file mode 100644 index 0000000..e180afe --- /dev/null +++ b/classes/class-grid.php @@ -0,0 +1,290 @@ +constants(); + // $this->grid_stats = $this->get_grid_stats(); + // $this->grid_info = $this->get_grid_info(); + // $this->grid_info_card = $this->get_grid_info_card(); + // $this->grid_stats_card = $this->get_grid_stats_card(); + } + + public function constants() { + self::$labels = array( + 'status' => _('Status'), + 'members' => _('Members'), + 'active_members' => _('Active members (30 days)'), + 'members_in_world' => _('Members in world'), + 'active_users' => _('Active users (30 days)'), + 'total_users' => _('Total users in world'), + 'regions' => _('Regions'), + 'total_area' => _('Total area'), + ); + } + + public static function get_grid_info( $grid_uri = false, $args = array() ) { + $info = array(); + $HomeURI = OpenSim::get_option( 'Hypergrid.HomeURI' ); + if( ! $grid_uri || $grid_uri === $HomeURI ) { + $is_local_grid = true; + // Default, get login_uri from config, query grid for live grid_info + $grid_uri = OpenSim::get_option( 'Hypergrid.HomeURI' ); + if( empty( $grid_uri ) ) { + return false; + } + } else { + $is_local_grid = false; + // External grid lookup, not yet implemented + $info = array( + 'Grid Name' => 'External Grid, not implemented.', + ); + return false; + } + + // Fetch live info from grid using $login_uri/get_grid_info and parse xml result in array + // Example xml result: + $xml_url = $grid_uri . '/get_grid_info'; + try { + $xml = simplexml_load_file( $xml_url ); + } catch( Exception $e ) { + $xml = false; + } + if( ! $xml ) { + $info = array( + 'online' => false, + 'login' => $grid_uri, + ); + } else { + $info['online'] = true; + try { + $array = (array) $xml; + if( ! $array ) { + throw new Exception( 'Error parsing grid info.' ); + } + $info = array_merge( $info, $array ); + } catch( Exception $e ) { + return $e; + } + } + + if( $is_local_grid ) { + $config_info = OpenSim::get_option( 'GridInfoService' ); + if( is_array( $config_info ) ) { + $info = array_merge( $config_info, $info ); + } + } + + return $info; + } + + public static function array_to_card( $id, $info, $args = array() ) { + if( empty( $info ) ) { + return; + } + $hide_first = ''; + if( ! empty( $args['title'] ) && is_string( $args['title'] ) ) { + $title = $args['title']; + } else { + $title = array_values( $info )[0]; + if( is_numeric( array_keys( $info )[0] ) || ( isset($args['hide_first']) && $args['hide_first'] === true ) ) { + $hide_first = 'hidden d-none'; + } + } + + $collapse_head = ''; + $collapse_class = ''; + $collapse_data = ''; + $html = sprintf( + '
+
+
+ +
+
    ', + $id, + $title, + $collapse_head, + $collapse_class, + $collapse_data + ); + + foreach( $info as $key => $value ) { + $html .= sprintf( + ' +
  • + %3$s %4$s +
  • ', + $id, + $hide_first, + is_numeric( $key ) ? '' : $key . ':', + $value + ); + $hide_first = ''; + $class=""; + } + $html .= '
'; + $html .= '
'; + $html .= '
'; + return $html; + } + + /** + * Get grid information as a card + */ + public static function grid_info_card( $grid_uri = false, $args = array() ) { + $grid_info = self::get_grid_info( $grid_uri, $args ); + if( ! $grid_info || OpenSim::is_error( $grid_info ) ) { + return false; + } + + $info = array( + _('Grid Name') => $grid_info['gridname'], + _('Login URI') => OpenSim::hop( $grid_info['login'] ), + ); + + $title = false; + if( ! empty( $args['title'])) { + $title = $args['title'] === true ? _( 'Grid Information' ) : $args['title']; + } else { + $title = _( 'Grid Information' ); + } + return self::array_to_card( 'grid-info', $info, array( + 'title' => $title, + ) ); + } + + public static function grid_stats_card( $args = null ) { + $grid_stats = self::get_grid_stats( $args ); + + if( ! $grid_stats || OpenSim::is_error( $grid_stats ) ) { + return false; + } + + $title = false; + if( ! empty( $args['title'])) { + $title = $args['title'] === true ? _( 'Grid Information' ) : $args['title']; + } else { + $title = _( 'Grid Status' ); + } + + return self::array_to_card( 'grid-status', $grid_stats, array( + 'title' => $title, + ) ); + } + + private static function array_to_xml($array, $xml) { + foreach($array as $key => $value) { + if(is_array($value)) { + $subnode = $xml->addChild($key); + self::array_to_xml($value, $subnode); + } else { + $xml->addChild($key, htmlspecialchars($value)); + } + } + } + + public static function get_grid_stats( $args = null ) { + $grid_info = self::get_grid_info(); + if( empty( $grid_info ) ) { + return false; + } + + $grid_uri = $grid_info['login']; + + $args = array_merge(array( + 'output' => 'array', + 'title' => true, + )); + + $stats = array( + 'status' => $grid_info['online'] ? _('Online') : _('Offline'), + ); + + $robust_db = OpenSim::$robust_db; + if ( ! $robust_db || OpenSim::is_error($robust_db) ) { + $stats['error'] = _('Database not connected.'); + } else { + $lastmonth = time() - 30 * 86400; + $gridonline = $grid_info['online'] ? _('Yes') : _('No'); + + $filter = ''; + // if ( get_option( 'w4os_exclude_models' ) ) { + // $filter .= "u.FirstName != '" . get_option( 'w4os_model_firstname' ) . "' + // AND u.LastName != '" . get_option( 'w4os_model_lastname' ) . "'"; + // } + // if ( get_option( 'w4os_exclude_nomail' ) ) { + // $filter .= " AND u.Email != ''"; + // } + if ( ! empty( $filter ) ) { + $filter = "$filter AND "; + } + + $stats = array( + 'status' => $grid_info['online'] ? _('Online') : _('Offline'), + 'members' => $robust_db->get_var( "SELECT COUNT(*) + FROM UserAccounts as u WHERE $filter active=1" + ), + 'active_members' => $robust_db->get_var( "SELECT COUNT(*) + FROM GridUser as g, UserAccounts as u + WHERE $filter PrincipalID = UserID AND g.Login > :lastmonth", + array( + 'lastmonth' => $lastmonth, + ) + ), + 'members_in_world' => $robust_db->get_var( "SELECT COUNT(*) + FROM Presence AS p, UserAccounts AS u + WHERE $filter RegionID != '00000000-0000-0000-0000-000000000000' + AND p.UserID = u.PrincipalID;" + ), + 'active_users' => $robust_db->get_var( "SELECT COUNT(*) + FROM GridUser WHERE Login > :lastmonth", + array( + 'lastmonth' => $lastmonth, + ) + ), + 'total_users' => $robust_db->get_var( "SELECT COUNT(*) + FROM Presence WHERE RegionID != '00000000-0000-0000-0000-000000000000';" + ), + 'regions' => $robust_db->get_var( "SELECT COUNT(*) + FROM regions" + ), + 'total_area' => $robust_db->get_var( "SELECT round(sum(sizex * sizey / 1000000),2) + FROM regions" + ) . ' km²', + ); + + // Replace keys with values of self::$labels + $labels = self::$labels; + $labels = array_intersect_key( self::$labels, $stats ); + $stats = array_combine( $labels, $stats ); + + } + + switch( $args['output'] ) { + case 'xml': + $xml = new SimpleXMLElement(''); + self::array_to_xml($stats, $xml); + return $xml->asXML(); + case 'array': + return $stats; + default: + return $stats; + } + + return $stats; + } +} diff --git a/classes/class-ini.php b/classes/class-ini.php new file mode 100644 index 0000000..48e700c --- /dev/null +++ b/classes/class-ini.php @@ -0,0 +1,94 @@ +notify_error( $e, 'Error reading file' ); + } + $content = file_get_contents( $args ); + $this->raw_ini_array = explode( "\n", $content ); + } elseif( is_string( $args ) ) { + $this->raw_ini_array = explode( "\n", $args ); + } elseif( is_array( $args ) ) { + $this->raw_ini_array = $args; + } else { + throw new OpenSim_Error( __CLASS__ .' accepts only string, array or file path value' ); + } + + $this->sanitize_and_parse( $this->raw_ini_array ); + } + + public function get_config() { + return $this->config; + } + + public function get_ini() { + return $this->ini; + } + + /** + * Sanitize an INI string. Make sure each value is encosed in quotes. + * Convert constants to their value. + */ + private function sanitize_and_parse() { + $this->ini = ''; + $this->config = array(); + + $lines = $this->raw_ini_array; + + $section = '_'; + foreach ( $lines as $line ) { + $line = trim( $line ); + if ( empty( $line ) || preg_match('/^\s*;/', $line ) ) { + $this->ini .= "$line\n"; + continue; + } + $parts = explode( '=', $line ); + if( preg_match( '/^\[[a-zA-Z]+\]$/' , $line)) { + $section = trim( $line, '[]' ); + $this->ini .= "$line\n"; + continue; + } + if ( count( $parts ) < 2 ) { + $this->ini .= "$line\n"; + continue; + } + // use first part as key, the rest as value + $key = trim( array_shift( $parts ) ); + $value = trim( implode( '=', $parts ), '\" '); + + // Replace constants with their value for $config array, leave untouched for $ini. + $config_value = $value; + while ( preg_match( '/\${Const\|([a-zA-Z]+)}/', $config_value, $matches ) ) { + $const = $matches[1]; + $config_value = str_replace( '${Const|' . $const . '}', $this->config['Const'][$const], $config_value ); + } + $this->config[$section][$key] = $config_value; + + if( is_numeric( $value ) || in_array( $value, array( "true", "false" ) ) ) { + $this->ini .= "$key = $value\n"; + } else { + $this->ini .= "$key = \"$value\"\n"; + } + } + } +} diff --git a/classes/class-locale.php b/classes/class-locale.php new file mode 100644 index 0000000..4b9ec8a --- /dev/null +++ b/classes/class-locale.php @@ -0,0 +1,97 @@ + 'en-CA', 'q' => 1.0], ['locale' => 'ar-EG', 'q' => 0.5]] + foreach (explode(',', $httpAcceptLanguageHeader) as $locale) { + // separate the locale key ("ar-EG") from its weight ("q=0.5") + $localeParts = explode(';', $locale); + $weightedLocale = ['locale' => $localeParts[0]]; + if (count($localeParts) == 2) { + // explicit weight e.g. 'q=0.5' + $weightParts = explode('=', $localeParts[1]); + // grab the '0.5' bit and parse it to a float + $weightedLocale['q'] = floatval($weightParts[1]); + } else { + // no weight given in string, ie. implicit weight of 'q=1.0' + $weightedLocale['q'] = 1.0; + } + $weightedLocales[] = $weightedLocale; + } + return $weightedLocales; + } + + /** + * Sort by high to low `q` value + */ + private static function sortLocalesByWeight($locales) { + usort($locales, function ($a, $b) { + // usort will cast float values that we return here into integers, + // which can mess up our sorting. So instead of subtracting the `q`, + // values and returning the difference, we compare the `q` values and + // explicitly return integer values. + if ($a['q'] == $b['q']) { + return 0; + } + if ($a['q'] > $b['q']) { + return -1; + } + return 1; + }); + + return $locales; + } +} diff --git a/classes/class-page.php b/classes/class-page.php new file mode 100644 index 0000000..d8be259 --- /dev/null +++ b/classes/class-page.php @@ -0,0 +1,51 @@ +page_title; + } + + public function get_content() { + return $this->content; + } + + public function get_sidebar_left() { + return ''; + } + + public function get_sidebar( $id = 'right' ) { + if( empty( $id ) ) { + return ''; + } + $html = ''; + switch( $id ) { + case 'left': + // $html = OpenSim_Grid::grid_stats_card(); + break; + case 'right': + $html .= OpenSim_Grid::grid_info_card(); + $html .= OpenSim_Grid::grid_stats_card(); + break; + default: + break; + } + + $class="flex"; + // if( ! empty( $html ) ) { + // $html = sprintf( + // '', + // $id, + // $id, + // $html + // ); + // } + return $html; + } +} diff --git a/classes/init.php b/classes/init.php new file mode 100644 index 0000000..38c474c --- /dev/null +++ b/classes/init.php @@ -0,0 +1,680 @@ +constants(); + $this->includes(); + + $this->grid = new OpenSim_Grid(); + } + + public function constants() { + if( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', dirname( __FILE__ ) . '/' ); + } + define( 'OSHELPERS', true ); + define( 'OSHELPERS_DIR', self::trailingslashit( dirname( __DIR__ ) ) ); + define( 'OSHELPERS_URL', self::get_helpers_url() ); + // ( isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http" ) . "://$_SERVER[HTTP_HOST]$_SERVER[SCRIPT_NAME]" ); + + } + + public function includes() { + require_once( OSHELPERS_DIR . 'classes/class-exception.php' ); + require_once( OSHELPERS_DIR . 'includes/databases.php' ); + require_once( OSHELPERS_DIR . 'includes/functions.php' ); + $this->db_connect(); + + require_once( OSHELPERS_DIR . 'classes/class-locale.php' ); + require_once( OSHELPERS_DIR . 'classes/class-ini.php' ); + require_once( OSHELPERS_DIR . 'classes/class-grid.php' ); + } + + public function db_connect() { + $DatabaseService = self::get_option( 'DatabaseService', false ); + + $connectionstring = self::get_option( 'DatabaseService.ConnectionString', false); + if( $connectionstring ) { + $creds = self::connectionstring_to_array( $connectionstring ); + $dsn = sprintf( + 'mysql:host=%s;dbname=%s', + $creds['host'] . ( empty( $creds['port'] ) ? '' : ':' . $creds['port'] ), + $creds['name'], + ); + $db = new OSPDO( $dsn, $creds['user'], $creds['pass'] ); + if( $db->connected ) { + $db->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + $db->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC ); + self::$robust_db = $db; + } + } else { + return false; + } + } + + public function get_helpers_url() { + $helpers_path = dirname( __DIR__ ); + $url_path = self::trailingslashit( str_replace( $_SERVER['DOCUMENT_ROOT'], '', $helpers_path ) ); + + $parsed = array( + 'scheme' => isset( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http', + 'host' => $_SERVER['HTTP_HOST'], + ); + $url = self::build_url( $parsed ) . ltrim( $url_path ); + + return $url; + } + + public static function get_version( $sanitized = false ) { + if( $sanitized && self::$version_slug ) { + return self::$version_slug; + } else if ( ! $sanitized && self::$version ) { + return self::$version; + } + if( file_exists( OSHELPERS_DIR . '.version' ) ) { + $version = file_get_contents( '.version' ); + } else { + $version = '0.0.0'; + } + if( file_exists( '.git/HEAD' ) ) { + $hash = trim( file_get_contents( '.git/HEAD' ) ); + $hash = trim( preg_replace( '+.*[:/]+', '', $hash ) ); + if( !empty( $hash ) && file_exists( '.git/refs/heads/' . $hash ) ) { + $hash = substr( file_get_contents( '.git/refs/heads/' . $hash ), 0, 7 ) . " ($hash)"; + } else { + $hash = substr( $hash, 0, 7 ); + $hash .= ' (detached)'; + } + + $version .= empty( $hash ) ? ' git ' : ' git ' . $hash; + self::$is_dev = ( empty( $hash ) ) ? self::$is_dev : true; + } + + self::$version = $version; + self::$version_slug = self::sanitize_slug( $version ); + if( $sanitized && self::$version_slug ) { + return self::$version_slug; + } + return $version; + } + + public static function get_temp_dir( $dir = false ) { + if( isset( self::$tmp_dir ) ) { + return self::$tmp_dir; + } + + if ( ! empty( $dir ) && is_dir( $dir ) && is_writable( $dir ) ) { + $dir = realpath( $dir ); + } else { + $dirs = array( + sys_get_temp_dir(), + dirname( $_SERVER['DOCUMENT_ROOT'] ) . '/tmp', + ini_get( 'upload_tmp_dir' ), + '/var/tmp', + '~/tmp', + ); + foreach( $dirs as $key => $dir ) { + if( is_dir( $dir ) && is_writable( $dir ) ) { + $dir = realpath( $dir ); + break; + } + } + } + if ( ! $dir ) { + throw new OpenSim_Error( 'No writable temporary directory found.' ); + } + + self::$tmp_dir = $dir; + return $dir; + } + + public static function parse_args( $args, $defaults ) { + if( empty( $defaults ) ) { + $defaults = array(); + } + if( is_object( $args ) ) { + $args = get_object_vars( $args ); + } elseif( is_array( $args ) ) { + $args = $args; + } else { + parse_str( $args, $args ); + } + return array_merge( $defaults, $args ); + } + + public static function connectionstring_to_array( $connectionstring ) { + $parts = explode( ';', $connectionstring ); + $creds = array(); + foreach ( $parts as $part ) { + $pair = explode( '=', $part ); + $creds[ $pair[0] ] = $pair[1] ?? ''; + } + if( preg_match( '/:[0-9]+$/', $creds['Data Source'] ) ) { + $host = explode( ':', $creds['Data Source'] ); + $creds['Data Source'] = $host[0]; + $creds['Port'] = empty( $host[1] || $host[1] == 3306 ) ? null : $creds['Port']; + } + $result = array( + 'host' => $creds['Data Source'], + 'port' => $creds['Port'] ?? null, + 'name' => $creds['Database'], + 'user' => $creds['User ID'], + 'pass' => $creds['Password'], + ); + return $result; + } + + // Clone of WP trailingslashit function + public static function trailingslashit( $value ) { + return self::untrailingslashit( $value ) . '/'; + } + + // Clone of WP untrailingslashit function + public static function untrailingslashit( $value ) { + return rtrim( $value, '/\\' ); + } + + /** + * Notify user of an error, log it and display it in the admin area + * + * @param mixed $error (string) Error message or (Throwable) Exception + * @param string $type Error severity: 'info', 'warning', 'danger' + * @return void + */ + public static function notify_error( $error, $type = 'warning' ) { + // Initialize the prefix before error type check + $prefix = '[' . strtoupper( $type ) . '] '; + + // Retrieve the calling method's information + if ( $error instanceof Throwable ) { + $message = $error->getMessage(); + } elseif( is_string($error) ) { + $message = $error; + } else { + $message = _('Unknown error, see log for details'); + error_log( $prefix . 'Unidentified error type: ' . gettype( $error ) . ' ' . print_r( $error, true ) ); + } + if( ! empty( $message ) ) { + self::notify( $message, $type ); + } + } + + public static function notify( $message, $type = 'info' ) { + $key = md5( $type . $message ); // Make sure we don't have duplicates + self::$user_notices[$key] = array( + 'message' => $message, + 'type' => $type, + ); + } + + public static function get_notices() { + $html = ''; + foreach( self::$user_notices as $key => $notice ) { + $type = $notice['type'] ?? 'info'; + switch( $type ) { + case 'task-checked': + $html .= sprintf( + '
+ + +
', + $type, + $notice['message'] + ); + break; + + default: + $html .= sprintf( + '
%s
', + $type, + $notice['message'] + ); + } + } + return $html; + } + + public static function validate_error_type( $type, $fallback = 'light' ) { + $given = $type; + $type = in_array( $type, array( + 'primary', + 'secondary', + 'success', + 'danger', + 'warning', + 'info', + 'light', + 'dark', + ) ) ? $type : $fallback; + return $type; + } + + public static function validate_error ( $error, $type = 'light' ) { + if( is_string( $error )) { + $error = array( 'message', $error ); + } + $error = self::parse_args( $error, array( + 'message' => _('Error'), + 'type' => $type, + )); + $error['type'] = self::validate_error_type( $error['type'], $type ); + return $error; + } + + public static function error_html( $error, $type = null ) { + $error = self::validate_error( $error, $type ); + $html = sprintf( + '
%s
', + $error['type'], + $error['message'], + ); + return $html; + } + + /** + * Log message to error_log, adding calling [CLASS] and function before message, and severity if given + */ + public static function log( $message, $severity = none ) { + $caller = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 ); + $caller = $caller[1]; + $class = $caller['class'] ?? ''; + $function = $caller['function'] ?? ''; + $message = sprintf( + '[%s%s%s] %s%s', + $class, + empty( $class ) ? '' : '::', + $function, + empty( $severity ) ? '' : strtoupper( $severity ) . ' ', + $message + ); + // Add severity if given + error_log( $message ); + } + + public static function callback_name_string( $callback ) { + if( is_string( $callback ) ) { + return $callback; + } + if( is_array( $callback ) && is_object( $callback[0] ) ) { + $callback_name = get_class($callback[0]) . '::' . $callback[1]; + return $callback_name; + } + if( is_array( $callback ) ) { + return $callback[0] . '::' . $callback[1]; + } + if( is_object( $callback ) ) { + return get_class( $callback ); + } + return 'Unknown'; + } + + public static function build_url( $parsed ) { + if( empty( $parsed['host'] ) ) { + $url = ''; + } else { + $url = ( $parsed['scheme'] ?? 'https' ) . '://' . $parsed['host']; + } + $url .= $parsed['path'] ?? ''; + if( ! empty( $parsed['query'] ) ) { + $url .= '?' . $parsed['query']; + } + return $url; + } + + public static function add_query_args( $url, $args ) { + $parsed = parse_url( $url ); + $query = $parsed['query'] ?? ''; + $query = self::parse_args( $query, array() ); + $query = array_merge( $query, $args ); + $query = http_build_query( $query ); + $parsed['query'] = $query; + $url = self::build_url( $parsed ); + return $url; + } + + /** + * Basic function to replace WP enqueue_script when not in WP environment. + * Add the script to a private property that will be used with another method to output all scripts. + * Use OSHELPERS_URL constant to build the URL unless it's already full. + * Use self::get_version() to define the version of the script unless it is already defined. + */ + public static function enqueue_script( $handle, $src, $deps = array(), $ver = false, $in_footer = false ) { + if( ! file_exists( $src ) ) { + error_log( __FUNCTION__ . ' file not found: ' . $src ); + return false; + } + + $handle = preg_match( '/^oshelpers-/', $handle ) ? $handle : 'oshelpers-' . $handle; + $handle = ( rtrim ( $handle, '-css' ) ) . '-js'; + + self::$scripts = self::$scripts ?? array( 'head' => array(), 'footer' => array() ); + if( strpos( $src, '://' ) === false ) { + $src = OSHELPERS_URL . ltrim( $src, '/' ); + } + $ver = empty( $ver ) ? self::get_version( true ) : self::sanitize_slug( $ver ); + $src = self::add_query_args( $src, array( 'ver' => $ver ) ); + + $section = $in_footer ? 'footer' : 'head'; + self::$scripts[$section][$handle] = array( + 'src' => $src, + 'deps' => $deps, + 'ver' => $ver ?? self::get_version( true ), + 'in_footer' => $in_footer, + ); + } + + /** + * Return or output the html for scripts in the head or footer + * + * @param string $section 'head' or 'footer' + * @param bool $echo Output the html if true, return it if false + */ + public static function get_scripts( $section, $echo = false ) { + if( ! isset( self::$scripts[$section] ) ) { + return ''; + } + $html = ''; + if(empty( self::$scripts[$section] ) ) { + return ''; + } + + $template = ''; + + $scripts = self::$scripts[$section]; + foreach( $scripts as $handle => $script ) { + // error_log( 'Script: ' . print_r( $script, true ) ); + $html .= sprintf( + $template, + $handle, + $script['src'], + empty( $script['ver'] ) ? '' : 'version="' . $script['ver'] . '"' + ); + } + if( $echo ) { + echo $html; + } + // error_log( $html ); + return $html; + } + + public static function enqueue_style( $handle, $src, $deps = array(), $ver = false, $media = 'all' ) { + if( ! file_exists( $src ) ) { + error_log( __FUNCTION__ . ' file not found: ' . $src ); + return false; + } + + $handle = preg_match( '/^oshelpers-/', $handle ) ? $handle : 'oshelpers-' . $handle; + $handle = ( rtrim ( $handle, '-css' ) ) . '-css'; + + self::$styles = self::$styles ?? array( 'head' => array(), 'footer' => array() ); + if( strpos( $src, '://' ) === false ) { + $src = OSHELPERS_URL . ltrim( $src, '/' ); + } + $ver = empty( $ver ) ? self::get_version( true ) : self::sanitize_slug( $ver ); + $src = self::add_query_args( $src, array( 'ver' => $ver ) ); + + self::$styles['head'][$handle] = array( + 'src' => $src, + 'deps' => $deps, + 'ver' => $ver, + 'media' => $media, + ); + } + + public static function get_styles( $echo = false ) { + if( ! isset( self::$styles['head'] ) ) { + return ''; + } + $html = ''; + if(empty( self::$styles['head'] ) ) { + return ''; + } + + $template = ''; + + $styles = self::$styles['head']; + foreach( $styles as $handle => $style ) { + $html .= sprintf( + $template, + $handle, + $style['src'], + $style['media'], + ); + } + if( $echo ) { + echo $html; + } + return $html; + } + + public static function sanitize_id( $string ) { + if( empty( $string ) ) { + return false; + } + + $id = $string; + try { + $id = transliterator_transliterate("Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; [:Punctuation:] Remove; Lower();", $id ); + $id = preg_replace('/[-\s]+/', '-', $id ); + } catch ( Error $e ) { + error_log( 'Error sanitizing slug: ' . $e->getMessage() ); + $id = $string; + } + + return $id; + } + + public static function sanitize_slug( $string ) { + return self::sanitize_id( $string ); + } + + public static function sanitize_url( $url ) { + $url = trim( $url ); + $url = str_replace( ' ', '+', $url ); + $url = filter_var( $url, FILTER_SANITIZE_URL ); + return $url; + } + + public static function validate_condition( $condition ) { + if( is_callable( $condition ) ) { + return $condition(); + } + if( is_bool( $condition ) ) { + return $condition; + } + switch( $condition ) { + case 'logged_in': + return self::is_logged_in(); + case 'logged_out': + return self::is_logged_out(); + default: + return false; + } + } + + /** + * Get user preferred language from browser settings. + * + * @param bool $long Full locale string if true (en_US), language code otherwise (en) + * @return string + */ + public static function user_locale( $long = true ) { + return $long ? OpenSim_Locale::locale() : OpenSim_Locale::lang(); + } + + public static function user_lang( $long = false ) { + return $long ? OpenSim_Locale::locale() : OpenSim_Locale::lang(); + } + + /** + * Return the actual language of the content if localization is setup + */ + public static function content_lang( $long = false ) { + // When localization is setup, we will return user language + // For now, we return english. + $lang = 'en_US'; + + // return self::user_locale( $long ); + return $long ? $lang : substr( $lang, 0, 2 ); + } + + public static function is_logged_in() { + // WP is not loaded so constants like COOKIEHASH are not available. + // Any cookie matching wordpress_logged_in or wordpress_logged_in_* + // is considered a valid login cookie. + // If logged_in, use first part of cookie value as user_id + foreach( $_COOKIE as $key => $value ) { + if( preg_match( '/^wordpress_logged_in/', $key ) ) { + $parts = explode( '|', $value ); + $_SESSION['user_id'] = $parts[0]; + break; + } + } + + return isset( $_SESSION['user_id'] ); + } + + public static function is_logged_out() { + return ! self::is_logged_in(); + } + + public static function get_user_id() { + return $_SESSION['user_id'] ?? false; + } + + public static function display_name( $fallback = false ) { + if( ! self::is_logged_in() ) { + return $fallback; + } + + // For now, we return the user_id + return self::get_user_id() ?? $fallback; + } + + public static function icon( $icon, $size = 'inherit' ) { + if( is_callable( $icon ) ) { + $callback = $icon; + return call_user_func( $callback, $size ); + } + $size = is_numeric( $size ) ? $size . 'px' : $size; + return ' '; + } + + /** + * Display small user icon based on in-world Avatar profile picture. + * Not implemented yet. For now, we use bootstrap icons. + * In any case, we don't use gravatar yet. + */ + public static function user_icon( $size = 'inherit' ) { + if( ! self::is_logged_in() ) { + return null; + } + $size = is_numeric( $size ) ? $size . 'px' : $size; + return self::icon( 'person-circle', $size ); + // For now, we return user icon with bootstrap library + // return ' '; + } + + /** + * get_option() + * + * Retrieve an option value from + * - $_SESSION['installation']['config'], if exists, organized as an array( + * 'section' => array( + * 'option' => 'value', + * 'option2' => 'value2', + * ) + * - self::$config, if exists, organized the same way as $_SESSION['installation']['config'] + * - constants defined includes/config.php (map to be implemented later) + * - site configuration data (to be implemented later) + */ + public static function get_option( $option, $default = null ) { + $config = $_SESSION['installation']['config'] ?? self::$config ?? array(); + if( empty( $config ) ) { + return $default; + } + // Give value of $config[$section][$key] if when given $option = 'section.key' + if( strpos( $option, '.' ) !== false ) { + $parts = explode( '.', $option ); + $section = $config[$parts[0]]; + $key = trim($parts[1]); + return $section[$key] ?? $default; + } + + // Otherwise return global option + return $config[$option] ?? $default; + } + + static function hop( $url = null, $string = null, $format = true ) { + if ( empty( $url ) ) { + // $url = get_option( 'w4os_login_uri' ); + return $string; + } + $url = opensim_format_tp( $url, TPLINK_HOP ); + + if ( ! $format ) { + return $url; + } + + $string = ( empty( $string ) ) ? $url : $string; + $classes[] = 'hop'; + $classes[] = 'hop-link'; + if ( preg_match( ':/app/agent/:', $url ) ) { + $classes[] = 'profile'; + } + + $string = preg_replace( '+.*://+', '', $string ); + return sprintf( + '%s %s', + implode( ' ', $classes ), + $url, + self::icon( 'door-open' ), + $string, + // self::icon( 'box-arrow-up-right' ), + // self::icon( 'arrow-right-square' ), + ); + } + + public static function is_error( $thing ) { + // Throwable includes Exception and probably other things. + if ( $thing instanceof Throwable ) { + return true; + } + return false; + } + +} + +$OpenSim = new OpenSim(); +$OpenSim->init(); diff --git a/css/bootstrap.css b/css/bootstrap.css new file mode 100644 index 0000000..e69de29 diff --git a/css/form.css b/css/form.css new file mode 100644 index 0000000..8a7a778 --- /dev/null +++ b/css/form.css @@ -0,0 +1,5 @@ +::placeholder, +input::placeholder { + display: none; + opacity: 0.5; +} diff --git a/js/form.js b/js/form.js new file mode 100644 index 0000000..e69de29 diff --git a/templates/bootstrap.css b/templates/bootstrap.css new file mode 100644 index 0000000..5c6f170 --- /dev/null +++ b/templates/bootstrap.css @@ -0,0 +1,41 @@ +/* + * Template page CSS +**/ + +:root { + --bs-primary-rgb: 0, 192, 0; + --bs-secondary-rgb: 63, 159, 63; +} + +[data-bs-theme="dark"] { +} + +/* Add this to your CSS file */ +.dropdown-hover:hover .dropdown-menu { + display: block; +} + +.block a, +a.hop-link { + text-decoration: none !important; +} + +.cards .accordion-button { + border: 0; +} + +.cards .accordion-button:not(.collapsed) { + color: inherit; + background-color: inherit; + box-shadow: none; +} + +.cards .accordion-button::after { + background-image: url("data:image/svg+xml,") !important; + transform: rotate(180deg); +} + +.accordion-button:not(.collapsed)::after { + color: white; + transform: rotate(0); +} diff --git a/templates/bootstrap.js b/templates/bootstrap.js new file mode 100644 index 0000000..83a53d7 --- /dev/null +++ b/templates/bootstrap.js @@ -0,0 +1,17 @@ +document.addEventListener('DOMContentLoaded', () => { + // Function to apply the color scheme + const applyColorScheme = (e) => { + if (e.matches) { + document.documentElement.setAttribute('data-bs-theme', 'dark'); + } else { + document.documentElement.setAttribute('data-bs-theme', 'light'); + } + }; + + // Detect and apply user's preferred color scheme on initial load + const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + applyColorScheme(darkModeMediaQuery); + + // Listen for changes in the user's color scheme preference + darkModeMediaQuery.addEventListener('change', applyColorScheme); +}); diff --git a/templates/template-page.php b/templates/template-page.php new file mode 100644 index 0000000..4d6d49e --- /dev/null +++ b/templates/template-page.php @@ -0,0 +1,74 @@ + + + + + + <?php echo $page_title; ?> + + + + + + +
+ + +
+
+ +
+ Card
'; + // $sidebar_right = '
Card
'; + ?> +
+

+
+ +
+
+ get_sidebar('left'); + if ( ! empty( $sidebar_left ) ) : + ?> + + + get_sidebar('right'); + if ( ! empty( $sidebar_right ) ) : + ?> + + +
+
+
+ +
+ + + + diff --git a/templates/templates.php b/templates/templates.php new file mode 100644 index 0000000..d10db30 --- /dev/null +++ b/templates/templates.php @@ -0,0 +1,130 @@ + array( +// 'url' => '/', +// 'label' => 'Home', +// ), +// 'about' => array( +// 'url' => '/about', +// 'label' => 'About', +// ), +// ); + +$menus['user'] = array( + 'userprofile' => array( + 'url' => '/profile', + 'label' => OpenSim::display_name( _('Profile') ), + 'icon' => [ 'OpenSim', 'user_icon' ], + 'condition' => 'logged_in', + 'children' => array( + 'account' => array( + 'url' => '/account', + 'label' => _('Account Settings'), + 'icon' => 'sliders' + ), + 'logout' => array( + 'url' => '?action=logout', + 'label' => _('Logout'), + 'icon' => 'box-arrow-right', + ), + ), + ), + 'login' => array( + 'url' => '/login', + 'label' => 'Login', + 'icon' => 'box-arrow-in-right', + 'condition' => 'logged_out', + ), +); + +$menus['footer'] = array( + 'github' => array( + 'url' => 'http://github.com/magicoli/opensim-helpers', + 'label' => 'GitHub Repository', + ), + 'w4os' => array( + 'url' => 'https://w4os.org', + 'label' => 'W4OS Project', + ), +); + +/** + * Build HTML menu using Bootstrap styles + */ +function format_menu( $menu, $slug = 'main', $class = '' ) { + $id = "navbar-$slug"; + $html = sprintf( + ''; + + return $html; +} + +$branding = '' . htmlspecialchars($GLOBALS['site_title']) . ''; + +// Generate HTML for each menu +$main_menu_html = format_menu( ($menus['main'] ?? null ), 'main' ); +$user_menu_html = format_menu( $menus['user'], 'user' ); +$footer_menu_html = format_menu( $menus['footer'], 'footer' ); + +OpenSim::enqueue_script( 'template-page', 'templates/bootstrap.js' ); +OpenSim::enqueue_style( 'template-page', 'templates/bootstrap.css' ); + +require( 'template-page.php' );