<?php
/**
* @package Habari
*
*/
/**
* Habari Posts Class
*
* class Posts
* This class provides two key features.
* 1: Posts contains static method get() that returns the
* requested posts based on the passed criteria. Depending on the type of
* request, different types are returned. See the function for details
* 2: An instance of Posts functions as an array (by extending ArrayObject) and
* is returned by Posts::get() as the results of a query. This allows the
* result of Posts::get() to be iterated (for example, in a foreach construct)
* and to have properties that can be accessed that describe the results
* (for example, $posts->onepost).
*
* @property-read boolean $onepost Whether or not this object contains only one post
* @property-read Post $first The first Post in this object
* @property-read array $preset The presets for this object
*
*/
class Posts extends ArrayObject implements IsContent
{
public $get_param_cache; // Stores info about the last set of data fetched that was not a single value
/**
* Returns properties of a Posts object.
* This is the function that returns information about the set of posts that
* was requested. This function should offer property names that are identical
* to properties of instances of the URL class. A call to Posts::get()
* without parameters should return mostly the same property values as the
* global $url object for the request. The difference would occur when
* the data returned doesn't necessarily match the request, such as when
* several posts are requested, but only one is available to return.
* @param string The name of the property to return.
*/
public function __get( $name )
{
switch ( $name ) {
case 'onepost':
return ( count( $this ) == 1 );
case 'first':
return reset($this);
case 'preset':
return isset($this->get_param_cache['preset']) ? $this->get_param_cache['preset'] : array();
}
return false;
}
/**
* Returns a post or posts based on supplied parameters.
* @todo <b>THIS CLASS SHOULD CACHE QUERY RESULTS!</b>
*
* @param array $paramarray An associative array of parameters, or a querystring.
* The following keys are supported:
* - id => a post id or array of post ids
* - not:id => a post id or array of post ids to exclude
* - slug => a post slug or array of post slugs
* - not:slug => a post slug or array of post slugs to exclude
* - user_id => an author id or array of author ids
* - content_type => a post content type or array post content types
* - not:content_type => a post content type or array post content types to exclude
* - status => a post status, an array of post statuses, or 'any' for all statuses
* - year => a year of post publication
* - month => a month of post publication, ignored if year is not specified
* - day => a day of post publication, ignored if month and year are not specified
* - before => a timestamp to compare post publication dates
* - after => a timestamp to compare post publication dates
* - month_cts => return the number of posts published in each month
* - criteria => a literal search string to match post content or title
* - title => an exact case-insensitive match to a post title
* - title_search => a search string that acts only on the post title
* - has:info => a post info key or array of post info keys, which should be present
* - all:info => a post info key and value pair or array of post info key and value pairs, which should all be present and match
* - not:all:info => a post info key and value pair or array of post info key and value pairs, to exclude if all are present and match
* - any:info => a post info key and value pair or array of post info key and value pairs, any of which can match
* - not:any:info => a post info key and value pair or array of post info key and value pairs, to exclude if any are present and match
* - vocabulary => an array describing parameters related to vocabularies attached to posts. This can be one of two forms:
* - object-based, in which an array of Term objects are passed
* - any => posts associated with any of the terms are returned
* - all => posts associated with all of the terms are returned
* - not => posts associated with none of the terms are returned
* - property-based, in which an array of vocabulary names and associated fields are passed
* - vocabulary_name:term => a vocabulary name and term slug pair or array of vocabulary name and term slug pairs, any of which can be associated with the posts
* - vocabulary_name:term_display => a vocabulary name and term display pair or array of vocabulary name and term display pairs, any of which can be associated with the posts
* - vocabulary_name:not:term => a vocabulary name and term slug pair or array of vocabulary name and term slug pairs, none of which can be associated with the posts
* - vocabulary_name:not:term_display => a vocabulary name and term display pair or array of vocabulary name and term display pairs, none of which can be associated with the posts
* - vocabulary_name:all:term => a vocabulary name and term slug pair or array of vocabulary name and term slug pairs, all of which must be associated with the posts
* - vocabulary_name:all:term_display => a vocabulary name and term display pair or array of vocabulary name and term display pairs, all of which must be associated with the posts
* - limit => the maximum number of posts to return, implicitly set for many queries
* - nolimit => do not implicitly set limit
* - offset => amount by which to offset returned posts, used in conjunction with limit
* - page => the 'page' of posts to return when paging, sets the appropriate offset
* - count => return the number of posts that would be returned by this request
* - orderby => how to order the returned posts
* - groupby => columns by which to group the returned posts, for aggregate functions
* - having => for selecting posts based on an aggregate function
* - where => manipulate the generated WHERE clause. Currently broken, see https://trac.habariproject.org/habari/ticket/1383
* - add_select => an array of clauses to be added to the generated SELECT clause.
* - fetch_fn => the function used to fetch data, one of 'get_results', 'get_row', 'get_value', 'get_query'
*
* Further description of parameters, including usage examples, can be found at
* http://wiki.habariproject.org/en/Dev:Retrieving_Posts
*
* @return Posts|Post|string An array of Post objects, or a single post object, depending on request
*/
public static function get( $paramarray = array() )
{
static $presets;
// If $paramarray is a string, use it as a Preset
if(is_string($paramarray)) {
$paramarray = array('preset' => $paramarray);
}
// If $paramarray is a querystring, convert it to an array
$paramarray = Utils::get_params( $paramarray );
if($paramarray instanceof ArrayIterator) {
$paramarray = $paramarray->getArrayCopy();
}
// If a preset is defined, get the named array and merge it with the provided parameters,
// allowing the additional $paramarray settings to override the preset
if(isset($paramarray['preset'])) {
if(!isset($presets)) {
$presets = Plugins::filter('posts_get_all_presets', $presets, $paramarray['preset']);
}
$paramarray['preset'] = Utils::single_array($paramarray['preset']);
foreach($paramarray['preset'] as $presetname => $fallbackpreset) {
if(isset($presets[$fallbackpreset])) {
$preset = Plugins::filter('posts_get_update_preset', $presets[$fallbackpreset], $presetname, $paramarray);
if(is_array( $preset ) || $preset instanceof ArrayObject || $preset instanceof ArrayIterator) {
$paramarray = array_merge($preset, $paramarray);
break;
}
}
}
}
// let plugins alter the param array before we use it. could be useful for modifying search results, etc.
$paramarray = Plugins::filter( 'posts_get_paramarray', $paramarray );
$join_params = array();
$params = array();
$fns = array( 'get_results', 'get_row', 'get_value', 'get_query' );
$select_ary = array();
// Default fields to select, everything by default
$default_fields = Plugins::filter('post_default_fields', Post::default_fields(), $paramarray);
if(isset($paramarray['default_fields'])) {
$param_defaults = Utils::single_array($paramarray['default_fields']);
$default_fields = array_merge($default_fields, $param_defaults);
}
foreach ( $default_fields as $field => $value ) {
if(preg_match('/(?:(?P<table>[\w\{\}]+)\.)?(?P<field>\w+)(?:(?:\s+as\s+)(?P<alias>\w+))?/i', $field, $fielddata)) {
if(empty($fielddata['table'])) {
$fielddata['table'] = '{posts}';
}
if(empty($fielddata['alias'])) {
$fielddata['alias'] = $fielddata['field'];
}
}
$select_ary[$fielddata['alias']] = "{$fielddata['table']}.{$fielddata['field']} AS {$fielddata['alias']}";
$select_distinct[$fielddata['alias']] = "{$fielddata['table']}.{$fielddata['field']}";
}
// Default parameters
$orderby = 'pubdate DESC';
// Define the WHERE sets to process and OR in the final SQL statement
if ( isset( $paramarray['where'] ) && is_array( $paramarray['where'] ) ) {
$wheresets = $paramarray['where'];
}
else {
$wheresets = array( array() );
}
/* Start building the WHERE clauses */
$query = Query::create('{posts}');
$query->select($select_ary);
// If the request has a textual WHERE clause, add it to the query then continue the processing of the $wheresets
if ( isset( $paramarray['where'] ) && is_string( $paramarray['where'] ) ) {
$query->where()->add($paramarray['where']);
}
foreach ( $wheresets as $paramset ) {
$where = new QueryWhere();
$paramset = array_merge( (array) $paramarray, (array) $paramset );
if ( isset( $paramset['id'] ) ) {
$where->in('{posts}.id', $paramset['id'], 'posts_id', 'intval');
}
if ( isset( $paramset['not:id'] ) ) {
$where->in('{posts}.id', $paramset['not:id'], 'posts_not_id', 'intval', false);
}
if ( isset( $paramset['status'] ) && ( $paramset['status'] != 'any' ) && ( 0 !== $paramset['status'] ) ) {
$where->in('{posts}.status', $paramset['status'], 'posts_status', function($a) {return Post::status( $a );} );
}
if ( isset( $paramset['not:status'] ) && ( $paramset['not:status'] != 'any' ) && ( 0 !== $paramset['not:status'] ) ) {
$where->in('{posts}.status', $paramset['not:status'], 'posts_not_status', function($a) {return Post::status( $a );}, null, false );
}
if ( isset( $paramset['content_type'] ) && ( $paramset['content_type'] != 'any' ) && ( 0 !== $paramset['content_type'] ) ) {
$where->in('{posts}.content_type', $paramset['content_type'], 'posts_content_type', function($a) {return Post::type( $a );} );
}
if ( isset( $paramset['not:content_type'] ) ) {
$where->in('{posts}.content_type', $paramset['not:content_type'], 'posts_not_content_type', function($a) {return Post::type( $a );}, false );
}
if ( isset( $paramset['slug'] ) ) {
$where->in('{posts}.slug', $paramset['slug'], 'posts_slug');
}
if ( isset( $paramset['not:slug'] ) ) {
$where->in('{posts}.slug', $paramset['not:slug'], 'posts_not_slug', null, false);
}
if ( isset( $paramset['user_id'] ) && 0 !== $paramset['user_id'] ) {
$where->in('{posts}.user_id', $paramset['user_id'], 'posts_user_id', 'intval');
}
if ( isset( $paramset['not:user_id'] ) && 0 !== $paramset['not:user_id'] ) {
$where->in('{posts}.user_id', $paramset['not:user_id'], 'posts_not_user_id', 'intval', false);
}
if ( isset( $paramset['vocabulary'] ) ) {
if ( is_string( $paramset['vocabulary'] ) ) {
$paramset['vocabulary'] = Utils::get_params( $paramset['vocabulary'] );
}
// parse out the different formats we accept arguments in into a single mutli-dimensional array of goodness
$paramset['vocabulary'] = self::vocabulary_params( $paramset['vocabulary'] );
$object_id = Vocabulary::object_type_id( 'post' );
if ( isset( $paramset['vocabulary']['all'] ) ) {
$all = $paramset['vocabulary']['all'];
foreach ( $all as $vocab => $value ) {
foreach ( $value as $field => $terms ) {
// we only support these fields to search by
if ( !in_array( $field, array( 'id', 'term', 'term_display' ) ) ) {
continue;
}
$join_group = Query::new_param_name('join');
$query->join( 'JOIN {object_terms} ' . $join_group . '_ot ON {posts}.id = ' . $join_group . '_ot.object_id', array(), 'term2post_posts_' . $join_group );
$query->join( 'JOIN {terms} ' . $join_group . '_t ON ' . $join_group . '_ot.term_id = ' . $join_group . '_t.id', array(), 'terms_term2post_' . $join_group );
$query->join( 'JOIN {vocabularies} ' . $join_group . '_v ON ' . $join_group . '_t.vocabulary_id = ' . $join_group . '_v.id', array(), 'terms_vocabulary_' . $join_group );
$where->in( $join_group . '_v.name', $vocab );
$where->in( $join_group . "_t.{$field}", $terms );
$where->in( $join_group . '_ot.object_type_id', $object_id );
}
// this causes no posts to match if combined with 'any' below and should be re-thought... somehow
$groupby = implode( ',', $select_distinct );
$having = 'count(*) = ' . count( $terms );
}
}
if ( isset( $paramset['vocabulary']['any'] ) ) {
$any = $paramset['vocabulary']['any'];
$orwhere = new QueryWhere( 'OR' );
foreach ( $any as $vocab => $value ) {
foreach ( $value as $field => $terms ) {
$andwhere = new QueryWhere();
// we only support these fields to search by
if ( !in_array( $field, array( 'id', 'term', 'term_display' ) ) ) {
continue;
}
$join_group = Query::new_param_name('join');
$query->join( 'JOIN {object_terms} ' . $join_group . '_ot ON {posts}.id = ' . $join_group . '_ot.object_id', array(), 'term2post_posts_' . $join_group );
$query->join( 'JOIN {terms} ' . $join_group . '_t ON ' . $join_group . '_ot.term_id = ' . $join_group . '_t.id', array(), 'terms_term2post_' . $join_group );
$query->join( 'JOIN {vocabularies} ' . $join_group . '_v ON ' . $join_group . '_t.vocabulary_id = ' . $join_group . '_v.id', array(), 'terms_vocabulary_' . $join_group );
$andwhere->in( $join_group . '_v.name', $vocab );
$andwhere->in( $join_group . "_t.{$field}", $terms );
$andwhere->in( $join_group . '_ot.object_type_id', $object_id );
}
$orwhere->add( $andwhere );
}
$where->add( $orwhere );
}
if ( isset( $paramset['vocabulary']['not'] ) ) {
$not = $paramset['vocabulary']['not'];
foreach ( $not as $vocab => $value ) {
foreach ( $value as $field => $terms ) {
// we only support these fields to search by
if ( !in_array( $field, array( 'id', 'term', 'term_display' ) ) ) {
continue;
}
$subquery_alias = Query::new_param_name('subquery');
$subquery = Query::create( '{object_terms}' )->select('object_id');
$subquery->join( 'JOIN {terms} ON {terms}.id = {object_terms}.term_id' );
$subquery->join( 'JOIN {vocabularies} ON {terms}.vocabulary_id = {vocabularies}.id' );
$subquery->where()->in( "{terms}.{$field}", $terms );
$subquery->where()->in( '{object_terms}.object_type_id', $object_id );
$subquery->where()->in( '{vocabularies}.name', $vocab );
$query->join( 'LEFT JOIN (' . $subquery->get() . ') ' . $subquery_alias . ' ON ' . $subquery_alias . '.object_id = {posts}.id', $subquery->params(), $subquery_alias );
$where->add( 'COALESCE(' . $subquery_alias . '.object_id, 0) = 0' );
}
}
}
}
if ( isset( $paramset['criteria'] ) ) {
// this regex matches any unicode letters (\p{L}) or numbers (\p{N}) inside a set of quotes (but strips the quotes) OR not in a set of quotes
preg_match_all( '/(?<=")([\p{L}\p{N}]+[^"]*)(?=")|([\p{L}\p{N}]+)/u', $paramset['criteria'], $matches );
foreach ( $matches[0] as $word ) {
$crit_placeholder = $query->new_param_name('criteria');
$where->add("( LOWER( {posts}.title ) LIKE :{$crit_placeholder} OR LOWER( {posts}.content ) LIKE :{$crit_placeholder})", array($crit_placeholder => '%' . MultiByte::strtolower( $word ) . '%'));
}
}
if ( isset( $paramset['title'] ) ) {
$where->add("LOWER( {posts}.title ) LIKE :title_match", array('title_match' => MultiByte::strtolower( $paramset['title'] )));
}
if ( isset( $paramset['title_search'] ) ) {
// this regex matches any unicode letters (\p{L}) or numbers (\p{N}) inside a set of quotes (but strips the quotes) OR not in a set of quotes
preg_match_all( '/(?<=")([\p{L}\p{N}]+[^"]*)(?=")|([\p{L}\p{N}]+)/u', $paramset['title_search'], $matches );
foreach ( $matches[0] as $word ) {
$crit_placeholder = $query->new_param_name('title_search');
$where->add("LOWER( {posts}.title ) LIKE :{$crit_placeholder}", array($crit_placeholder => '%' . MultiByte::strtolower( $word ) . '%'));
}
}
// Handle field queries on posts and joined tables
foreach($select_ary as $field => $aliasing) {
if(in_array($field, array('id', 'title', 'slug', 'status', 'content_type', 'user_id')) ) {
// skip fields that we're handling a different way
continue;
}
if(isset($paramset[$field])) {
if(is_callable($paramset[$field])) {
$paramset[$field]($where, $paramset);
}
else {
$where->in($field, $paramset[$field], 'posts_field_' . $field);
}
}
}
//Done
if ( isset( $paramset['all:info'] ) || isset( $paramset['info'] ) ) {
// merge the two possibile calls together
$infos = array_merge( isset( $paramset['all:info'] ) ? $paramset['all:info'] : array(), isset( $paramset['info'] ) ? $paramset['info'] : array() );
if ( Utils::is_traversable( $infos ) ) {
$pi_count = 0;
foreach ( $infos as $info_key => $info_value ) {
$pi_count++;
$infokey_field = Query::new_param_name('info_key' );
$infovalue_field = Query::new_param_name( 'info_value');
$query->join( "LEFT JOIN {postinfo} ipi{$pi_count} ON {posts}.id = ipi{$pi_count}.post_id AND ipi{$pi_count}.name = :{$infokey_field} AND ipi{$pi_count}.value = :{$infovalue_field}", array( $infokey_field => $info_key, $infovalue_field => $info_value ), 'all_info_' . $info_key );
$where->add( "ipi{$pi_count}.name <> ''" );
$query->select( array( "info_{$info_key}_value" => "ipi{$pi_count}.value AS info_{$info_key}_value" ) );
$select_distinct["info_{$info_key}_value"] = "info_{$info_key}_value";
}
}
}
//Done
if ( isset( $paramset['any:info'] ) ) {
if ( Utils::is_traversable( $paramset['any:info'] ) ) {
$pi_count = 0;
$orwhere = new QueryWhere( 'OR' );
foreach ( $paramset['any:info'] as $info_key => $info_value ) {
$pi_count++;
if ( is_array( $info_value ) ) {
$infokey_field = Query::new_param_name( 'info_key' );
$inwhere = new QueryWhere( '' );
$inwhere->in( "aipi{$pi_count}.value", $info_value );
$query->join( "LEFT JOIN {postinfo} aipi{$pi_count} ON {posts}.id = aipi{$pi_count}.post_id AND aipi{$pi_count}.name = :{$infokey_field} AND " . $inwhere->get(), array_merge( array( $info_key ), $inwhere->params() ), 'any_info_' . $info_key );
}
else {
$infokey_field = Query::new_param_name( 'info_key' );
$infovalue_field = Query::new_param_name( 'info_value' );
$query->join( "LEFT JOIN {postinfo} aipi{$pi_count} ON {posts}.id = aipi{$pi_count}.post_id AND aipi{$pi_count}.name = :{$infokey_field} AND aipi{$pi_count}.value = :{$infovalue_field}", array( $infokey_field => $info_key, $infovalue_field => $info_value ), 'any_info_' . $info_key );
}
$orwhere->add( "aipi{$pi_count}.name <> ''" );
$query->select( array( "info_{$info_key}_value" => "aipi{$pi_count}.value AS info_{$info_key}_value" ) );
$select_distinct["info_{$info_key}_value"] = "info_{$info_key}_value";
}
$where->add( '(' . $orwhere->get() . ')' );
}
}
// Done
if ( isset( $paramset['has:info'] ) ) {
$has_info = Utils::single_array( $paramset['has:info'] );
$pi_count = 0;
$orwhere = new QueryWhere( 'OR' );
foreach( $has_info as $info_name ) {
$infoname_field = Query::new_param_name( 'info_name' );
$pi_count++;
$query->join("LEFT JOIN {postinfo} hipi{$pi_count} ON {posts}.id = hipi{$pi_count}.post_id AND hipi{$pi_count}.name = :{$infoname_field}", array( $infoname_field => $info_name ), 'has_info_' . $info_name );
$orwhere->add( "hipi{$pi_count}.name <> ''" );
$query->select( array( "info_{$info_name}_value" => "hipi{$pi_count}.value AS info_{$info_name}_value" ) );
$select_distinct["info_{$info_name}_value"] = "info_{$info_name}_value";
}
$where->add( '(' . $orwhere->get() . ')' );
}
//Done
if ( isset( $paramset['not:all:info'] ) || isset( $paramset['not:info'] ) ) {
// merge the two possible calls together
$infos = array_merge( isset( $paramset['not:all:info'] ) ? $paramset['not:all:info'] : array(), isset( $paramset['not:info'] ) ? $paramset['not:info'] : array() );
if ( Utils::is_traversable( $infos ) ) {
$orwhere = new QueryWhere( 'OR' );
foreach ( $infos as $info_key => $info_value ) {
$andwhere = new QueryWhere();
$andwhere->in( '{postinfo}.name', $info_key );
$andwhere->in( '{postinfo}.value', $info_value );
$orwhere->add( $andwhere );
}
// see that hard-coded number in having()? sqlite wets itself if we use a bound parameter... don't change that
$subquery = Query::create( '{postinfo}' )->select( '{postinfo}.post_id' )->groupby( 'post_id' )->having( 'COUNT(*) = ' . count( $infos ) );
$subquery->where()->add( $orwhere );
$where->in( '{posts}.id', $subquery, 'posts_not_all_info_query', null, false );
}
}
//Tested. Test fails with original code
if ( isset( $paramset['not:any:info'] ) ) {
if ( Utils::is_traversable( $paramset['not:any:info'] ) ) {
$subquery = Query::create('{postinfo}')->select('post_id');
foreach ( $paramset['not:any:info'] as $info_key => $info_value ) {
$infokey_field = $query->new_param_name('info_key');
$infovalue_field = $query->new_param_name('info_value');
// $subquery->where()->add(" ({postinfo}.name = :{$infokey_field} AND {postinfo}.value = :{$infovalue_field} ) ", array($infokey_field => $info_key, $infovalue_field => $info_value));
$subquery->where( 'OR' )->add(" ({postinfo}.name = :{$infokey_field} AND {postinfo}.value = :{$infovalue_field} ) ", array($infokey_field => $info_key, $infovalue_field => $info_value));
}
$where->in('{posts}.id', $subquery, 'posts_not_any_info', null, false);
}
}
/**
* Build the statement needed to filter by pubdate:
* If we've got the day, then get the date;
* If we've got the month, but no date, get the month;
* If we've only got the year, get the whole year.
*/
if ( isset( $paramset['day'] ) && isset( $paramset['month'] ) && isset( $paramset['year'] ) ) {
$start_date = sprintf( '%d-%02d-%02d', $paramset['year'], $paramset['month'], $paramset['day'] );
$start_date = HabariDateTime::date_create( $start_date );
$where->add('pubdate BETWEEN :start_date AND :end_date', array('start_date' => $start_date->sql, 'end_date' => $start_date->modify( '+1 day -1 second' )->sql));
}
elseif ( isset( $paramset['month'] ) && isset( $paramset['year'] ) ) {
$start_date = sprintf( '%d-%02d-%02d', $paramset['year'], $paramset['month'], 1 );
$start_date = HabariDateTime::date_create( $start_date );
$where->add('pubdate BETWEEN :start_date AND :end_date', array('start_date' => $start_date->sql, 'end_date' => $start_date->modify( '+1 month -1 second' )->sql));
}
elseif ( isset( $paramset['year'] ) ) {
$start_date = sprintf( '%d-%02d-%02d', $paramset['year'], 1, 1 );
$start_date = HabariDateTime::date_create( $start_date );
$where->add('pubdate BETWEEN :start_date AND :end_date', array('start_date' => $start_date->sql, 'end_date' => $start_date->modify( '+1 year -1 second' )->sql));
}
if ( isset( $paramset['after'] ) ) {
$where->add('pubdate > :after_date', array('after_date' => HabariDateTime::date_create( $paramset['after'] )->sql));
}
if ( isset( $paramset['before'] ) ) {
$where->add('pubdate < :before_date', array('before_date' => HabariDateTime::date_create( $paramset['before'] )->sql));
}
// Concatenate the WHERE clauses
$query->where()->add($where);
}
if(isset($paramset['post_join'])) {
$post_joins = Utils::single_array($paramset['post_join']);
foreach($post_joins as $post_join) {
if(preg_match('#^(\S+)(?:\s+as)?\s+(\S+)$#i', $post_join, $matches)) {
$query->join("LEFT JOIN {$matches[1]} {$matches[2]} ON {$matches[2]}.post_id = {posts}.id ");
}
else {
$query->join("LEFT JOIN {$post_join} ON {$post_join}.post_id = {posts}.id ");
}
}
}
// Only show posts to which the current user has permission
if ( isset( $paramset['ignore_permissions'] ) ) {
$master_perm_where = new QueryWhere();
// Set up the merge params
$merge_params = array( $join_params, $params );
$params = call_user_func_array( 'array_merge', $merge_params );
}
else {
$master_perm_where = new QueryWhere();
// This set of wheres will be used to generate a list of post_ids that this user can read
$perm_where = new QueryWhere('OR');
$perm_where_denied = new QueryWhere('AND');
// Get the tokens that this user is granted or denied access to read
$read_tokens = isset( $paramset['read_tokens'] ) ? $paramset['read_tokens'] : ACL::user_tokens( User::identify(), 'read', true );
$deny_tokens = isset( $paramset['deny_tokens'] ) ? $paramset['deny_tokens'] : ACL::user_tokens( User::identify(), 'deny', true );
// If a user can read any post type, let him
if ( User::identify()->can( 'post_any', 'read' ) ) {
$perm_where->add( '(1=1)' );
}
else {
// If a user can read his own posts, let him
if ( User::identify()->can( 'own_posts', 'read' ) ) {
$perm_where->add('{posts}.user_id = :current_user_id', array('current_user_id' => User::identify()->id));
}
// If a user can read specific post types, let him
$permitted_post_types = array();
foreach ( Post::list_active_post_types() as $name => $posttype ) {
if ( User::identify()->can( 'post_' . Utils::slugify( $name ), 'read' ) ) {
$permitted_post_types[] = $posttype;
}
}
if ( count( $permitted_post_types ) > 0 ) {
$perm_where->in('{posts}.content_type', $permitted_post_types, 'posts_permitted_types', 'intval');
}
// If a user can read posts with specific tokens, let him
if ( count( $read_tokens ) > 0 ) {
$query->join('LEFT JOIN {post_tokens} pt_allowed ON {posts}.id= pt_allowed.post_id AND pt_allowed.token_id IN ('.implode( ',', $read_tokens ).')', array(), 'post_tokens__allowed');
$perm_where->add('pt_allowed.post_id IS NOT NULL', array(), 'perms_join_not_null');
}
// If a user has access to read other users' unpublished posts, let him
if ( User::identify()->can( 'post_unpublished', 'read' ) ) {
$perm_where->add('({posts}.status <> :status_published AND {posts}.user_id <> :current_user_id)', array('current_user_id' => User::identify()->id, 'status_published' => Post::status('published')));
}
}
// If a user is denied access to all posts, do so
if ( User::identify()->cannot( 'post_any' ) ) {
$perm_where_denied->add('(1=0)');
}
else {
// If a user is denied read access to specific post types, deny him
$denied_post_types = array();
foreach ( Post::list_active_post_types() as $name => $posttype ) {
if ( User::identify()->cannot( 'post_' . Utils::slugify( $name ) ) ) {
$denied_post_types[] = $posttype;
}
}
if ( count( $denied_post_types ) > 0 ) {
$perm_where_denied->in('{posts}.content_type', $denied_post_types, 'posts_denied_types', 'intval', false);
}
// If a user is denied read access to posts with specific tokens, deny it
if ( count( $deny_tokens ) > 0 ) {
$query->join('LEFT JOIN {post_tokens} pt_denied ON {posts}.id= pt_denied.post_id AND pt_denied.token_id IN ('.implode( ',', $deny_tokens ).')', array(), 'post_tokens__denied');
$perm_where_denied->add('pt_denied.post_id IS NULL', array(), 'perms_join_null');
}
// If a user is denied access to read other users' unpublished posts, deny it
if ( User::identify()->cannot( 'post_unpublished' ) ) {
$perm_where_denied->add('({posts}.status = :status_published OR {posts}.user_id = :current_user_id)', array('current_user_id' => User::identify()->id, 'status_published' => Post::status('published')));
}
}
Plugins::act( 'post_get_perm_where', $perm_where, $paramarray );
Plugins::act( 'post_get_perm_where_denied', $perm_where_denied, $paramarray );
// If there are granted permissions to check, add them to the where clause
if($perm_where->count() == 0 && !$query->joined('post_tokens__allowed')) {
$master_perm_where->add('(1=0)', array(), 'perms_granted');
}
else {
$master_perm_where->add($perm_where, array(), 'perms_granted');
}
// If there are denied permissions to check, add them to the where clause
if($perm_where_denied->count() > 0 || $query->joined('post_tokens__denied')) {
$master_perm_where->add($perm_where_denied, array(), 'perms_denied');
}
}
$query->where()->add($master_perm_where, array(), 'master_perm_where');
// Extract the remaining parameters which will be used onwards
// For example: page number, fetch function, limit
$paramarray = new SuperGlobal( $paramarray );
$extract = $paramarray->filter_keys( 'page', 'fetch_fn', 'count', 'orderby', 'groupby', 'limit', 'offset', 'nolimit', 'having', 'add_select' );
foreach ( $extract as $key => $value ) {
$$key = $value;
}
// Define the LIMIT if it does not exist, unless specific posts are requested or we're getting the monthly counts
if ( !isset( $limit ) && !isset( $paramset['id'] ) && !isset( $paramset['slug'] ) && !isset( $paramset['month_cts'] ) ) {
$limit = Options::get( 'pagination' ) ? (int) Options::get( 'pagination' ) : 5;
}
elseif ( !isset( $limit ) ) {
$selected_posts = 0;
if ( isset( $paramset['id'] ) ) {
$selected_posts += count( Utils::single_array( $paramset['id'] ) );
}
if ( isset( $paramset['slug'] ) ) {
$selected_posts += count( Utils::single_array( $paramset['slug'] ) );
}
$limit = $selected_posts > 0 ? $selected_posts : '';
}
// Calculate the OFFSET based on the page number
if ( isset( $page ) && is_numeric( $page ) && !isset( $paramset['offset'] ) ) {
$offset = ( intval( $page ) - 1 ) * intval( $limit );
}
/**
* Determine which fetch function to use:
* If it is specified, make sure it is valid (based on the $fns array defined at the beginning of this function);
* Else, use 'get_results' which will return a Posts array of Post objects.
*/
if ( isset( $fetch_fn ) ) {
if ( ! in_array( $fetch_fn, $fns ) ) {
$fetch_fn = $fns[0];
}
}
else {
$fetch_fn = $fns[0];
}
// Add arbitrary fields to the select clause for sorting and output
if ( isset( $add_select ) ) {
$query->select($add_select);
}
/**
* If a count is requested:
* Replace the current fields to select with a COUNT();
* Change the fetch function to 'get_value';
* Remove the ORDER BY since it's useless.
* Remove the GROUP BY (tag search added it)
*/
if ( isset( $count ) ) {
$query->set_select( "COUNT({$count})" );
$fetch_fn = isset($paramarray['fetch_fn']) ? $fetch_fn : 'get_value';
$orderby = null;
$groupby = null;
$having = null;
}
// If the month counts are requested, replaced the select clause
if ( isset( $paramset['month_cts'] ) ) {
if ( isset( $paramset['vocabulary'] ) ) {
$query->set_select('MONTH(FROM_UNIXTIME(pubdate)) AS month, YEAR(FROM_UNIXTIME(pubdate)) AS year, COUNT(DISTINCT {posts}.id) AS ct');
}
else {
$query->set_select('MONTH(FROM_UNIXTIME(pubdate)) AS month, YEAR(FROM_UNIXTIME(pubdate)) AS year, COUNT(*) AS ct');
}
$groupby = 'year, month';
if ( !isset( $paramarray['orderby'] ) ) {
$orderby = 'year, month';
}
}
// Remove the LIMIT if 'nolimit'
// Doing this first should allow OFFSET to work
if ( isset( $nolimit ) ) {
$limit = null;
}
// Define the LIMIT, OFFSET, ORDER BY, GROUP BY if they exist
if(isset($limit)) {
$query->limit($limit);
}
if(isset($offset)) {
$query->offset($offset);
}
if(isset($orderby)) {
$query->orderby($orderby);
}
if(isset($groupby)) {
$query->groupby($groupby);
}
if(isset($having)) {
$query->having($having);
}
if(isset($paramarray['on_query_built'])) {
foreach(Utils::single_array($paramarray['on_query_built']) as $built) {
$built($query);
}
}
Plugins::act('posts_get_query', $query, $paramarray);
/* All SQL parts are constructed, on to real business! */
/**
* DEBUG: Uncomment the following line to display everything that happens in this function
*/
//print_R('<pre>'.$query.'</pre>');
//Utils::debug( $paramarray, $fetch_fn, $query, $params );
//Session::notice($query);
if ( 'get_query' == $fetch_fn ) {
return array($query->get(), $query->params());
}
/**
* Execute the SQL statement using the PDO extension
*/
DB::set_fetch_mode( PDO::FETCH_CLASS );
$fetch_class = 'Post';
if(isset($paramarray['fetch_class'])) {
$fetch_class = $paramarray['fetch_class'];
}
DB::set_fetch_class( $fetch_class );
$results = DB::$fetch_fn( $query->get(), $query->params(), $fetch_class );
//Utils::debug($results, $query->get(), $query->params());
//Utils::debug( $paramarray, $fetch_fn, $query->get(), $query->params(), $results );
//var_dump( $query );
/**
* Return the results
*/
if ( 'get_results' != $fetch_fn ) {
// Since a single result was requested, return a single Post object.
return $results;
}
elseif ( is_array( $results ) ) {
// With multiple results, return a Posts array of Post objects.
$c = __CLASS__;
$return_value = new $c( $results );
$return_value->get_param_cache = $paramarray;
return $return_value;
}
}
/**
* Extract parameters from a Posts::get()-style param array, even from within where's
* @static
* @param array $paramarray An array of Posts::get()-style parameters
* @param string $param The parameters to extract
* @return array|bool The parameters in the $paramarray that match $param or false
*/
public static function extract_param($paramarray, $param) {
$result = array();
if(isset($paramarray[$param])) {
$result = array_merge($result, Utils::single_array($paramarray[$param]));
}
if(isset($paramarray['where'])) {
if(is_array($paramarray['where'])) {
foreach($paramarray['where'] as $where) {
if(isset($where[$param])) {
$result = array_merge($result, Utils::single_array($where[$param]));
}
}
}
}
return count($result) ? $result : false;
}
/**
* function by_status
* select all posts of a given status
* @param int a status value
* @return array an array of Comment objects with the same status
*/
public static function by_status ( $status )
{
return self::get( array( 'status' => $status ) );
}
/**
* function by_slug
* select all post content by slug
* @param string a post slug
* @return array an array of post content
*/
public static function by_slug ( $slug = '' )
{
return self::get( array( 'slug' => $slug ) );
}
/**
* static count_total
* return a count for the total number of posts
* @param mixed a status value to filter posts by; if false, then no filtering will be performed
* @return int the number of posts of specified type ( published or draft )
*/
public static function count_total( $status = false )
{
$params = array( 'count' => 1 );
if ( $status !== false ) {
$params['status'] = $status;
}
return self::get( $params );
}
/**
* return a count for the number of posts last queried
* @return int the number of posts of specified type ( published or draft )
*/
public function count_all()
{
$params = array_merge( ( array ) $this->get_param_cache, array( 'count' => '*', 'nolimit' => 1 ) );
return Posts::get( $params );
}
/**
* return the query that generated this set of posts
* @return string The SQL and paramters used to generate this set of posts
*/
public function get_query()
{
$params = array_merge( ( array ) $this->get_param_cache, array( 'fetch_fn' => 'get_query') );
return Posts::get( $params );
}
/**
* static count_by_author
* return a count of the number of posts by the specified author
* @param int an author ID
* @param mixed a status value to filter posts by; if false, then no filtering will be performed
* @return int the number of posts by the specified author
*/
public static function count_by_author( $user_id, $status = false )
{
$params = array( 'user_id' => $user_id, 'count' => 1 );
if ( false !== $status ) {
$params['status'] = $status;
}
return self::get( $params );
}
/**
* static count_by_tag
* return a count of the number of posts with the assigned tag
* @param string A tag
* @param mixed a status value to filter posts by; if false, then no filtering will be performed
* @return int the number of posts with the specified tag
*/
public static function count_by_tag( $tag, $status = false )
{
$params = array( 'vocabulary' => array( Tags::vocabulary()->name . ':term_display' => $tag ), 'count' => 1 );
if ( false !== $status ) {
$params['status'] = $status;
}
return self::get( $params );
}
/**
* Reassigns the author of a specified set of posts
* @param mixed a user ID or name
* @param mixed an array of post IDs, an array of Post objects, or an instance of Posts
* @return bool Whether the rename operation succeeded or not
*/
public static function reassign( $user, $posts )
{
if ( ! is_int( $user ) ) {
$u = User::get( $user );
$user = $u->id;
}
// safety checks
if ( ( $user == 0 ) || empty( $posts ) ) {
return false;
}
switch ( true ) {
case is_integer( reset( $posts ) ):
break;
case reset( $posts ) instanceof Post:
$ids = array();
foreach ( $posts as $post ) {
$ids[] = $post->id;
}
$posts = $ids;
break;
default:
return false;
}
$ids = implode( ',', $posts );
// allow plugins the opportunity to prevent the reassignment now that we've verified the user and posts
$allow = true;
$allow = Plugins::filter( 'posts_reassign_allow', $allow, $user, $posts );
if ( !$allow ) {
return false;
}
// actually perform the reassignment
Plugins::act( 'posts_reassign_before', array( $user, $posts ) );
$results = DB::query( "UPDATE {posts} SET user_id=? WHERE id IN ({$ids})", array( $user ) );
Plugins::act( 'posts_reassign_after', array( $user, $posts ) );
return $results;
}
/**
* function publish_scheduled_posts
*
* Callback function to publish scheduled posts
*/
public static function publish_scheduled_posts( $params )
{
$select = array();
// Default fields to select, everything by default
foreach ( Post::default_fields() as $field => $value ) {
$select[$field] = "{posts}.$field AS $field";
}
$select = implode( ',', $select );
$posts = DB::get_results( 'SELECT ' . $select . ' FROM {posts} WHERE {posts}.status = ? AND {posts}.pubdate <= ? ORDER BY {posts}.pubdate DESC', array( Post::status( 'scheduled' ), HabariDateTime::date_create() ), 'Post' );
foreach ( $posts as $post ) {
$post->publish();
}
}
/**
* function update_scheduled_posts_cronjob
*
* Creates or recreates the cronjob to publish
* scheduled posts. It is called whenever a post
* is updated or created
*
*/
public static function update_scheduled_posts_cronjob()
{
$min_time = DB::get_value( 'SELECT MIN(pubdate) FROM {posts} WHERE status = ?', array( Post::status( 'scheduled' ) ) );
CronTab::delete_cronjob( 'publish_scheduled_posts' );
if ( $min_time ) {
CronTab::add_single_cron( 'publish_scheduled_posts', array( 'Posts', 'publish_scheduled_posts' ), $min_time, 'Next run: ' . HabariDateTime::date_create( $min_time )->get( 'c' ) );
}
}
/**
* Returns an ascending post
*
* @param The Post from which to start
* @param The params by which to work out what is the next ascending post
* @return Post The ascending post
*/
public static function ascend( $post, $params = null )
{
$posts = null;
$ascend = false;
if ( !$params ) {
$params = array( 'where' => "pubdate >= '{$post->pubdate->sql}' AND content_type = {$post->content_type} AND status = {$post->status}", 'limit' => 2, 'orderby' => 'pubdate ASC' );
$posts = Posts::get( $params );
}
elseif ( $params instanceof Posts ) {
$posts = $params;
}
else {
if ( !array_key_exists( 'orderby', $params ) ) {
$params['orderby'] = 'pubdate ASC';
}
$posts = Posts::get( $params );
}
if($posts) {
// find $post and return the next one.
$index = $posts->search( $post );
$target = $index + 1;
if ( array_key_exists( $target, $posts ) ) {
$ascend = $posts[$target];
return $ascend;
}
}
return false;
}
/**
* Returns a descending post
*
* @param The Post from which to start
* @param The params by which to work out what is the next descending post
* @return Post The descending post
*/
public static function descend( $post, $params = null )
{
$posts = null;
$descend = false;
if ( !$params ) {
$params = array( 'where' => "pubdate <= '{$post->pubdate->sql}' AND content_type = {$post->content_type} AND status = {$post->status}", 'limit' => 2, 'orderby' => 'pubdate DESC' );
$posts = Posts::get( $params );
}
elseif ( $params instanceof Posts ) {
$posts = array_reverse( $params );
}
else {
if ( !array_key_exists( 'orderby', $params ) ) {
$params['orderby'] = 'pubdate DESC';
}
$posts = Posts::get( $params );
}
if($posts) {
// find $post and return the next one.
$index = $posts->search( $post );
$target = $index + 1;
if ( array_key_exists( $target, $posts ) ) {
$descend = $posts[$target];
return $descend;
}
}
return false;
}
/**
* Search this Posts object for the needle, returns its key if found
*
* @param Post $needle Post object to find within this Posts object
* @return mixed Returns the index of the needle, on failure, null is returned
*/
public function search( $needle )
{
return array_search( $needle, $this->getArrayCopy() );
}
/**
* Parses a search string for status, type, author, and tag keywords. Returns
* an associative array which can be passed to Posts::get(). If multiple
* authors, statuses, tags, or types are specified, we assume an implicit OR
* such that (e.g.) any author that matches would be returned.
*
* @param string $search_string The search string
* @return array An associative array which can be passed to Posts::get()
*/
public static function search_to_get( $search_string )
{
// if adding to this array, make sure you update the consequences of a search on this below in the switch.
$keywords = array( 'author' => 1, 'status' => 1, 'type' => 1, 'tag' => 1, 'info' => 1 );
$statuses = Post::list_post_statuses();
$types = Post::list_active_post_types();
$arguments = array(
'user_id' => array(),
'status' => array(),
'content_type' => array(),
'vocabulary' => array(),
'info' => array(),
);
$criteria = '';
// this says, find stuff that has the keyword at the start, and then some term straight after.
// the terms should have no whitespace, or if it does, be ' delimited.
// ie tag:foo or tag:'foo bar'
$flag_regex = '/(?P<flag>\w+):(?P<value>[^\'"][^\s]*|(?P<quote>[\'"])[^\3]+(?<!\\\\)\3)/i';
// now do some matching.
preg_match_all( $flag_regex, $search_string, $matches, PREG_SET_ORDER );
// now we remove those terms from the search string, otherwise the keyword search below has issues. It will pick up things like
// from tag:'pair of' -> matches of'
$criteria = trim( preg_replace( $flag_regex, '', $search_string ) );
// Add special criteria based on the flag parameters.
foreach ( $matches as $match ) {
// trim out any quote marks that have been matched.
$quote = isset( $match['quote'] ) ? $match['quote'] : ' ';
$value = trim( stripslashes( $match['value'] ), $quote );
$flag = $match['flag'];
$arguments = Plugins::filter( 'posts_search_to_get', $arguments, $flag, $value, $match, $search_string );
switch ( $flag ) {
case 'author':
if ( $u = User::get( $value ) ) {
$arguments['user_id'][] = (int) $u->id;
}
break;
case 'tag':
$arguments['vocabulary'][Tags::vocabulary()->name . ':term_display'][] = $value;
break;
case 'status':
if ( isset( $statuses[$value] ) ) {
$arguments['status'][] = (int) $statuses[$value];
}
break;
case 'type':
if ( isset( $types[$value] ) ) {
$arguments['content_type'][] = (int) $types[$value];
}
break;
case 'info':
if ( strpos( $value, ':' ) !== false ) {
list( $infokey, $infovalue ) = explode( ':', $value, 2 );
$arguments['info'][$infokey] = $infovalue;
}
break;
}
}
// flatten keys that have single-element or no-element arrays
foreach ( $arguments as $key => $arg ) {
switch ( count( $arg ) ) {
case 0:
unset( $arguments[$key] );
break;
case 1:
if ( is_array( $arg ) ) {
$arguments[$key] = $arg;
}
else {
$arguments[$key] = $arg[0];
}
break;
}
}
if ( $criteria != '' ) {
$arguments['criteria'] = $criteria;
}
return $arguments;
}
/**
* Check if the requested post is of the type specified, to see if a rewrite rule matches.
*
* @return Boolean Whether the requested post matches the content type of the rule.
*/
public static function rewrite_match_type( $rule, $slug, $parameters )
{
$args = $rule->named_arg_values;
$args['count'] = true;
$postcount = Posts::get( $args );
return $postcount > 0;
}
/**
* Return the type of the content represented by this object
*
* @return array The names of the possible content represented by this object
*/
function content_type ()
{
$content_type = array_map(
function($a){
return 'posts.' . $a;
},
$this->preset
);
$content_type[] = 'posts';
return $content_type;
}
/**
* Accepts a set of term query qualifiers and converts it into a multi-dimensional array
* of vocabulary (ie: tags), matching method (any, all, not), matching field (id, term, term_display), and list of terms
*
* @return array An array of parsed term-matching conditions
*/
private static function vocabulary_params( $params )
{
$return = array();
foreach ( $params as $key => $value ) {
// split vocab off the beginning of the key
if ( strpos( $key, ':' ) !== false ) {
list( $newkey, $subkey ) = explode( ':', $key, 2 );
$params[$newkey][$subkey] = $value;
unset( $params[$key] );
}
}
foreach ( $params as $vocab => $values ) {
foreach ( $values as $key => $value ) {
$value = Utils::single_array( $value );
// if there's a colon we've got a mode and a field
if ( strpos( $key, ':' ) !== false ) {
list( $mode, $by_field ) = explode( ':', $key, 2 );
foreach ( $value as $v ) {
$return[$mode][$vocab][$by_field][] = $v;
}
}
else {
// if there's no colon we've got a single field name
foreach ( $value as $v ) {
if ( $v instanceof Term ) {
// $vocab is not a vocab, but the mode - always match by its ID for the best performance
$return[$vocab][$v->vocabulary->name]['id'][] = $v->id;
}
else {
$return['any'][$vocab][$key][] = $v;
}
}
}
}
}
return $return;
}
/**
* Register plugin hooks
* @static
*/
public static function __static()
{
Pluggable::load_hooks('Posts');
}
/**
* Provide some default presets
* @static
* @param array $presets List of presets that other classes might provide
* @return array List of presets this class provides
*/
public static function filter_posts_get_all_presets($presets)
{
$presets['page_list'] = array( 'content_type' => 'page', 'status' => 'published', 'nolimit' => true );
$presets['asides'] = array( 'vocabulary' => array( 'tags:term' => 'aside' ), 'limit' => 5 );
$presets['home'] = array( 'content_type' => Post::type( 'entry' ), 'status' => Post::status( 'published' ), 'limit' => Options::get('pagination', 5) );
return $presets;
}
/**
* function delete
* Delete all Posts in a Posts object
*/
public function delete()
{
foreach( $this as $post ) {
$post->delete();
}
}
/**
* Serialize these posts as JSON
* @return string Posts as JSON
*/
public function to_json()
{
$posts = array_map(function($e){return $e->to_json();}, $this->getArrayCopy());
return '[' . implode(',', $posts) . ']';
}
}
?>