<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[6596] sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins: WordCamp Utilities: Add classes from WordCamp Reports plugin.</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="http://meta.trac.wordpress.org/changeset/6596">6596</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"http://meta.trac.wordpress.org/changeset/6596","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>iandunn</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2018-02-12 17:35:07 +0000 (Mon, 12 Feb 2018)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>WordCamp Utilities: Add classes from WordCamp Reports plugin.

These are generic enough that they can be reused across plugins.

props coreymckrill</pre>

<h3>Added Paths</h3>
<ul>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentmuplugins1autoloaderphp">sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/1-autoloader.php</a></li>
<li>sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/</li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclasscurrencyxrtclientphp">sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-currency-xrt-client.php</a></li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclassexportcsvphp">sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-export-csv.php</a></li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclassmeetupclientphp">sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-meetup-client.php</a></li>
<li><a href="#sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclassqboclientphp">sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-qbo-client.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentmuplugins1autoloaderphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/1-autoloader.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/1-autoloader.php                               (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/1-autoloader.php 2018-02-12 17:35:07 UTC (rev 6596)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,22 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+
+namespace WordCamp\Autoloader;
+defined( 'WPINC' ) or die();
+
+spl_autoload_register( __NAMESPACE__ . '\autoload' );
+
+/**
+ * Autoload Utility classes
+ *
+ * @param string $class_name
+ */
+function autoload( $class_name ) {
+       if ( false === strpos( $class_name, 'WordCamp\Utilities' ) ) {
+               return;
+       }
+
+       $file_name = str_replace( 'WordCamp\Utilities\\', '', $class_name );
+       $file_name = str_replace( '_', '-', strtolower( $file_name ) );
+
+       require_once( sprintf( '%s/utilities/class-%s.php', __DIR__, $file_name ) );
+}
</ins></span></pre></div>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclasscurrencyxrtclientphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-currency-xrt-client.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-currency-xrt-client.php                                (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-currency-xrt-client.php  2018-02-12 17:35:07 UTC (rev 6596)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,245 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+
+namespace WordCamp\Utilities;
+defined( 'WPINC' ) || die();
+
+/**
+ * Class Currency_XRT_Client
+ *
+ * Get historical exchange rates for the major currencies. Designed to be able to use different API sources for
+ * the exchange rate data. Initially built using the Fixer API (fixer.io). "XRT" = "exchange rate".
+ */
+class Currency_XRT_Client {
+       /**
+        * @var \WP_Error|null Container for errors.
+        */
+       public $error = null;
+
+       /**
+        * @var string Currency symbol for the base currency.
+        */
+       protected $base_currency = '';
+
+       /**
+        * @var string Identifier for the exchange rates source.
+        */
+       protected $source = '';
+
+       /**
+        * @var string Base URL for the source's API.
+        */
+       protected $api_base = '';
+
+       /**
+        * @var array Cache of exchange rates by date for reuse.
+        */
+       protected $cache = array();
+
+       /**
+        * Currency_XRT_Client constructor.
+        *
+        * @param string $base_currency Optional. Currency symbol for the base currency. Default 'USD'.
+        * @param string $source        Optional. Identifier for the exchange rates source. Default 'fixer'.
+        */
+       public function __construct( $base_currency = 'USD', $source = 'fixer' ) {
+               $this->error = new \WP_Error();
+
+               $this->base_currency = $base_currency;
+               $this->source        = $source;
+               $this->api_base      = $this->get_api_base( $this->source );
+       }
+
+       /**
+        * Get the API base URL based on the given source.
+        *
+        * @param string $source
+        *
+        * @return string The API base URL.
+        */
+       protected function get_api_base( $source ) {
+               $base_url = '';
+
+               switch ( $source ) {
+                       case 'fixer' :
+                       default :
+                               $base_url = 'https://api.fixer.io/';
+                               break;
+               }
+
+               return trailingslashit( $base_url );
+       }
+
+       /**
+        * Get the currency exchange rates for a particular date.
+        *
+        * @param string $date The date to retrieve the rates for.
+        *
+        * @return array|\WP_Error An array of rates, or an error.
+        */
+       public function get_rates( $date ) {
+               $rates = array();
+
+               try {
+                       $date = new \DateTime( $date );
+               } catch ( \Exception $e ) {
+                       $this->error->add( $e->getCode(), $e->getMessage() );
+
+                       return $this->error;
+               }
+
+               $cached_rates = $this->get_cached_rates( $date );
+
+               if ( false !== $cached_rates ) {
+                       return $cached_rates;
+               }
+
+               switch ( $this->source ) {
+                       case 'fixer' :
+                       default :
+                               $rates = $this->send_fixer_request( $date );
+                               break;
+               }
+
+               if ( is_wp_error( $rates ) ) {
+                       return $rates;
+               }
+
+               $rates = array_map( 'floatval', $rates );
+
+               $this->cache_rates( $date, $rates );
+
+               return $rates;
+       }
+
+       /**
+        * Convert an amount in a given currency to an amount in the base currency using
+        * a particular date's rates.
+        *
+        * @param float  $amount        The amount to convert.
+        * @param string $from_currency The currency to convert from.
+        * @param string $date          The date to get the rate for.
+        *
+        * @return object|\WP_Error An object with properties for the beginning and ending currencies,
+        *                          each with a float value. Or an error.
+        */
+       public function convert( $amount, $from_currency, $date = '' ) {
+               if ( ! $date ) {
+                       $date = 'now';
+               }
+
+               $amount = floatval( $amount );
+
+               $rates = $this->get_rates( $date );
+
+               if ( is_wp_error( $rates ) ) {
+                       return $rates;
+               }
+
+               if ( ! isset( $rates[ $from_currency ] ) ) {
+                       $this->error->add(
+                               'unknown_currency',
+                               sprintf(
+                                       '%s is not an available currency to convert from.',
+                                       esc_html( $from_currency )
+                               )
+                       );
+
+                       return $this->error;
+               }
+
+               $rate = $rates[ $from_currency ];
+
+               try {
+                       $converted_amount = $amount / $rate;
+               } catch ( \Exception $e ) {
+                       $this->error->add(
+                               $e->getCode(),
+                               $e->getMessage()
+                       );
+
+                       return $this->error;
+               }
+
+               return (object) [
+                       $from_currency       => $amount,
+                       $this->base_currency => $converted_amount,
+               ];
+       }
+
+       /**
+        * Send a request to the Fixer API and return the results.
+        *
+        * @param \DateTime $date The date to retrieve rates for.
+        *
+        * @return array|\WP_Error An array of rates, or an error.
+        */
+       protected function send_fixer_request( \DateTime $date ) {
+               $data = array();
+
+               $request_url = add_query_arg( array(
+                       'base' => $this->base_currency,
+               ), $this->api_base . $date->format( 'Y-m-d' ) );
+
+               $response      = wcorg_redundant_remote_get( $request_url );
+               $response_code = wp_remote_retrieve_response_code( $response );
+               $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+               if ( 200 === $response_code ) {
+                       if ( isset( $response_body['rates'] ) ) {
+                               $data = $response_body['rates'];
+                       } elseif ( isset( $response_body['error'] ) ) {
+                               $this->error->add(
+                                       'request_error',
+                                       $response_body['error']
+                               );
+                       } else {
+                               $this->error->add(
+                                       'unexpected_response_data',
+                                       'The API response did not provide the expected data.'
+                               );
+                       }
+               } else {
+                       $this->error->add(
+                               'http_response_code',
+                               $response_code . ': ' . print_r( $response_body, true )
+                       );
+               }
+
+               if ( ! empty( $this->error->get_error_messages() ) ) {
+                       return $this->error;
+               }
+
+               return $data;
+       }
+
+       /**
+        * Check for cached currency exchange rates for a particular date and return them if available.
+        *
+        * @todo Add object and/or database caching.
+        *
+        * @param \DateTime $date The date to retrieve rates for.
+        *
+        * @return array|bool
+        */
+       protected function get_cached_rates( \DateTime $date ) {
+               if ( isset( $this->cache[ $date->format( 'Y-m-d' ) ] ) ) {
+                       return $this->cache[ $date->format( 'Y-m-d' ) ];
+               }
+
+               return false;
+       }
+
+       /**
+        * Cache the currency exchange rates for a particular date.
+        *
+        * @todo Add object and/or database caching.
+        *
+        * @param \DateTime $date The date of the rates to be cached.
+        * @param array $rates    The rates to be cached.
+        *
+        * @return void
+        */
+       protected function cache_rates( \DateTime $date, $rates ) {
+               $this->cache[ $date->format( 'Y-m-d' ) ] = $rates;
+       }
+}
</ins></span></pre></div>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclassexportcsvphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-export-csv.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-export-csv.php                         (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-export-csv.php   2018-02-12 17:35:07 UTC (rev 6596)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,295 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+
+namespace WordCamp\Utilities;
+defined( 'WPINC' ) || die();
+
+/**
+ * Class Export_CSV
+ */
+class Export_CSV {
+       /**
+        * @var string The name of the CSV file.
+        */
+       protected $filename = '';
+
+       /**
+        * @var array The column headers for the CSV file.
+        */
+       protected $header_row = array();
+
+       /**
+        * @var array The data rows for the CSV file.
+        */
+       protected $data_rows = array();
+
+       /**
+        * @var \WP_Error|null Container for errors.
+        */
+       public $error = null;
+
+
+       public function __construct( array $options = array() ) {
+               $this->error = new \WP_Error();
+
+               $options = wp_parse_args( $options, array(
+                       'filename' => array(),
+                       'headers'  => array(),
+                       'data'     => array(),
+               ) );
+
+               if ( ! empty( $options['filename'] ) ) {
+                       $this->set_filename( $options['filename'] );
+               }
+
+               if ( ! empty( $options['headers'] ) ) {
+                       $this->set_column_headers( $options['headers'] );
+               }
+
+               if ( ! empty( $options['data'] ) ) {
+                       $this->add_data_rows( $options['data'] );
+               }
+       }
+
+       /**
+        * Specify the name for the CSV file.
+        *
+        * This method takes an array of string segments that will be concatenated into a single file name string.
+        * It is not necessary to include the file name suffix (.csv).
+        *
+        * Example:
+        *
+        *   array( 'Payment Activity', '2017-01-01', '2017-12-31' )
+        *
+        *   will become:
+        *
+        *   payment-activity_2017-01-01_2017-12-31.csv
+        *
+        * @param array|string $name_segments One or more string segments that will comprise the CSV file name.
+        *
+        * @return bool True if the file name was successfully set. Otherwise false.
+        */
+       public function set_filename( $name_segments ) {
+               if ( ! is_array( $name_segments ) ) {
+                       $name_segments = (array) $name_segments;
+               }
+
+               $name_segments = array_map( function( $segment ) {
+                       $segment = strtolower( $segment );
+                       $segment = str_replace( '_', '-', $segment );
+                       $segment = sanitize_file_name( $segment );
+                       $segment = str_replace( '.csv', '', $segment );
+
+                       return $segment;
+               }, $name_segments );
+
+               if ( ! empty( $name_segments ) ) {
+                       $this->filename = implode( '_', $name_segments ) . '.csv';
+
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Set the first row of the CSV file as headers for each column.
+        *
+        * If used, this also determines how many columns each row should have. Note that, while optional, this method
+        * must be used before data rows are added.
+        *
+        * @param array $headers The column header strings.
+        *
+        * @return bool True if the column headers were successfully set. Otherwise false.
+        */
+       public function set_column_headers( array $headers ) {
+               if ( ! empty( $this->data_rows ) ) {
+                       $this->error->add(
+                               'csv_error',
+                               'Column headers cannot be set after data rows have been added.'
+                       );
+
+                       return false;
+               }
+
+               $this->header_row = array_map( 'sanitize_text_field', $headers );
+
+               return true;
+       }
+
+       /**
+        * Add a single row of data to the CSV file.
+        *
+        * @param array $row A single row of data.
+        *
+        * @return bool True if the data row was successfully added. Otherwise false.
+        */
+       public function add_row( array $row ) {
+               $column_count = 0;
+
+               if ( ! empty( $this->header_row ) ) {
+                       $column_count = count( $this->header_row );
+               } elseif ( ! empty( $this->data_rows ) ) {
+                       $column_count = count( $this->data_rows[0] );
+               }
+
+               if ( $column_count && count( $row ) !== $column_count ) {
+                       $this->error->add(
+                               'csv_error',
+                               sprintf(
+                                       'Could not add row because it has %d columns, when it should have %d.',
+                                       absint( count( $row ) ),
+                                       absint( $column_count )
+                               )
+                       );
+
+                       return false;
+               }
+
+               $this->data_rows[] = array_map( 'sanitize_text_field', $row );
+
+               return true;
+       }
+
+       /**
+        * Wrapper method for adding multiple data rows at once.
+        *
+        * @param array $data
+        *
+        * @return void
+        */
+       public function add_data_rows( array $data ) {
+               foreach ( $data as $row ) {
+                       $result = $this->add_row( $row );
+
+                       if ( ! $result ) {
+                               break;
+                       }
+               }
+       }
+
+       /**
+        * Escape an array of strings to be used in a CSV context.
+        *
+        * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks,
+        * information disclosure, and arbitrary command execution.
+        *
+        * @see http://www.contextis.com/resources/blog/comma-separated-vulnerabilities/
+        * @see https://hackerone.com/reports/72785
+        *
+        * Derived from CampTix_Plugin::esc_csv.
+        *
+        * Note that this method is not recursive, so should only be used for individual data rows, not an entire data set.
+        *
+        * @param array $fields
+        *
+        * @return array
+        */
+       public static function esc_csv( array $fields ) {
+               $active_content_triggers = array( '=', '+', '-', '@' );
+
+               /*
+                * Formulas that follow all common delimiters need to be escaped, because the user may choose any delimiter
+                * when importing a file into their spreadsheet program. Different delimiters are also used as the default
+                * in different locales. For example, Windows + Russian uses `;` as the delimiter, rather than a `,`.
+                *
+                * The file encoding can also effect the behavior; e.g., opening/importing as UTF-8 will enable newline
+                * characters as delimiters.
+                */
+               $delimiters = array(
+                       ',', ';', ':', '|', '^',
+                       "\n", "\t", " "
+               );
+
+               foreach( $fields as $index => $field ) {
+                       // Escape trigger characters at the start of a new field
+                       $first_cell_character = mb_substr( $field, 0, 1 );
+                       $is_trigger_character = in_array( $first_cell_character, $active_content_triggers, true );
+                       $is_delimiter         = in_array( $first_cell_character, $delimiters,              true );
+
+                       if ( $is_trigger_character || $is_delimiter ) {
+                               $field = "'" . $field;
+                       }
+
+                       // Escape trigger characters that follow delimiters
+                       foreach ( $delimiters as $delimiter ) {
+                               foreach ( $active_content_triggers as $trigger ) {
+                                       $field = str_replace( $delimiter . $trigger, $delimiter . "'" . $trigger, $field );
+                               }
+                       }
+
+                       $fields[ $index ] = $field;
+               }
+
+               return $fields;
+       }
+
+       /**
+        * Generate the contents of the CSV file.
+        *
+        * @return string
+        */
+       protected function generate_file_content() {
+               if ( empty( $this->data_rows ) ) {
+                       $this->error->add(
+                               'csv_error',
+                               'No data.'
+                       );
+
+                       return '';
+               }
+
+               ob_start();
+
+               $csv = fopen( 'php://output', 'w' );
+
+               if ( ! empty( $this->header_row ) ) {
+                       fputcsv( $csv, self::esc_csv( $this->header_row ) );
+               }
+
+               foreach ( $this->data_rows as $row ) {
+                       fputcsv( $csv, self::esc_csv( $row ) );
+               }
+
+               fclose( $csv );
+
+               return ob_get_clean();
+       }
+
+       /**
+        * Output the CSV file, or a text file with error messages.
+        */
+       public function emit_file() {
+               if ( ! $this->filename ) {
+                       $this->error->add(
+                               'csv_error',
+                               'Could not generate a CSV file without a file name.'
+                       );
+               }
+
+               $content = $this->generate_file_content();
+
+               header( 'Cache-control: private' );
+               header( 'Pragma: private' );
+               header( 'Expires: Mon, 26 Jul 1997 05:00:00 GMT' ); // As seen in CampTix_Plugin::summarize_admin_init.
+
+               if ( ! empty( $this->error->get_error_messages() ) ) {
+                       header( 'Content-Type: text' );
+                       header( 'Content-Disposition: attachment; filename="error.txt"' );
+
+                       foreach ( $this->error->get_error_codes() as $code ) {
+                               foreach ( $this->error->get_error_messages( $code ) as $message ) {
+                                       echo "$code: $message\n";
+                               }
+                       }
+
+                       die();
+               }
+
+               header( 'Content-Type: text/csv' );
+               header( sprintf( 'Content-Disposition: attachment; filename="%s"', sanitize_file_name( $this->filename ) ) );
+
+               echo $content;
+
+               die();
+       }
+}
</ins></span></pre></div>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclassmeetupclientphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-meetup-client.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-meetup-client.php                              (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-meetup-client.php        2018-02-12 17:35:07 UTC (rev 6596)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,326 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+
+namespace WordCamp\Utilities;
+defined( 'WPINC' ) || die();
+
+/**
+ * Class Meetup_Client
+ */
+class Meetup_Client {
+       /**
+        * @var string The base URL for the API endpoints.
+        */
+       protected $api_base = 'https://api.meetup.com/';
+
+       /**
+        * @var string The API key.
+        */
+       protected $api_key = '';
+
+       /**
+        * @var \WP_Error|null Container for errors.
+        */
+       public $error = null;
+
+       /**
+        * Meetup_Client constructor.
+        */
+       public function __construct() {
+               $this->error = new \WP_Error();
+
+               if ( defined( 'MEETUP_API_KEY' ) ) {
+                       $this->api_key = MEETUP_API_KEY;
+               } else {
+                       $this->error->add(
+                               'api_key_undefined',
+                               'The Meetup.com API Key is undefined.'
+                       );
+               }
+       }
+
+       /**
+        * Send a paginated request to the Meetup API and return the aggregated response.
+        *
+        * This automatically paginates requests and will repeat requests to ensure all results are retrieved.
+        * It also tries to account for API request limits and throttles to avoid getting a limit error.
+        *
+        * @param string $request_url The API endpoint URL to send the request to.
+        *
+        * @return array|\WP_Error The results of the request.
+        */
+       protected function send_paginated_request( $request_url ) {
+               $data = array();
+
+               $request_url = add_query_arg( array(
+                       'page' => 200,
+               ), $request_url );
+
+               while ( $request_url ) {
+                       $request_url = $this->sign_request_url( $request_url );
+
+                       $response = wcorg_redundant_remote_get( $request_url );
+
+                       if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
+                               $body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+                               if ( isset( $body['results'] ) ) {
+                                       $new_data = $body['results'];
+                               } else {
+                                       $new_data = $body;
+                               }
+
+                               if ( is_array( $new_data ) ) {
+                                       $data = array_merge( $data, $new_data );
+                               } else {
+                                       $this->error->add(
+                                               'unexpected_response_data',
+                                               'The API response did not provide the expected data format.'
+                                       );
+                                       break;
+                               }
+
+                               $request_url = $this->get_next_url( $response );
+                       } else {
+                               $this->handle_error_response( $response );
+                               break;
+                       }
+
+                       if ( $request_url ) {
+                               $this->maybe_throttle( $response );
+                       }
+               }
+
+               if ( ! empty( $this->error->get_error_messages() ) ) {
+                       return $this->error;
+               }
+
+               return $data;
+       }
+
+       /**
+        * Send a single request to the Meetup API and return the total number of results available.
+        *
+        * @param string $request_url The API endpoint URL to send the request to.
+        *
+        * @return int|\WP_Error
+        */
+       protected function send_total_count_request( $request_url ) {
+               $count = 0;
+
+               $request_url = add_query_arg( array(
+                       // We're only interested in the headers, so we don't need to receive more than one result.
+                       'page' => 1,
+               ), $request_url );
+
+               $request_url = $this->sign_request_url( $request_url );
+
+               $response = wcorg_redundant_remote_get( $request_url );
+
+               if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
+                       $count_header = wp_remote_retrieve_header( $response, 'X-Total-Count' );
+
+                       if ( $count_header ) {
+                               $count = absint( $count_header );
+                       } else {
+                               $this->error->add(
+                                       'unexpected_response_data',
+                                       'The API response did not provide a total count value.'
+                               );
+                       }
+               } else {
+                       $this->handle_error_response( $response );
+               }
+
+               $this->maybe_throttle( $response );
+
+               if ( ! empty( $this->error->get_error_messages() ) ) {
+                       return $this->error;
+               }
+
+               return $count;
+       }
+
+       /**
+        * Sign a request URL with our API key.
+        *
+        * @param string $request_url
+        *
+        * @return string
+        */
+       protected function sign_request_url( $request_url ) {
+               return add_query_arg( array(
+                       'sign' => true,
+                       'key'  => $this->api_key,
+               ), $request_url );
+       }
+
+       /**
+        * Get the URL for the next page of results from a paginated API response.
+        *
+        * @param array $response
+        *
+        * @return string
+        */
+       protected function get_next_url( $response ) {
+               $url   = '';
+
+               // First try v3.
+               $links = wp_remote_retrieve_header( $response, 'link' );
+               if ( $links ) {
+                       foreach ( (array) $links as $link ) {
+                               if ( false !== strpos( $link, 'rel="next"' ) && preg_match( '/^<([^>]+)>/', $link, $matches ) ) {
+                                       $url = $matches[1];
+                                       break;
+                               }
+                       }
+               }
+
+               // Then try v2.
+               if ( ! $url ) {
+                       $body = json_decode( wp_remote_retrieve_body( $response ), true );
+
+                       if ( isset( $body['meta']['next'] ) ) {
+                               $url = $body['meta']['next'];
+                       }
+               }
+
+               return esc_url_raw( $url );
+       }
+
+       /**
+        * Check the rate limit status in an API response and delay further execution if necessary.
+        *
+        * @param array $response
+        *
+        * @return void
+        */
+       protected function maybe_throttle( $response ) {
+               $remaining = absint( wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' ) );
+               $period    = absint( wp_remote_retrieve_header( $response, 'x-ratelimit-reset' ) );
+
+               if ( $remaining > 2 ) {
+                       return;
+               }
+
+               if ( $period < 2 ) {
+                       $period = 2;
+               }
+
+               sleep( $period );
+       }
+
+       /**
+        * Extract error information from an API response and add it to our error handler.
+        *
+        * @param array $response
+        *
+        * @return void
+        */
+       protected function handle_error_response( $response ) {
+               $data = json_decode( wp_remote_retrieve_body( $response ), true );
+
+               if ( isset( $data['errors'] ) ) {
+                       foreach ( $data['errors'] as $error ) {
+                               $this->error->add( $error['code'], $error['message'] );
+                       }
+               } elseif ( isset( $data['code'] ) && isset( $data['details'] ) ) {
+                       $this->error->add( $data['code'], $data['details'] );
+               } else {
+                       $this->error->add(
+                               'http_response_code',
+                               wp_remote_retrieve_response_code( $response ) . ': ' . print_r( $data, true )
+                       );
+               }
+       }
+
+       /**
+        * Retrieve data about groups in the Chapter program.
+        *
+        * @param array $args Optional. Additional request parameters.
+        *                    See https://www.meetup.com/meetup_api/docs/pro/:urlname/groups/
+        *
+        * @return array|\WP_Error
+        */
+       public function get_groups( array $args = array() ) {
+               $request_url = $this->api_base . 'pro/wordpress/groups';
+
+               if ( ! empty( $args ) ) {
+                       $request_url = add_query_arg( $args, $request_url );
+               }
+
+               return $this->send_paginated_request( $request_url );
+       }
+
+       /**
+        * Retrieve data about events associated with a set of groups.
+        *
+        * This automatically breaks up requests into chunks of 50 groups to avoid overloading the API.
+        *
+        * @param array $group_ids The IDs of the groups to get events for.
+        * @param array $args      Optional. Additional request parameters.
+        *                         See https://www.meetup.com/meetup_api/docs/2/events/
+        *
+        * @return array|\WP_Error
+        */
+       public function get_events( array $group_ids, array $args = array() ) {
+               $url_base     = $this->api_base . '2/events';
+               $group_chunks = array_chunk( $group_ids, 50, true ); // Meetup API sometimes throws an error with chunk size larger than 50.
+               $events       = array();
+
+               foreach ( $group_chunks as $chunk ) {
+                       $query_args = array_merge( array(
+                               'group_id' => implode( ',', $chunk ),
+                       ), $args );
+
+                       $request_url = add_query_arg( $query_args, $url_base );
+
+                       $data = $this->send_paginated_request( $request_url );
+
+                       if ( is_wp_error( $data ) ) {
+                               return $data;
+                       }
+
+                       $events = array_merge( $events, $data );
+               }
+
+               return $events;
+       }
+
+       /**
+        * Retrieve data about events associated with one particular group.
+        *
+        * @param string $group_slug The slug/urlname of a group.
+        * @param array $args        Optional. Additional request parameters.
+        *                           See https://www.meetup.com/meetup_api/docs/:urlname/events/
+        *
+        * @return array|\WP_Error
+        */
+       public function get_group_events( $group_slug, array $args = array() ) {
+               $request_url = $this->api_base . "$group_slug/events";
+
+               if ( ! empty( $args ) ) {
+                       $request_url = add_query_arg( $args, $request_url );
+               }
+
+               return $this->send_paginated_request( $request_url );
+       }
+
+       /**
+        * Find out how many results are available for a particular request.
+        *
+        * @param string $route The Meetup.com API route to send a request to.
+        * @param array  $args  Optional. Additional request parameters.
+        *                      See https://www.meetup.com/meetup_api/docs/
+        *
+        * @return int|\WP_Error
+        */
+       public function get_result_count( $route, array $args = array() ) {
+               $request_url = $this->api_base . $route;
+
+               if ( ! empty( $args ) ) {
+                       $request_url = add_query_arg( $args, $request_url );
+               }
+
+               return $this->send_total_count_request( $request_url );
+       }
+}
</ins></span></pre></div>
<a id="sitestrunkwordcamporgpublic_htmlwpcontentmupluginsutilitiesclassqboclientphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-qbo-client.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-qbo-client.php                         (rev 0)
+++ sites/trunk/wordcamp.org/public_html/wp-content/mu-plugins/utilities/class-qbo-client.php   2018-02-12 17:35:07 UTC (rev 6596)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,359 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+
+namespace WordCamp\Utilities;
+defined( 'WPINC' ) || die();
+
+/**
+ * Class QBO_Client
+ *
+ * This is a general purpose client, whereas the one in `plugins/wordcamp-qbo-client` is specific to the WordCamp
+ * Payments plugin. Eventually, we should probably merge the two into a single general purpose client, rather
+ * than having multiple.
+ */
+class QBO_Client {
+       /**
+        * @var bool True if in Sandbox mode.
+        */
+       protected $sandbox_mode = true;
+
+       /**
+        * @var string The base URL for the API endpoints.
+        */
+       protected $api_base = 'https://sandbox-quickbooks.api.intuit.com';
+
+       /**
+        * @var int Number of seconds to wait during a request.
+        */
+       protected $request_timeout = 45; //seconds
+
+       /**
+        * @var \WordCamp_QBO_OAuth_Client|null OAuth client from the WordCamp QBO plugin.
+        */
+       protected $oauth = null;
+
+       /**
+        * @var array QBO options configured by the environment.
+        */
+       protected $qbo_options = array();
+
+       /**
+        * @var array OAuth options that were set by linking the OAuth client to the QBO account.
+        */
+       protected $oauth_options = array();
+
+       /**
+        * @var \WP_Error|null Container for errors.
+        */
+       public $error = null;
+
+       /**
+        * QBO_Client constructor.
+        */
+       public function __construct() {
+               $this->error = new \WP_Error();
+
+               if ( defined( 'WORDCAMP_ENVIRONMENT' ) && 'production' === WORDCAMP_ENVIRONMENT ) {
+                       $this->sandbox_mode = false;
+               }
+
+               if ( ! $this->sandbox_mode ) {
+                       $this->api_base = 'https://quickbooks.api.intuit.com';
+               }
+
+               $this->get_oauth();
+       }
+
+       /**
+        * Get an instance of the OAuth client from the WordCamp.org QBO Integration plugin.
+        *
+        * @return \WordCamp_QBO_OAuth_Client
+        */
+       protected function get_oauth() {
+               if ( ! is_null( $this->oauth ) ) {
+                       return $this->oauth;
+               }
+
+               $qbo_options   = $this->get_qbo_options();
+               $oauth_options = $this->get_oauth_options();
+
+               require_once( WP_PLUGIN_DIR . '/wordcamp-qbo/class-wordcamp-qbo-oauth-client.php' );
+
+               $this->oauth = new \WordCamp_QBO_OAuth_Client( $qbo_options['consumer_key'], $qbo_options['consumer_secret'] );
+
+               $this->oauth->set_token( $oauth_options['auth']['oauth_token'], $oauth_options['auth']['oauth_token_secret'] );
+
+               return $this->oauth;
+       }
+
+       /**
+        * Get the QBO options configured by the environment.
+        *
+        * The options should be set using the `wordcamp_qbo_options` filter.
+        *
+        * @return array
+        */
+       protected function get_qbo_options() {
+               if ( ! empty( $this->qbo_options ) ) {
+                       return $this->qbo_options;
+               }
+
+               $defaults = array(
+                       'app_token'       => '',
+                       'consumer_key'    => '',
+                       'consumer_secret' => '',
+                       'hmac_key'        => '',
+               );
+
+               $this->qbo_options = wp_parse_args( apply_filters( 'wordcamp_qbo_options', array() ), $defaults );
+
+               if ( ! empty( array_intersect_assoc( $defaults, $this->qbo_options ) ) ) {
+                       $this->error->add(
+                               'qbo_options_not_set',
+                               'The QBO options are not correctly set.'
+                       );
+               }
+
+               return $this->qbo_options;
+       }
+
+       /**
+        * Get the OAuth options set when the client was linked to the QBO account.
+        *
+        * @return array
+        */
+       protected function get_oauth_options() {
+               if ( ! empty( $this->oauth_options ) ) {
+                       return $this->oauth_options;
+               }
+
+               $defaults = array(
+                       'auth' => array(),
+               );
+
+               $auth_defaults = array(
+                       'oauth_token'        => '',
+                       'oauth_token_secret' => '',
+                       'realmId'            => '',
+                       'name'               => '',
+                       'timestamp'          => 0,
+               );
+
+               $this->oauth_options = wp_parse_args( get_option( 'wordcamp-qbo', array() ), $defaults );
+               $this->oauth_options['auth'] = wp_parse_args( $this->oauth_options['auth'], $auth_defaults );
+
+               if ( ! empty( array_intersect_assoc( $auth_defaults, $this->oauth_options['auth'] ) ) ) {
+                       $this->error->add(
+                               'qbo_account_not_linked',
+                               'The QBO client has not been linked to the WPCS QBO account.'
+                       );
+               }
+
+               return $this->oauth_options;
+       }
+
+       /**
+        * Send a GET request to the QBO API.
+        *
+        * @param string $url  The complete URL of the API endpoint.
+        * @param array  $args Optional. Additional arguments for the API request.
+        *
+        * @return array|mixed|object
+        */
+       protected function send_get_request( $url, $args = array() ) {
+               $encoded_args = array_map( 'rawurlencode', $args );
+               $request_url  = add_query_arg( $encoded_args, $url );
+
+               $auth_header  = $this->oauth->get_oauth_header( 'GET', $url, $args );
+               $request_args = array(
+                       'timeout' => $this->request_timeout,
+                       'headers' => array(
+                               'Authorization' => $auth_header,
+                               'Accept'        => 'application/json',
+                       ),
+               );
+
+               $response = wcorg_redundant_remote_get( $request_url, $request_args );
+
+               if ( is_wp_error( $response ) ) {
+                       foreach ( $response->get_error_codes() as $code ) {
+                               foreach ( $response->get_error_messages( $code ) as $message ) {
+                                       $this->error->add( $code, $message );
+                               }
+                       }
+
+                       return array();
+               }
+
+               if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
+                       $this->error->add(
+                               'invalid_http_code',
+                               sprintf(
+                                       'Invalid HTTP response code: %d',
+                                       wp_remote_retrieve_response_code( $response )
+                               )
+                       );
+
+                       return array();
+               }
+
+               return json_decode( wp_remote_retrieve_body( $response ), true );
+       }
+
+       /**
+        * @param $response
+        *
+        * @return bool
+        */
+       protected function validate_get_response( $response ) {
+               if ( ! isset( $response['QueryResponse'] ) ) {
+                       $this->error->add(
+                               'empty_response',
+                               'The GET request returned an empty response.'
+                       );
+
+                       return false;
+               }
+
+               if ( isset( $response['QueryResponse']['Fault'] ) ) {
+                       foreach ( $response['QueryResponse']['Fault'] as $error ) {
+                               $this->error->add(
+                                       'response_fault',
+                                       esc_html( $error['code'] . ': ' . $error['Message'] )
+                               );
+                       }
+
+                       return false;
+               }
+
+               return true;
+       }
+
+       /**
+        * Retrieve transactions from QBO.
+        *
+        * Supports making multiple requests if the results are paginated.
+        *
+        * @param string $type         The type of transactions to retrieve. Possible values: 'invoice', 'payment'.
+        * @param array  $filter       Optional. One or more WHERE clauses that will be joined together with AND. Specific
+        *                             values should be represented by placeholder tokens supported by $wpdb->prepare().
+        * @param array  $filter_input Optional. The values that will replace the placeholder tokens.
+        *
+        * @return array|\WP_Error
+        */
+       protected function get_transactions( $type, array $filter = array(), array $filter_input = array() ) {
+               $allowed_types = array(
+                       // Type => Fields to select.
+                       'Invoice' => 'Id, TxnDate, CurrencyRef, LinkedTxn, TotalAmt, Balance',
+                       'Payment' => 'Id, TxnDate, CurrencyRef, Line, TotalAmt, UnappliedAmt',
+               );
+
+               if ( ! array_key_exists( $type, $allowed_types ) ) {
+                       $this->error->add(
+                               'invalid_transaction_type',
+                               sprintf(
+                                       '%s is not a valid transaction type.',
+                                       esc_html( $type )
+                               )
+                       );
+               }
+
+               if ( ! empty( $this->error->get_error_messages() ) ) {
+                       return $this->error;
+               }
+
+               /** @var \wpdb $wpdb */
+               global $wpdb;
+
+               $url = sprintf(
+                       '%s/v3/company/%d/query',
+                       $this->api_base,
+                       rawurlencode( $this->oauth_options['auth']['realmId'] )
+               );
+
+               // Build query elements.
+               $select_count  = 'SELECT count(*)';
+               $select_fields = 'SELECT ' . $allowed_types[ $type ];
+               $from          = 'FROM ' . $type;
+               $where         = '';
+
+               if ( ! empty( $filter ) ) {
+                       $where = 'WHERE ' . implode( ' AND ', $filter );
+               }
+
+               // First send an initial request to get the total number of items available.
+               $count_query = $wpdb->prepare(
+                       "$select_count $from $where",
+                       $filter_input
+               );
+
+               $response = $this->send_get_request( $url, array( 'query' => $count_query ) );
+
+               if ( ! $this->validate_get_response( $response ) ) {
+                       return $this->error;
+               }
+
+               // Then send paginated requests until all of the items have been retrieved.
+               // See https://developer.intuit.com/docs/0100_quickbooks_online/0300_references/0000_programming_guide/0050_data_queries#/Maximum_number_of_entities_in_a_response
+               $data           = array();
+               $max_results    = 1000;
+               $pages          = ceil( $response['QueryResponse']['totalCount'] / $max_results );
+               $page           = 1;
+               $start_position = 1;
+
+               while ( $page <= $pages ) {
+                       $page_query = $wpdb->prepare(
+                               "$select_fields $from $where STARTPOSITION $start_position MAXRESULTS $max_results",
+                               $filter_input
+                       );
+
+                       $response = $this->send_get_request( $url, array( 'query' => $page_query ) );
+
+                       if ( ! $this->validate_get_response( $response ) ) {
+                               return $this->error;
+                       }
+
+                       $data = array_merge( $data, $response['QueryResponse'][ $type ] );
+
+                       $page++;
+                       $start_position += $max_results;
+               }
+
+               return $data;
+       }
+
+       /**
+        * A wrapper method for retrieving transactions occurring during a specific period of time.
+        *
+        * @param string    $type       The type of transactions to retrieve. Possible values: 'invoice', 'payment'.
+        * @param \DateTime $start_date The beginning of the date range.
+        * @param \DateTime $end_date   The end of the date range.
+        *
+        * @return array|\WP_Error
+        */
+       public function get_transactions_by_date( $type, \DateTime $start_date, \DateTime $end_date ) {
+               $filter = array(
+                       'TxnDate >= %s',
+                       'TxnDate <= %s',
+               );
+
+               return $this->get_transactions( $type, $filter, array( $start_date->format( 'Y-m-d' ), $end_date->format( 'Y-m-d' ) ) );
+       }
+
+       /**
+        * A wrapper method for retrieving specific transactions based on their IDs.
+        *
+        * @param string $type    The type of transactions to retrieve. Possible values: 'invoice', 'payment'.
+        * @param array  $txn_ids A list of transaction IDs.
+        *
+        * @return array|\WP_Error
+        */
+       public function get_transactions_by_id( $type, array $txn_ids ) {
+               // IDs are initially cast as integers for validation, and then converted back to strings, because that's what QBO expects.
+               $txn_ids             = array_map( 'absint', $txn_ids );
+               $txn_id_placeholders = implode( ', ', array_fill( 0, count( $txn_ids ), '%s' ) );
+
+               $filter = array( 'Id IN ( ' . $txn_id_placeholders . ' )' );
+
+               return $this->get_transactions( $type, $filter, $txn_ids );
+       }
+}
</ins></span></pre>
</div>
</div>

</body>
</html>