. * * @package core * @subpackage portfolio * @author Penny Leach * @license http://www.gnu.org/copyleft/gpl.html GNU GPL * @copyright (C) 1999 onwards Martin Dougiamas http://dougiamas.com * * This file contains the class definition for the exporter object. */ defined('MOODLE_INTERNAL') || die(); /** * The class that handles the various stages of the actual export * and the communication between the caller and the portfolio plugin. * this is stored in the database between page requests in serialized base64 encoded form * also contains helper methods for the plugin and caller to use (at the end of the file) * {@see get_base_filearea} - where to write files to * {@see write_new_file} - write some content to a file in the export filearea * {@see copy_existing_file} - copy an existing file into the export filearea * {@see get_tempfiles} - return list of all files in the export filearea */ class portfolio_exporter { /** * the caller object used during the export */ private $caller; /** the portfolio plugin instanced used during the export */ private $instance; /** * if there has been no config form displayed to the user */ private $noexportconfig; /** * the user currently exporting content * always $USER, but more conveniently placed here */ private $user; /** the file to include that contains the class defintion * of the portfolio instance plugin * used to re-waken the object after sleep */ public $instancefile; /** * the file to include that contains the class definition * of the caller object * used to re-waken the object after sleep */ public $callerfile; /** * the current stage of the export */ private $stage; /** * whether something (usually the portfolio plugin) * has forced queuing */ private $forcequeue; /** * id of this export * matches record in portfolio_tempdata table * and used for itemid for file storage. */ private $id; /** * array of stages that have had the portfolio plugin already steal control from them */ private $alreadystolen; /** * files that the exporter has written to this temp area * keep track of this in case of duplicates within one export * see MDL-16390 */ private $newfilehashes; /** * selected exportformat * this is also set in export_config in the portfolio and caller classes */ private $format; /** * queued - this is set after the event is triggered */ private $queued = false; /** * expiry time - set the first time the object is saved out */ private $expirytime; /** * deleted - this is set during the cleanup routine * so that subsequent save() calls can detect it */ private $deleted = false; /** * construct a new exporter for use * * @param portfolio_plugin_base subclass $instance portfolio instance (passed by reference) * @param portfolio_caller_base subclass $caller portfolio caller (passed by reference) * @param string $callerfile path to callerfile (relative to dataroot) */ public function __construct(&$instance, &$caller, $callerfile) { $this->instance =& $instance; $this->caller =& $caller; if ($instance) { $this->instancefile = 'portfolio/' . $instance->get('plugin') . '/lib.php'; $this->instance->set('exporter', $this); } $this->callerfile = $callerfile; $this->stage = PORTFOLIO_STAGE_CONFIG; $this->caller->set('exporter', $this); $this->alreadystolen = array(); $this->newfilehashes = array(); } /* * generic getter for properties belonging to this instance * outside the subclasses * like name, visible etc. */ public function get($field) { if ($field == 'format') { return portfolio_format_object($this->format); } else if ($field == 'formatclass') { return $this->format; } if (property_exists($this, $field)) { return $this->{$field}; } $a = (object)array('property' => $field, 'class' => get_class($this)); throw new portfolio_export_exception($this, 'invalidproperty', 'portfolio', null, $a); } /** * generic setter for properties belonging to this instance * outside the subclass * like name, visible, etc. */ public function set($field, &$value) { if (property_exists($this, $field)) { $this->{$field} =& $value; if ($field == 'instance') { $this->instancefile = 'portfolio/' . $this->instance->get('plugin') . '/lib.php'; $this->instance->set('exporter', $this); } $this->dirty = true; return true; } $a = (object)array('property' => $field, 'class' => get_class($this)); throw new portfolio_export_exception($this, 'invalidproperty', 'portfolio', null, $a); } /** * sets this export to force queued * sometimes plugins need to set this randomly * if an external system changes its mind * about what's supported */ public function set_forcequeue() { $this->forcequeue = true; } /** * process the given stage calling whatever functions are necessary * * @param int $stage (see PORTFOLIO_STAGE_* constants) * @param boolean $alreadystolen used to avoid letting plugins steal control twice. * * @return boolean whether or not to process the next stage. this is important as the function is called recursively. */ public function process_stage($stage, $alreadystolen=false) { $this->set('stage', $stage); if ($alreadystolen) { $this->alreadystolen[$stage] = true; } else { if (!array_key_exists($stage, $this->alreadystolen)) { $this->alreadystolen[$stage] = false; } } if (!$this->alreadystolen[$stage] && $url = $this->instance->steal_control($stage)) { $this->save(); redirect($url); // does not return } else { $this->save(); } $waiting = $this->instance->get_export_config('wait'); if ($stage > PORTFOLIO_STAGE_QUEUEORWAIT && empty($waiting)) { $stage = PORTFOLIO_STAGE_FINISHED; } $functionmap = array( PORTFOLIO_STAGE_CONFIG => 'config', PORTFOLIO_STAGE_CONFIRM => 'confirm', PORTFOLIO_STAGE_QUEUEORWAIT => 'queueorwait', PORTFOLIO_STAGE_PACKAGE => 'package', PORTFOLIO_STAGE_CLEANUP => 'cleanup', PORTFOLIO_STAGE_SEND => 'send', PORTFOLIO_STAGE_FINISHED => 'finished' ); $function = 'process_stage_' . $functionmap[$stage]; try { if ($this->$function()) { // if we get through here it means control was returned // as opposed to wanting to stop processing // eg to wait for user input. $this->save(); $stage++; return $this->process_stage($stage); } else { $this->save(); return false; } } catch (portfolio_caller_exception $e) { portfolio_export_rethrow_exception($this, $e); } catch (portfolio_plugin_exception $e) { portfolio_export_rethrow_exception($this, $e); } catch (portfolio_export_exception $e) { throw $e; } catch (Exception $e) { debugging(get_string('thirdpartyexception', 'portfolio', get_class($e))); debugging($e); portfolio_export_rethrow_exception($this, $e); } } /** * helper function to return the portfolio instance * * @return portfolio_plugin_base subclass */ public function instance() { return $this->instance; } /** * helper function to return the caller object * * @return portfolio_caller_base subclass */ public function caller() { return $this->caller; } /** * processes the 'config' stage of the export * * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_config() { global $OUTPUT, $CFG; $pluginobj = $callerobj = null; if ($this->instance->has_export_config()) { $pluginobj = $this->instance; } if ($this->caller->has_export_config()) { $callerobj = $this->caller; } $formats = portfolio_supported_formats_intersect($this->caller->supported_formats(), $this->instance->supported_formats()); $expectedtime = $this->instance->expected_time($this->caller->expected_time()); if (count($formats) == 0) { // something went wrong, we should not have gotten this far. throw new portfolio_export_exception($this, 'nocommonformats', 'portfolio', null, array('location' => get_class($this->caller), 'formats' => implode(',', $formats))); } // even if neither plugin or caller wants any config, we have to let the user choose their format, and decide to wait. if ($pluginobj || $callerobj || count($formats) > 1 || ($expectedtime != PORTFOLIO_TIME_LOW && $expectedtime != PORTFOLIO_TIME_FORCEQUEUE)) { $customdata = array( 'instance' => $this->instance, 'id' => $this->id, 'plugin' => $pluginobj, 'caller' => $callerobj, 'userid' => $this->user->id, 'formats' => $formats, 'expectedtime' => $expectedtime, ); require_once($CFG->libdir . '/portfolio/forms.php'); $mform = new portfolio_export_form('', $customdata); if ($mform->is_cancelled()){ $this->cancel_request(); } else if ($fromform = $mform->get_data()){ if (!confirm_sesskey()) { throw new portfolio_export_exception($this, 'confirmsesskeybad'); } $pluginbits = array(); $callerbits = array(); foreach ($fromform as $key => $value) { if (strpos($key, 'plugin_') === 0) { $pluginbits[substr($key, 7)] = $value; } else if (strpos($key, 'caller_') === 0) { $callerbits[substr($key, 7)] = $value; } } $callerbits['format'] = $pluginbits['format'] = $fromform->format; $pluginbits['wait'] = $fromform->wait; if ($expectedtime == PORTFOLIO_TIME_LOW) { $pluginbits['wait'] = 1; $pluginbits['hidewait'] = 1; } else if ($expectedtime == PORTFOLIO_TIME_FORCEQUEUE) { $pluginbits['wait'] = 0; $pluginbits['hidewait'] = 1; $this->forcequeue = true; } $callerbits['hideformat'] = $pluginbits['hideformat'] = (count($formats) == 1); $this->caller->set_export_config($callerbits); $this->instance->set_export_config($pluginbits); $this->set('format', $fromform->format); return true; } else { $this->print_header(get_string('configexport', 'portfolio')); echo $OUTPUT->box_start(); $mform->display(); echo $OUTPUT->box_end(); echo $OUTPUT->footer(); return false;; } } else { $this->noexportconfig = true; $format = array_shift($formats); $config = array( 'hidewait' => 1, 'wait' => (($expectedtime == PORTFOLIO_TIME_LOW) ? 1 : 0), 'format' => $format, 'hideformat' => 1 ); $this->set('format', $format); $this->instance->set_export_config($config); $this->caller->set_export_config(array('format' => $format, 'hideformat' => 1)); if ($expectedtime == PORTFOLIO_TIME_FORCEQUEUE) { $this->forcequeue = true; } return true; // do not break - fall through to confirm } } /** * processes the 'confirm' stage of the export * * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_confirm() { global $CFG, $DB, $OUTPUT; $previous = $DB->get_records( 'portfolio_log', array( 'userid' => $this->user->id, 'portfolio' => $this->instance->get('id'), 'caller_sha1' => $this->caller->get_sha1(), ) ); if (isset($this->noexportconfig) && empty($previous)) { return true; } $strconfirm = get_string('confirmexport', 'portfolio'); $baseurl = $CFG->wwwroot . '/portfolio/add.php?sesskey=' . sesskey() . '&id=' . $this->get('id'); $yesurl = $baseurl . '&stage=' . PORTFOLIO_STAGE_QUEUEORWAIT; $nourl = $baseurl . '&cancel=1'; $this->print_header(get_string('confirmexport', 'portfolio')); echo $OUTPUT->box_start(); echo $OUTPUT->heading(get_string('confirmsummary', 'portfolio'), 3); $mainsummary = array(); if (!$this->instance->get_export_config('hideformat')) { $mainsummary[get_string('selectedformat', 'portfolio')] = get_string('format_' . $this->instance->get_export_config('format'), 'portfolio'); } if (!$this->instance->get_export_config('hidewait')) { $mainsummary[get_string('selectedwait', 'portfolio')] = get_string(($this->instance->get_export_config('wait') ? 'yes' : 'no')); } if ($previous) { $previousstr = ''; foreach ($previous as $row) { $previousstr .= userdate($row->time); if ($row->caller_class != get_class($this->caller)) { require_once($CFG->dirroot . '/' . $row->caller_file); $previousstr .= ' (' . call_user_func(array($row->caller_class, 'display_name')) . ')'; } $previousstr .= '
'; } $mainsummary[get_string('exportedpreviously', 'portfolio')] = $previousstr; } if (!$csummary = $this->caller->get_export_summary()) { $csummary = array(); } if (!$isummary = $this->instance->get_export_summary()) { $isummary = array(); } $mainsummary = array_merge($mainsummary, $csummary, $isummary); $table = new html_table(); $table->attributes['class'] = 'generaltable exportsummary'; $table->data = array(); foreach ($mainsummary as $string => $value) { $table->data[] = array($string, $value); } echo html_writer::table($table); echo $OUTPUT->confirm($strconfirm, $yesurl, $nourl); echo $OUTPUT->box_end(); echo $OUTPUT->footer(); return false; } /** * processes the 'queueornext' stage of the export * * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_queueorwait() { $wait = $this->instance->get_export_config('wait'); if (empty($wait)) { events_trigger('portfolio_send', $this->id); $this->queued = true; return $this->process_stage_finished(true); } return true; } /** * processes the 'package' stage of the export * * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_package() { // now we've agreed on a format, // the caller is given control to package it up however it wants // and then the portfolio plugin is given control to do whatever it wants. try { $this->caller->prepare_package(); } catch (portfolio_exception $e) { throw new portfolio_export_exception($this, 'callercouldnotpackage', 'portfolio', null, $e->getMessage()); } catch (file_exception $e) { throw new portfolio_export_exception($this, 'callercouldnotpackage', 'portfolio', null, $e->getMessage()); } try { $this->instance->prepare_package(); } catch (portfolio_exception $e) { throw new portfolio_export_exception($this, 'plugincouldnotpackage', 'portfolio', null, $e->getMessage()); } catch (file_exception $e) { throw new portfolio_export_exception($this, 'plugincouldnotpackage', 'portfolio', null, $e->getMessage()); } return true; } /** * processes the 'cleanup' stage of the export * * @param boolean $pullok normally cleanup is deferred for pull plugins until after the file is requested from portfolio/file.php * if you want to clean up earlier, pass true here (defaults to false) * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_cleanup($pullok=false) { global $CFG, $DB; if (!$pullok && $this->get('instance') && !$this->get('instance')->is_push()) { return true; } if ($this->get('instance')) { // might not be set - before export really starts $this->get('instance')->cleanup(); } $DB->delete_records('portfolio_tempdata', array('id' => $this->id)); $fs = get_file_storage(); $fs->delete_area_files(SYSCONTEXTID, 'portfolio', 'exporter', $this->id); $this->deleted = true; return true; } /** * processes the 'send' stage of the export * * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_send() { // send the file try { $this->instance->send_package(); } catch (portfolio_plugin_exception $e) { // not catching anything more general here. plugins with dependencies on other libraries that throw exceptions should catch and rethrow. // eg curl exception throw new portfolio_export_exception($this, 'failedtosendpackage', 'portfolio', null, $e->getMessage()); } // only log push types, pull happens in send_file if ($this->get('instance')->is_push()) { $this->log_transfer(); } return true; } /** * log the transfer * this should only be called after the file has been sent * either via push, or sent from a pull request. */ public function log_transfer() { global $DB; $l = array( 'userid' => $this->user->id, 'portfolio' => $this->instance->get('id'), 'caller_file' => $this->callerfile, 'caller_sha1' => $this->caller->get_sha1(), 'caller_class' => get_class($this->caller), 'continueurl' => $this->instance->get_static_continue_url(), 'returnurl' => $this->caller->get_return_url(), 'tempdataid' => $this->id, 'time' => time(), ); $DB->insert_record('portfolio_log', $l); } /** * in some cases (mahara) we need to update this after the log has been done * because of MDL-20872 */ public function update_log_url($url) { global $DB; $DB->set_field('portfolio_log', 'continueurl', $url, array('tempdataid' => $this->id)); } /** * processes the 'finish' stage of the export * * @return boolean whether or not to process the next stage. this is important as the control function is called recursively. */ public function process_stage_finished($queued=false) { global $OUTPUT; $returnurl = $this->caller->get_return_url(); $continueurl = $this->instance->get_interactive_continue_url(); $extras = $this->instance->get_extra_finish_options(); $key = 'exportcomplete'; if ($queued || $this->forcequeue) { $key = 'exportqueued'; if ($this->forcequeue) { $key = 'exportqueuedforced'; } } $this->print_header(get_string($key, 'portfolio'), false); self::print_finish_info($returnurl, $continueurl, $extras); echo $OUTPUT->footer(); return false; } /** * local print header function to be reused across the export * * @param string $headerstring full language string */ public function print_header($headingstr, $summary=true) { global $OUTPUT, $PAGE; $titlestr = get_string('exporting', 'portfolio'); $headerstr = get_string('exporting', 'portfolio'); $PAGE->set_title($titlestr); $PAGE->set_heading($headerstr); echo $OUTPUT->header(); echo $OUTPUT->heading($headingstr); if (!$summary) { return; } echo $OUTPUT->box_start(); echo $OUTPUT->box_start(); echo $this->caller->heading_summary(); echo $OUTPUT->box_end(); if ($this->instance) { echo $OUTPUT->box_start(); echo $this->instance->heading_summary(); echo $OUTPUT->box_end(); } echo $OUTPUT->box_end(); } /** * cancels a potfolio request and cleans up the tempdata * and redirects the user back to where they started */ public function cancel_request($logreturn=false) { global $CFG; if (!isset($this)) { return; } $this->process_stage_cleanup(true); if ($logreturn) { redirect($CFG->wwwroot . '/user/portfoliologs.php'); } redirect($this->caller->get_return_url()); exit; } /** * writes out the contents of this object and all its data to the portfolio_tempdata table and sets the 'id' field. */ public function save() { global $DB; if (empty($this->id)) { $r = (object)array( 'data' => base64_encode(serialize($this)), 'expirytime' => time() + (60*60*24), 'userid' => $this->user->id, 'instance' => (empty($this->instance)) ? null : $this->instance->get('id'), ); $this->id = $DB->insert_record('portfolio_tempdata', $r); $this->expirytime = $r->expirytime; $this->save(); // call again so that id gets added to the save data. } else { if (!$r = $DB->get_record('portfolio_tempdata', array('id' => $this->id))) { if (!$this->deleted) { //debugging("tried to save current object, but failed - see MDL-20872"); } return; } $r->data = base64_encode(serialize($this)); $r->instance = (empty($this->instance)) ? null : $this->instance->get('id'); $DB->update_record('portfolio_tempdata', $r); } } /** * rewakens the data from the database given the id * makes sure to load the required files with the class definitions * * @param int $id id of data * * @return portfolio_exporter */ public static function rewaken_object($id) { global $DB, $CFG; require_once($CFG->libdir . '/filelib.php'); require_once($CFG->libdir . '/portfolio/exporter.php'); require_once($CFG->libdir . '/portfolio/caller.php'); require_once($CFG->libdir . '/portfolio/plugin.php'); if (!$data = $DB->get_record('portfolio_tempdata', array('id' => $id))) { // maybe it's been finished already by a pull plugin // so look in the logs if ($log = $DB->get_record('portfolio_log', array('tempdataid' => $id))) { self::print_cleaned_export($log); } throw new portfolio_exception('invalidtempid', 'portfolio'); } $exporter = unserialize(base64_decode($data->data)); if ($exporter->instancefile) { require_once($CFG->dirroot . '/' . $exporter->instancefile); } require_once($CFG->dirroot . '/' . $exporter->callerfile); $exporter = unserialize(serialize($exporter)); if (!$exporter->get('id')) { // workaround for weird case // where the id doesn't get saved between a new insert // and the subsequent call that sets this field in the serialised data $exporter->set('id', $id); $exporter->save(); } return $exporter; } /** * helper function to create the beginnings of a file_record object * to create a new file in the portfolio_temporary working directory * use {@see write_new_file} or {@see copy_existing_file} externally * * @param string $name filename of new record */ private function new_file_record_base($name) { return (object)array_merge($this->get_base_filearea(), array( 'filepath' => '/', 'filename' => $name, )); } /** * verifies a rewoken object * * checks to make sure it belongs to the same user and session as is currently in use. * * @param boolean $readonly if we're reawakening this for a user to just display in the log view, don't verify the sessionkey * when continuing transfers, you must pass false here. * * @throws portfolio_exception */ public function verify_rewaken($readonly=false) { global $USER, $CFG; if ($this->get('user')->id != $USER->id) { // make sure it belongs to the right user throw new portfolio_exception('notyours', 'portfolio'); } if (!$readonly && $this->get('instance') && !$this->get('instance')->allows_multiple_exports() && ($already = portfolio_existing_exports($this->get('user')->id, $this->get('instance')->get('plugin'))) && array_shift(array_keys($already)) != $this->get('id') ) { $a = (object)array( 'plugin' => $this->get('instance')->get('plugin'), 'link' => $CFG->wwwroot . '/user/portfoliologs.php', ); throw new portfolio_exception('nomultipleexports', 'portfolio', '', $a); } if (!$this->caller->check_permissions()) { // recall the caller permission check throw new portfolio_caller_exception('nopermissions', 'portfolio', $this->caller->get_return_url()); } } /** * copies a file from somewhere else in moodle * to the portfolio temporary working directory * associated with this export * * @param $oldfile stored_file object * @return stored_file new file object */ public function copy_existing_file($oldfile) { if (array_key_exists($oldfile->get_contenthash(), $this->newfilehashes)) { return $this->newfilehashes[$oldfile->get_contenthash()]; } $fs = get_file_storage(); $file_record = $this->new_file_record_base($oldfile->get_filename()); if ($dir = $this->get('format')->get_file_directory()) { $file_record->filepath = '/'. $dir . '/'; } try { $newfile = $fs->create_file_from_storedfile($file_record, $oldfile->get_id()); $this->newfilehashes[$newfile->get_contenthash()] = $newfile; return $newfile; } catch (file_exception $e) { return false; } } /** * writes out some content to a file in the * portfolio temporary working directory * associated with this export * * @param string $content content to write * @param string $name filename to use * @param bool $maifest whether this is the main file or an secondary file (eg attachment) * @return stored_file new file object */ public function write_new_file($content, $name, $manifest=true) { $fs = get_file_storage(); $file_record = $this->new_file_record_base($name); if (empty($manifest) && ($dir = $this->get('format')->get_file_directory())) { $file_record->filepath = '/' . $dir . '/'; } return $fs->create_file_from_string($file_record, $content); } /** * zips all files in the temporary directory * * @param string $filename name of resulting zipfile (optional, defaults to portfolio-export.zip * @param string $filepath subpath in the filearea (optional, defaults to final) * * @return stored_file resulting stored_file object */ public function zip_tempfiles($filename='portfolio-export.zip', $filepath='/final/') { $zipper = new zip_packer(); list ($contextid, $component, $filearea, $itemid) = array_values($this->get_base_filearea()); if ($newfile = $zipper->archive_to_storage($this->get_tempfiles(), $contextid, $component, $filearea, $itemid, $filepath, $filename, $this->user->id)) { return $newfile; } return false; } /** * returns an arary of files in the temporary working directory * for this export * always use this instead of the files api directly * * @return array of stored_file objects keyed by name */ public function get_tempfiles($skipfile='portfolio-export.zip') { $fs = get_file_storage(); $files = $fs->get_area_files(SYSCONTEXTID, 'portfolio', 'exporter', $this->id, '', false); if (empty($files)) { return array(); } $returnfiles = array(); foreach ($files as $f) { if ($f->get_filename() == $skipfile) { continue; } $returnfiles[$f->get_filepath() . '/' . $f->get_filename()] = $f; } return $returnfiles; } /** * returns the context, filearea, and itemid * parts of a filearea (not filepath) to be used by * plugins if they want to do things like zip up the contents of * the temp area to here, or something that can't be done just using * write_new_file, copy_existing_file or get_tempfiles * * @return array contextid, filearea, itemid are the keys. */ public function get_base_filearea() { return array( 'contextid' => SYSCONTEXTID, 'component' => 'portfolio', 'filearea' => 'exporter', 'itemid' => $this->id, ); } /** wrapper function to print a friendly error to users * * this is generally caused by them hitting an expired transfer * through the usage of the backbutton */ public static function print_expired_export() { global $CFG, $OUTPUT, $PAGE; $title = get_string('exportexpired', 'portfolio'); $PAGE->navbar->add(get_string('exportexpired', 'portfolio')); $PAGE->set_title($title); $PAGE->set_heading($title); echo $OUTPUT->header(); echo $OUTPUT->notification(get_string('exportexpireddesc', 'portfolio')); echo $OUTPUT->continue_button($CFG->wwwroot); echo $OUTPUT->footer(); exit; } public static function print_cleaned_export($log, $instance=null) { global $CFG, $OUTPUT, $PAGE; if (empty($instance) || !$instance instanceof portfolio_plugin_base) { $instance = portfolio_instance($log->portfolio); } $title = get_string('exportalreadyfinished', 'portfolio'); $PAGE->navbar->add($title); $PAGE->set_title($title); $PAGE->set_heading($title); echo $OUTPUT->header(); echo $OUTPUT->notification(get_string('exportalreadyfinished', 'portfolio')); self::print_finish_info($log->returnurl, $instance->resolve_static_continue_url($log->continueurl)); echo $OUTPUT->continue_button($CFG->wwwroot); echo $OUTPUT->footer(); exit; } public static function print_finish_info($returnurl, $continueurl, $extras=null) { if ($returnurl) { echo '' . get_string('returntowhereyouwere', 'portfolio') . '
'; } if ($continueurl) { echo '' . get_string('continuetoportfolio', 'portfolio') . '
'; } if (is_array($extras)) { foreach ($extras as $link => $string) { echo '' . $string . '
'; } } } }