leilukin-tumbleblog/includes/model/Post.php

1151 lines
38 KiB
PHP
Raw Permalink Normal View History

2024-06-20 14:10:42 +00:00
<?php
/**
* Class: Post
* The Post model.
*
* See Also:
* <Model>
*/
class Post extends Model {
const STATUS_PUBLIC = "public";
const STATUS_DRAFT = "draft";
const STATUS_REG_ONLY = "registered_only";
const STATUS_PRIVATE = "private";
const STATUS_SCHEDULED = "scheduled";
public $belongs_to = "user";
# Variable: $prev
# The previous post (the post made after this one).
private $prev;
# Variable: $next
# The next post (the post made before this one).
private $next;
# Array: $url_attrs
# The translation array of the post URL setting to regular expressions.
public static $url_attrs = array(
'(year)' => '([0-9]{4})',
'(month)' => '([0-9]{1,2})',
'(day)' => '([0-9]{1,2})',
'(hour)' => '([0-9]{1,2})',
'(minute)' => '([0-9]{1,2})',
'(second)' => '([0-9]{1,2})',
'(id)' => '([0-9]+)',
'(author)' => '([^\/]+)',
'(clean)' => '([^\/]+)',
'(url)' => '([^\/]+)',
'(feather)' => '([^\/]+)',
'(feathers)' => '([^\/]+)'
);
/**
* Function: __construct
* See Also:
* <Model::grab>
*/
public function __construct($post_id = null, $options = array()) {
if (!isset($post_id) and empty($options))
return;
if (isset($options["where"]) and !is_array($options["where"]))
$options["where"] = array($options["where"]);
elseif (!isset($options["where"]))
$options["where"] = array();
$has_status = false;
$skip_where = (
isset($options["skip_where"]) and $options["skip_where"]
);
foreach ($options["where"] as $key => $val) {
if (
(is_int($key) and substr_count($val, "status")) or
$key == "status"
)
$has_status = true;
}
# Construct SQL query "chunks" for enabled feathers and user privileges.
if (!$skip_where) {
$options["where"][] = self::feathers();
if (!$has_status) {
$visitor = Visitor::current();
$private = (
isset($options["drafts"]) and
$options["drafts"] and
$visitor->group->can("view_draft")
) ?
self::statuses(array(self::STATUS_DRAFT)) :
self::statuses() ;
if (
isset($options["drafts"]) and
$options["drafts"] and
$visitor->group->can("view_own_draft")
) {
$private.= " OR (status = '".
self::STATUS_DRAFT.
"' AND user_id = :visitor_id)";
$options["params"][":visitor_id"] = $visitor->id;
}
$options["where"][] = $private;
}
}
$options["left_join"][] = array(
"table" => "post_attributes",
"where" => "post_id = posts.id"
);
$options["select"] = array_merge(
array(
"posts.*",
"post_attributes.name AS attribute_names",
"post_attributes.value AS attribute_values"
),
fallback($options["select"], array())
);
$options["ignore_dupes"] = array("attribute_names", "attribute_values");
parent::grab($this, $post_id, $options);
if ($this->no_results)
return;
$this->slug = $this->url;
$this->filtered = (!isset($options["filter"]) or $options["filter"]);
$this->attribute_values = (array) $this->attribute_values;
$this->attribute_names = (array) $this->attribute_names;
$this->attributes = ($this->attribute_names) ?
array_combine(
$this->attribute_names,
$this->attribute_values
) :
array() ;
foreach($this->attributes as $key => $val) {
if (!empty($key))
$this->$key = $val;
}
Trigger::current()->filter($this, "post");
if ($this->filtered)
$this->filter();
}
/**
* Function: find
* See Also:
* <Model::search>
*/
public static function find(
$options = array(),
$options_for_object = array()
): array {
if (isset($options["where"]) and !is_array($options["where"]))
$options["where"] = array($options["where"]);
elseif (!isset($options["where"]))
$options["where"] = array();
$has_status = false;
$skip_where = (isset($options["skip_where"]) and $options["skip_where"]);
foreach ($options["where"] as $key => $val) {
if (
(is_int($key) and substr_count($val, "status")) or
$key === "status"
)
$has_status = true;
}
# Construct SQL query "chunks" for enabled feathers and user privileges.
if (!$skip_where) {
$options["where"][] = self::feathers();
if (!$has_status) {
$visitor = Visitor::current();
$private = (
isset($options["drafts"]) and
$options["drafts"] and
$visitor->group->can("view_draft")
) ?
self::statuses(array(self::STATUS_DRAFT)) :
self::statuses() ;
if (
isset($options["drafts"]) and
$options["drafts"] and
$visitor->group->can("view_own_draft")
) {
$private.= " OR (status = '".
self::STATUS_DRAFT.
"' AND user_id = :visitor_id)";
$options["params"][":visitor_id"] = $visitor->id;
}
$options["where"][] = $private;
}
}
$options["left_join"][] = array(
"table" => "post_attributes",
"where" => "post_id = posts.id"
);
$options["select"] = array_merge(
array(
"posts.*",
"post_attributes.name AS attribute_names",
"post_attributes.value AS attribute_values"
),
fallback($options["select"], array())
);
$options["ignore_dupes"] = array("attribute_names", "attribute_values");
fallback($options["order"], "pinned DESC, created_at DESC, id DESC");
return parent::search(
self::class,
$options,
$options_for_object
);
}
/**
* Function: add
* Adds a post to the database.
*
* Parameters:
* $values - The data to insert.
* $clean - The slug for this post.
* $url - The unique sanitised URL (created from $clean by default).
* $feather - The feather to post as.
* $user - <User> to set as the post's author.
* $pinned - Pin the post?
* $status - Post status
* $created_at - New @created_at@ timestamp for the post.
* $updated_at - New @updated_at@ timestamp for the post.
* $pingbacks - Send pingbacks?
* $options - Options for the post.
*
* Returns:
* The newly created <Post>.
*
* Notes:
* The caller is responsible for validating all supplied values.
*
* See Also:
* <update>
*/
public static function add(
$values = array(),
$clean = "",
$url = "",
$feather = null,
$user = null,
$pinned = null,
$status = "",
$created_at = null,
$updated_at = null,
$pingbacks = true,
$options = array()
): self {
$user_id = ($user instanceof User) ? $user->id : $user ;
fallback($clean, slug(8));
fallback($url, self::check_url($clean));
fallback($feather, "undefined");
fallback($user_id, Visitor::current()->id);
fallback($pinned, false);
fallback($status, self::STATUS_DRAFT);
fallback($created_at, datetime());
fallback($updated_at, SQL_DATETIME_ZERO); # Model->updated will check this.
fallback($options, array());
$sql = SQL::current();
$config = Config::current();
$trigger = Trigger::current();
$new_values = array(
"feather" => $feather,
"user_id" => $user_id,
"pinned" => $pinned,
"status" => $status,
"clean" => $clean,
"url" => $url,
"created_at" => $created_at,
"updated_at" => $updated_at
);
$trigger->filter($new_values, "before_add_post");
$sql->insert(table:"posts", data:$new_values);
$id = $sql->latest("posts");
$attributes = array_merge($values, $options);
$trigger->filter($attributes, "before_add_post_attributes");
$attribute_values = array_values($attributes);
$attribute_names = array_keys($attributes);
# Insert the post attributes.
foreach ($attributes as $name => $value)
$sql->insert(
table:"post_attributes",
data:array(
"post_id" => $id,
"name" => $name,
"value" => $value
)
);
$post = new self($id, array("skip_where" => true));
# Notify URLs discovered in the structured feed content.
if (
$config->send_pingbacks and
$pingbacks and
$post->status == self::STATUS_PUBLIC and
feather_enabled($post->feather)
)
webmention_send($post->feed_content(), $post);
$trigger->call("add_post", $post, $options);
return $post;
}
/**
* Function: update
* Updates a post with the given attributes.
*
* Parameters:
* $values - An array of data to set for the post.
* $user - <User> to set as the post's author.
* $pinned - Pin the post?
* $status - Post status
* $clean - A new slug for the post.
* $url - A new unique URL for the post.
* $created_at - New @created_at@ timestamp for the post.
* $updated_at - New @updated_at@ timestamp for the post.
* $options - Options for the post.
* $pingbacks - Send pingbacks?
*
* Returns:
* The updated <Post>.
*
* Notes:
* The caller is responsible for validating all supplied values.
*
* See Also:
* <add>
*/
public function update(
$values = null,
$user = null,
$pinned = null,
$status = null,
$clean = null,
$url = null,
$created_at = null,
$updated_at = null,
$options = null,
$pingbacks = true
): self|false {
if ($this->no_results)
return false;
$user_id = ($user instanceof User) ? $user->id : $user ;
fallback($values, $this->attributes);
fallback($user_id, $this->user_id);
fallback($pinned, $this->pinned);
fallback($status, $this->status);
fallback($clean, $this->clean);
fallback($url,
($clean != $this->clean) ?
self::check_url($clean) :
$this->url
);
fallback($created_at, $this->created_at);
fallback($updated_at, datetime());
fallback($options, array());
$sql = SQL::current();
$config = Config::current();
$trigger = Trigger::current();
$new_values = array(
"user_id" => $user_id,
"pinned" => $pinned,
"status" => $status,
"clean" => $clean,
"url" => $url,
"created_at" => $created_at,
"updated_at" => $updated_at
);
$trigger->filter($new_values, "before_update_post");
$sql->update(
table:"posts",
conds:array("id" => $this->id),
data:$new_values
);
$attributes = array_merge($values, $options);
$trigger->filter($attributes, "before_update_post_attributes");
$attribute_values = array_values($attributes);
$attribute_names = array_keys($attributes);
# Replace the post attributes.
foreach ($attributes as $name => $value)
$sql->replace(
table:"post_attributes",
keys:array("post_id", "name"),
data:array(
"post_id" => $this->id,
"name" => $name,
"value" => $value
)
);
$post = new self(
null,
array(
"read_from" => array_merge(
$new_values,
array(
"id" => $this->id,
"feather" => $this->feather,
"attribute_names" => $attribute_names,
"attribute_values" => $attribute_values
)
)
)
);
# Notify URLs discovered in the structured feed content.
if (
$config->send_pingbacks and
$pingbacks and
$post->status == self::STATUS_PUBLIC and
feather_enabled($post->feather)
)
webmention_send($post->feed_content(), $post);
if (
$this->status == self::STATUS_SCHEDULED and
$post->status == self::STATUS_PUBLIC
) {
$trigger->call("publish_post", $post, $this, $options);
} else {
$trigger->call("update_post", $post, $this, $options);
}
return $post;
}
/**
* Function: delete
* Deletes a post from the database.
*
* See Also:
* <Model::destroy>
*/
public static function delete($id): void {
parent::destroy(
self::class,
$id,
array("skip_where" => true)
);
SQL::current()->delete(
table:"post_attributes",
conds:array("post_id" => $id)
);
}
/**
* Function: deletable
* Checks if the <User> can delete the post.
*/
public function deletable($user = null): bool {
if ($this->no_results)
return false;
fallback($user, Visitor::current());
if ($user->group->can("delete_post"))
return true;
return (
(
$this->status == self::STATUS_DRAFT and
$user->group->can("delete_draft")
) or
(
$user->group->can("delete_own_post") and
$this->user_id == $user->id
) or
(
$user->group->can("delete_own_draft") and
$this->status == self::STATUS_DRAFT and
$this->user_id == $user->id
)
);
}
/**
* Function: editable
* Checks if the <User> can edit the post.
*/
public function editable($user = null): bool {
if ($this->no_results)
return false;
fallback($user, Visitor::current());
if ($user->group->can("edit_post"))
return true;
return (
(
$this->status == self::STATUS_DRAFT and
$user->group->can("edit_draft")
) or
(
$user->group->can("edit_own_post") and
$this->user_id == $user->id
) or
(
$user->group->can("edit_own_draft") and
$this->status == self::STATUS_DRAFT and
$this->user_id == $user->id
)
);
}
/**
* Function: any_editable
* Checks if the <Visitor> can edit any posts.
*/
public static function any_editable(): bool {
$visitor = Visitor::current();
$sql = SQL::current();
# Can they edit posts?
if ($visitor->group->can("edit_post"))
return true;
# Can they edit drafts?
if ($visitor->group->can("edit_draft") and
$sql->count(
tables:"posts",
conds:array("status" => self::STATUS_DRAFT)
)
)
return true;
# Can they edit their own posts, and do they have any?
if ($visitor->group->can("edit_own_post") and
$sql->count(
tables:"posts",
conds:array("user_id" => $visitor->id)
)
)
return true;
# Can they edit their own drafts, and do they have any?
if ($visitor->group->can("edit_own_draft") and
$sql->count(
tables:"posts",
conds:array(
"status" => self::STATUS_DRAFT,
"user_id" => $visitor->id
)
)
)
return true;
return false;
}
/**
* Function: any_deletable
* Checks if the <Visitor> can delete any posts.
*/
public static function any_deletable(): bool {
$visitor = Visitor::current();
$sql = SQL::current();
# Can they delete posts?
if ($visitor->group->can("delete_post"))
return true;
# Can they delete drafts?
if ($visitor->group->can("delete_draft") and
$sql->count(
tables:"posts",
conds:array("status" => self::STATUS_DRAFT)
)
)
return true;
# Can they delete their own posts, and do they have any?
if ($visitor->group->can("delete_own_post") and
$sql->count(
tables:"posts",
conds:array("user_id" => $visitor->id)
)
)
return true;
# Can they delete their own drafts, and do they have any?
if ($visitor->group->can("delete_own_draft") and
$sql->count(
tables:"posts",
conds:array(
"status" => self::STATUS_DRAFT,
"user_id" => $visitor->id
)
)
)
return true;
return false;
}
/**
* Function: exists
* Checks if a post exists.
*
* Parameters:
* $post_id - The post ID to check
*
* Returns:
* true - if a post with that ID is in the database.
*/
public static function exists($post_id): bool {
return SQL::current()->count(
tables:"posts",
conds:array("id" => $post_id)
) == 1;
}
/**
* Function: check_url
* Checks if a given URL value is already being used as another post's URL.
*
* Parameters:
* $url - The URL to check.
*
* Returns:
* The unique version of the URL value.
* If unused, it's the same as $url. If used, a number is appended to it.
*/
public static function check_url($url): string {
if (empty($url))
return $url;
$count = 1;
$unique = substr($url, 0, 128);
while (
SQL::current()->count(
tables:"posts",
conds:array("url" => $unique)
)
) {
$count++;
$unique = substr($url, 0, (127 - strlen($count)))."-".$count;
}
return $unique;
}
/**
* Function: url
* Returns a post's URL.
*/
public function url(): string|false {
if ($this->no_results)
return false;
$config = Config::current();
$visitor = Visitor::current();
if (!$config->clean_urls)
return fix(
$config->url."/?action=view&url=".urlencode($this->url),
true
);
$login = (strpos($config->post_url, "(author)") !== false) ?
$this->user->login :
"" ;
$vals = array(
when("Y", $this->created_at),
when("m", $this->created_at),
when("d", $this->created_at),
when("H", $this->created_at),
when("i", $this->created_at),
when("s", $this->created_at),
$this->id,
urlencode($login),
urlencode($this->clean),
urlencode($this->url),
urlencode($this->feather),
urlencode(pluralize($this->feather))
);
$post_url = str_replace(
array_keys(self::$url_attrs),
$vals,
$config->post_url
);
return fix($config->url."/".$post_url, true);
}
/**
* Function: title_from_excerpt
* Generates an acceptable title from the post's excerpt.
*
* Returns:
* filtered -> first line -> ftags stripped ->
* truncated to 75 characters -> normalized.
*/
public function title_from_excerpt(): string|false {
if ($this->no_results)
return false;
# The text is likely to have some sort of markup module applied;
# if the current instantiation is not filtered, make one that is.
$post = ($this->filtered) ?
$this :
new self($this->id, array("skip_where" => true)) ;
$excerpt = $post->excerpt();
Trigger::current()->filter($excerpt, "title_from_excerpt");
$stripped = strip_tags($excerpt);
$truncated = truncate($stripped, 75);
$normalized = normalize($truncated);
return ($normalized == "") ? $post->url : $normalized ;
}
/**
* Function: title
* Returns the given post's title, provided by its Feather.
*/
public function title(): string|false {
if ($this->no_results)
return false;
# The text is likely to have some sort of markup module applied;
# if the current instantiation is not filtered, make one that is.
$post = ($this->filtered) ?
$this :
new self($this->id, array("skip_where" => true)) ;
$title = Feathers::$instances[$this->feather]->title($post);
return Trigger::current()->filter($title, "title", $post);
}
/**
* Function: excerpt
* Returns the given post's excerpt, provided by its Feather.
*/
public function excerpt(): string|false {
if ($this->no_results)
return false;
# The text is likely to have some sort of markup module applied;
# if the current instantiation is not filtered, make one that is.
$post = ($this->filtered) ?
$this :
new self($this->id, array("skip_where" => true)) ;
$excerpt = Feathers::$instances[$this->feather]->excerpt($post);
return Trigger::current()->filter($excerpt, "excerpt", $post);
}
/**
* Function: feed_content
* Returns the given post's feed content, provided by its Feather.
*/
public function feed_content(): string|false {
if ($this->no_results)
return false;
# The text is likely to have some sort of markup module applied;
# if the current instantiation is not filtered, make one that is.
$post = ($this->filtered) ?
$this :
new self($this->id, array("skip_where" => true)) ;
$feed_content = Feathers::$instances[$this->feather]->feed_content($post);
return Trigger::current()->filter($feed_content, "feed_content", $post);
}
/**
* Function: next
*
* Returns:
* The next post (the post made before this one).
*/
public function next(): self|false {
if ($this->no_results)
return false;
if (!isset($this->next)) {
$this->next = new self(
null,
array(
"where" => array(
"created_at <" => $this->created_at,
(
$this->status == self::STATUS_DRAFT ?
self::statuses(array(self::STATUS_DRAFT)) :
self::statuses()
)
),
"order" => "created_at DESC, id DESC"
)
);
}
return $this->next;
}
/**
* Function: prev
*
* Returns:
* The previous post (the post made after this one).
*/
public function prev(): self|false {
if ($this->no_results)
return false;
if (!isset($this->prev)) {
$this->prev = new self(
null,
array(
"where" => array(
"created_at >" => $this->created_at,
(
$this->status == self::STATUS_DRAFT ?
self::statuses(array(self::STATUS_DRAFT)) :
self::statuses()
)
),
"order" => "created_at ASC, id ASC"
)
);
}
return $this->prev;
}
/**
* Function: theme_exists
* Checks if the current post's feather theme file exists.
*/
public function theme_exists(): bool {
return (
!$this->no_results and
Theme::current()->file_exists("feathers".DIR.$this->feather)
);
}
/**
* Function: filter
* Filters the post attributes through filter_post and any Feather filters.
*/
private function filter(): void {
$class = camelize($this->feather);
$touched = array();
$trigger = Trigger::current();
$trigger->filter($this, "filter_post");
# Custom filters.
if (isset(Feathers::$custom_filters[$class]))
foreach (Feathers::$custom_filters[$class] as $custom_filter) {
$field = $custom_filter["field"];
$field_unfiltered = $field."_unfiltered";
if (!in_array($field_unfiltered, $touched)) {
$this->$field_unfiltered = isset($this->$field) ?
$this->$field :
null ;
$touched[] = $field_unfiltered;
}
$this->$field = call_user_func_array(
array(
Feathers::$instances[$this->feather],
$custom_filter["name"]
),
array($this->$field, $this)
);
}
# Trigger filters.
if (isset(Feathers::$filters[$class]))
foreach (Feathers::$filters[$class] as $filter) {
$field = $filter["field"];
$field_unfiltered = $field."_unfiltered";
if (!in_array($field_unfiltered, $touched)) {
$this->$field_unfiltered = isset($this->$field) ?
$this->$field :
null ;
$touched[] = $field_unfiltered;
}
if (isset($this->$field) and !empty($this->$field))
$trigger->filter($this->$field, $filter["name"], $this);
}
}
/**
* Function: from_url
* Attempts to grab a post from its clean or dirty URL.
*
* Parameters:
* $request - The request URI to parse.
* $route - The route to respond to, or null to return a Post.
* $options - Additional options for the Post object (optional).
*/
public static function from_url(
$request,
$route = null,
$options = array()
): self|array|false {
$config = Config::current();
# Dirty URL?
if (preg_match("/(\?|&)url=([^&#]+)/", $request, $slug)) {
$post = new self(array("url" => $slug[2]), $options);
return isset($route) ?
$route->try["view"] = array($post) :
$post ;
}
$regex = ""; # Request validity is tested with this.
$attrs = array(); # Post attributes present in post_url.
$found = array(); # Post attributes found in the request.
$parts = preg_split(
"~(\([^)]+\))~",
rtrim($config->post_url, "/"),
0,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);
# Differentiate between post attributes and junk in post_url.
foreach ($parts as $part)
if (isset(self::$url_attrs[$part])) {
$regex .= self::$url_attrs[$part];
$attrs[] = trim($part, "()");
} else {
$regex .= preg_quote($part, "~");
}
# Test the request and return false if it isn't valid.
if (
!preg_match(
"~^$regex(/|$)~",
ltrim(str_replace($config->url, "/", $request), "/"),
$matches
)
)
return false;
# Populate $found using the array of sub-pattern matches.
for ($i = 0; $i < count($attrs); $i++)
$found[$attrs[$i]] = urldecode($matches[$i + 1]);
$where = array();
$dates = array("year", "month", "day", "hour", "minute", "second");
$created_at = array(
"year" => "____",
"month" => "__",
"day" => "__",
"hour" => "__",
"minute" => "__",
"second" => "__"
);
# Conversions of some attributes.
foreach ($found as $part => $value)
if (in_array($part, $dates)) {
# Filter by date/time of creation.
$created_at[$part] = $value;
$where["created_at LIKE"] = (
$created_at["year"]."-".
$created_at["month"]."-".
$created_at["day"]." ".
$created_at["hour"].":".
$created_at["minute"].":".
$created_at["second"]."%"
);
} elseif ($part == "author") {
# Filter by "author" (login).
$user = new User(array("login" => $value));
$where["user_id"] = ($user->no_results) ?
0 :
$user->id ;
} elseif ($part == "feathers") {
# Filter by feather.
$where["feather"] = depluralize($value);
} else {
# Key => Val expression.
$where[$part] = $value;
}
$post = new self(
null,
array_merge($options, array("where" => $where))
);
return isset($route) ?
$route->try["view"] = array($post) :
$post ;
}
/**
* Function: statuses
* Returns a SQL query "chunk" for the "status" column permissions of the current user.
*
* Parameters:
* $start - An array of additional statuses to allow;
* "registered_only", "private" and "scheduled" are added deterministically.
*/
public static function statuses($start = array()): string {
$visitor = Visitor::current();
$statuses = array_merge(array(self::STATUS_PUBLIC), $start);
if (logged_in())
$statuses[] = self::STATUS_REG_ONLY;
if ($visitor->group->can("view_private"))
$statuses[] = self::STATUS_PRIVATE;
if ($visitor->group->can("view_scheduled"))
$statuses[] = self::STATUS_SCHEDULED;
return "(posts.status IN ('".implode("', '", $statuses)."')".
" OR posts.status LIKE '%{".$visitor->group->id."}%')".
" OR (posts.status LIKE '%{%' AND posts.user_id = ".$visitor->id.")";
}
/**
* Function: feathers
* Returns a SQL query "chunk" for the "feather" column so that it matches enabled feathers.
*/
public static function feathers(): string {
$feathers = array();
foreach (Config::current()->enabled_feathers as $feather)
if (feather_enabled($feather))
$feathers[] = $feather;
return "posts.feather IN ('".implode("', '", $feathers)."')";
}
/**
* Function: author
* Returns a post's author. Example: $post->author->name
*/
public function author(): object|false {
if ($this->no_results)
return false;
$author = (!$this->user->no_results) ?
array(
"id" => $this->user->id,
"name" => oneof($this->user->full_name, $this->user->login),
"website" => $this->user->website,
"email" => $this->user->email,
"joined" => $this->user->joined_at
)
:
array(
"id" => 0,
"name" => __("[Guest]"),
"website" => "",
"email" => "",
"joined" => $this->created_at
)
;
return (object) $author;
}
/**
* Function: groups
* Returns the IDs of any groups given viewing permission in the post's status.
*/
public function groups(): array|false {
if ($this->no_results)
return false;
preg_match_all(
"/\{([0-9]+)\}/",
$this->status,
$groups,
PREG_PATTERN_ORDER
);
return empty($groups[1]) ? false : $groups[1] ;
}
/**
* Function: publish_scheduled
* Searches for and publishes scheduled posts.
*
* Calls the @publish_post@ trigger with the updated <Post>.
*/
public static function publish_scheduled($pingbacks = false): void {
$sql = SQL::current();
$ids = $sql->select(
tables:"posts",
fields:"id",
conds:array(
"created_at <=" => datetime(),
"status" => self::STATUS_SCHEDULED
)
)->fetchAll();
foreach ($ids as $id) {
$post = new self(
$id,
array(
"skip_where" => true,
"filter" => false
)
);
$post->update(
status:self::STATUS_PUBLIC,
pingbacks:$pingbacks
);
}
}
}