Files
blog-source/var/Widget/Base/Contents.php
2026-03-04 00:23:03 +08:00

962 lines
26 KiB
PHP
Executable File

<?php
namespace Widget\Base;
use Typecho\Common;
use Typecho\Config;
use Typecho\Cookie;
use Typecho\Date;
use Typecho\Db\Exception;
use Typecho\Db\Query;
use Typecho\Plugin;
use Typecho\Router;
use Typecho\Router\ParamsDelegateInterface;
use Typecho\Widget;
use Utils\AutoP;
use Utils\Markdown;
use Widget\Base;
use Widget\Metas\Category\Rows;
use Widget\Upload;
use Widget\Users\Author;
use Widget\Metas\Category\Related as CategoryRelated;
use Widget\Metas\Tag\Related as TagRelated;
if (!defined('__TYPECHO_ROOT_DIR__')) {
exit;
}
/**
* 内容基类
*
* @property int $cid
* @property string $title
* @property string $slug
* @property int $created
* @property int $modified
* @property string $text
* @property int $order
* @property int $authorId
* @property string $template
* @property string $type
* @property string $status
* @property string|null $password
* @property int $commentsNum
* @property bool $allowComment
* @property bool $allowPing
* @property bool $allowFeed
* @property int $parent
* @property-read Users $author
* @property-read string $permalink
* @property-read string $path
* @property-read string $url
* @property-read string $feedUrl
* @property-read string $feedRssUrl
* @property-read string $feedAtomUrl
* @property-read bool $isMarkdown
* @property-read bool $hidden
* @property-read Date $date
* @property-read string $dateWord
* @property-read string[] $directory
* @property-read array[] $tags
* @property-read array[] $categories
* @property-read string $excerpt
* @property-read string $plainExcerpt
* @property-read string $summary
* @property-read string $content
* @property-read Config $fields
* @property-read Config $attachment
* @property-read string $theId
* @property-read string $respondId
* @property-read string $commentUrl
* @property-read string $trackbackUrl
* @property-read string $responseUrl
* @property-read string $year
* @property-read string $month
* @property-read string $day
*/
class Contents extends Base implements QueryInterface, RowFilterInterface, PrimaryKeyInterface, ParamsDelegateInterface
{
/**
* @return string 获取主键
*/
public function getPrimaryKey(): string
{
return 'cid';
}
/**
* @param string $key
* @return string
*/
public function getRouterParam(string $key): string
{
switch ($key) {
case 'cid':
return $this->cid;
case 'slug':
return urlencode($this->slug);
case 'directory':
return implode('/', array_map('urlencode', $this->directory));
case 'category':
return empty($this->categories) ? '' : urlencode($this->categories[0]['slug']);
case 'year':
return $this->date->year;
case 'month':
return $this->date->month;
case 'day':
return $this->date->day;
default:
return '{' . $key . '}';
}
}
/**
* 获取查询对象
*
* @param mixed $fields
* @return Query
*/
public function select(...$fields): Query
{
return $this->db->select(...$fields)->from('table.contents');
}
/**
* 插入内容
*
* @param array $rows 内容数组
* @return integer
* @throws Exception
*/
public function insert(array $rows): int
{
/** 构建插入结构 */
$insertStruct = [
'title' => !isset($rows['title']) || strlen($rows['title']) === 0
? null : htmlspecialchars($rows['title']),
'created' => empty($rows['created']) ? $this->options->time : $rows['created'],
'modified' => $this->options->time,
'text' => Common::strBy($rows['text'] ?? null),
'order' => empty($rows['order']) ? 0 : intval($rows['order']),
'authorId' => $rows['authorId'] ?? $this->user->uid,
'template' => Common::strBy($rows['template'] ?? null),
'type' => Common::strBy($rows['type'] ?? null, 'post'),
'status' => Common::strBy($rows['status'] ?? null, 'publish'),
'password' => Common::strBy($rows['password'] ?? null),
'commentsNum' => empty($rows['commentsNum']) ? 0 : $rows['commentsNum'],
'allowComment' => !empty($rows['allowComment']) && 1 == $rows['allowComment'] ? 1 : 0,
'allowPing' => !empty($rows['allowPing']) && 1 == $rows['allowPing'] ? 1 : 0,
'allowFeed' => !empty($rows['allowFeed']) && 1 == $rows['allowFeed'] ? 1 : 0,
'parent' => empty($rows['parent']) ? 0 : intval($rows['parent'])
];
if (!empty($rows['cid'])) {
$insertStruct['cid'] = $rows['cid'];
}
/** 首先插入部分数据 */
$insertId = $this->db->query($this->db->insert('table.contents')->rows($insertStruct));
/** 更新缩略名 */
if ($insertId > 0) {
$this->applySlug(Common::strBy($rows['slug'] ?? null), $insertId, $insertStruct['title']);
}
return $insertId;
}
/**
* 为内容应用缩略名
*
* @param string|null $slug 缩略名
* @param mixed $cid 内容id
* @param string $title 标题
* @return string
* @throws Exception
*/
public function applySlug(?string $slug, $cid, string $title = ''): string
{
if ($cid instanceof Query) {
$cid = $this->db->fetchObject($cid->select('cid')
->from('table.contents')->limit(1))->cid;
}
/** 生成一个非空的缩略名 */
if ((!isset($slug) || strlen($slug) === 0) && preg_match_all("/\w+/", $title, $matches)) {
$slug = implode('-', $matches[0]);
}
$slug = Common::slugName($slug, $cid);
$result = $slug;
/** 对草稿的slug做特殊处理 */
$draft = $this->db->fetchObject($this->db->select('type', 'parent')
->from('table.contents')->where('cid = ?', $cid));
if ('revision' === $draft->type && $draft->parent) {
$result = '@' . $result;
}
/** 判断是否在数据库中已经存在 */
$count = 1;
while (
$this->db->fetchObject($this->db->select(['COUNT(cid)' => 'num'])
->from('table.contents')->where('slug = ? AND cid <> ?', $result, $cid))->num > 0
) {
$result = $slug . '-' . $count;
$count++;
}
$this->db->query($this->db->update('table.contents')->rows(['slug' => $result])
->where('cid = ?', $cid));
return $result;
}
/**
* 更新内容
*
* @param array $rows 内容数组
* @param Query $condition 更新条件
* @return integer
* @throws Exception
*/
public function update(array $rows, Query $condition): int
{
/** 首先验证写入权限 */
if (!$this->isWriteable(clone $condition)) {
return 0;
}
/** 构建更新结构 */
$preUpdateStruct = [
'title' => !isset($rows['title']) || strlen($rows['title']) === 0
? null : htmlspecialchars($rows['title']),
'order' => empty($rows['order']) ? 0 : intval($rows['order']),
'text' => Common::strBy($rows['text'] ?? null),
'template' => Common::strBy($rows['template'] ?? null),
'type' => Common::strBy($rows['type'] ?? null, 'post'),
'status' => Common::strBy($rows['status'] ?? null, 'publish'),
'password' => Common::strBy($rows['password'] ?? null),
'allowComment' => !empty($rows['allowComment']) && 1 == $rows['allowComment'] ? 1 : 0,
'allowPing' => !empty($rows['allowPing']) && 1 == $rows['allowPing'] ? 1 : 0,
'allowFeed' => !empty($rows['allowFeed']) && 1 == $rows['allowFeed'] ? 1 : 0,
'parent' => empty($rows['parent']) ? 0 : intval($rows['parent'])
];
$updateStruct = [];
foreach ($rows as $key => $val) {
if (array_key_exists($key, $preUpdateStruct)) {
$updateStruct[$key] = $preUpdateStruct[$key];
}
}
/** 更新创建时间 */
if (isset($rows['created'])) {
$updateStruct['created'] = $rows['created'];
}
$updateStruct['modified'] = $this->options->time;
/** 首先插入部分数据 */
$updateCondition = clone $condition;
$updateRows = $this->db->query($condition->update('table.contents')->rows($updateStruct));
/** 更新缩略名 */
if ($updateRows > 0 && isset($rows['slug'])) {
$this->applySlug(strlen($rows['slug']) === 0 ? null : $rows['slug'], $updateCondition);
}
return $updateRows;
}
/**
* 内容是否可以被修改
*
* @param Query $condition 条件
* @return bool
* @throws Exception
*/
public function isWriteable(Query $condition): bool
{
$post = $this->db->fetchRow($condition->select('authorId')->from('table.contents')->limit(1));
return $post && ($this->user->pass('editor', true) || $post['authorId'] == $this->user->uid);
}
/**
* 删除内容
*
* @param Query $condition 查询对象
* @return integer
* @throws Exception
*/
public function delete(Query $condition): int
{
return $this->db->query($condition->delete('table.contents'));
}
/**
* 按照条件计算内容数量
*
* @param Query $condition 查询对象
* @return integer
* @throws Exception
*/
public function size(Query $condition): int
{
return $this->db->fetchObject($condition
->select(['COUNT(DISTINCT table.contents.cid)' => 'num'])
->from('table.contents')
->cleanAttribute('group'))->num;
}
/**
* 获取当前所有自定义模板
*
* @return array
*/
public function getTemplates(): array
{
$files = glob($this->options->themeFile($this->options->theme, '*.php'));
$result = [];
foreach ($files as $file) {
$info = Plugin::parseInfo($file);
$file = basename($file);
if ('index.php' != $file && 'custom' == $info['title']) {
$result[$file] = $info['description'];
}
}
return $result;
}
/**
* 将每行的值压入堆栈
*
* @param array $value 每行的值
* @return array
*/
public function push(array $value): array
{
$value = $this->filter($value);
return parent::push($value);
}
/**
* 通用过滤器
*
* @param array $row 需要过滤的行数据
* @return array
*/
public function filter(array $row): array
{
/** 处理默认空值 */
$row['title'] = $row['title'] ?? '';
$row['text'] = $row['text'] ?? '';
$row['slug'] = $row['slug'] ?? '';
$row['password'] = $row['password'] ?? '';
$row['date'] = new Date($row['created']);
return Contents::pluginHandle()->filter('filter', $row, $this);
}
/**
* 输出文章发布日期
*
* @param string|null $format 日期格式
*/
public function date(?string $format = null)
{
echo $this->date->format(empty($format) ? $this->options->postDateFormat : $format);
}
/**
* 输出文章内容
*
* @param mixed $more 文章截取后缀
*/
public function content($more = false)
{
echo false !== $more && false !== strpos($this->text, '<!--more-->') ?
$this->excerpt
. "<p class=\"more\"><a href=\"{$this->permalink}\" title=\"{$this->title}\">{$more}</a></p>"
: $this->content;
}
/**
* 输出文章摘要
*
* @param integer $length 摘要截取长度
* @param string $trim 摘要后缀
*/
public function excerpt(int $length = 100, string $trim = '...')
{
echo Common::subStr(strip_tags($this->excerpt), 0, $length, $trim);
}
/**
* 输出标题
*
* @param integer $length 标题截取长度
* @param string $trim 截取后缀
*/
public function title(int $length = 0, string $trim = '...')
{
$title = Contents::pluginHandle()->trigger($plugged)->filter('title', $this->title, $this);
if (!$plugged) {
echo $length > 0 ? Common::subStr($this->title, 0, $length, $trim) : $this->title;
} else {
echo $title;
}
}
/**
* 输出文章评论数
*
* @param ...$args
*/
public function commentsNum(...$args)
{
if (empty($args)) {
$args[] = '%d';
}
$num = intval($this->commentsNum);
echo sprintf($args[$num] ?? array_pop($args), $num);
}
/**
* 获取文章权限
*
* @param ...$permissions
*/
public function allow(...$permissions): bool
{
$allow = true;
foreach ($permissions as $permission) {
$permission = strtolower($permission);
if ('edit' == $permission) {
$allow &= ($this->user->pass('editor', true) || $this->authorId == $this->user->uid);
} else {
/** 对自动关闭反馈功能的支持 */
if (
('ping' == $permission || 'comment' == $permission) && $this->options->commentsPostTimeout > 0 &&
$this->options->commentsAutoClose
) {
if ($this->options->time - $this->created > $this->options->commentsPostTimeout) {
return false;
}
}
$allow &= ($this->row['allow' . ucfirst($permission)] == 1) && !$this->hidden;
}
}
return $allow;
}
/**
* 输出文章分类
*
* @param string $split 多个分类之间分隔符
* @param boolean $link 是否输出链接
* @param string|null $default 如果没有则输出
*/
public function category(string $split = ',', bool $link = true, ?string $default = null)
{
if (!empty($this->categories)) {
$result = [];
foreach ($this->categories as $category) {
$result[] = $link ? "<a href=\"{$category['permalink']}\">{$category['name']}</a>" : $category['name'];
}
echo implode($split, $result);
} else {
echo $default;
}
}
/**
* 输出文章多级分类
*
* @param string $split 多个分类之间分隔符
* @param boolean $link 是否输出链接
* @param string|null $default 如果没有则输出
* @throws Widget\Exception
*/
public function directory(string $split = '/', bool $link = true, ?string $default = null)
{
$category = $this->categories[0];
$directory = Rows::alloc()->getAllParents($category['mid']);
$directory[] = $category;
if ($directory) {
$result = [];
foreach ($directory as $category) {
$result[] = $link ? '<a href="' . $category['permalink'] . '">'
. $category['name'] . '</a>' : $category['name'];
}
echo implode($split, $result);
} else {
echo $default;
}
}
/**
* 输出文章标签
*
* @param string $split 多个标签之间分隔符
* @param boolean $link 是否输出链接
* @param string|null $default 如果没有则输出
*/
public function tags(string $split = ',', bool $link = true, ?string $default = null)
{
if (!empty($this->tags)) {
$result = [];
foreach ($this->tags as $tag) {
$result[] = $link ? "<a href=\"{$tag['permalink']}\">{$tag['name']}</a>" : $tag['name'];
}
echo implode($split, $result);
} else {
echo $default;
}
}
/**
* 输出当前作者
*
* @param string $item 需要输出的项目
*/
public function author(string $item = 'screenName')
{
if ($this->have()) {
echo $this->author->{$item};
}
}
/**
* @return string
*/
protected function ___title(): string
{
return $this->hidden ? _t('此内容被密码保护') : $this->row['title'];
}
/**
* @return string
*/
protected function ___text(): string
{
if ('attachment' == $this->type) {
if ($this->attachment->isImage) {
return '<img src="' . $this->attachment->url . '" alt="' .
$this->title . '" />';
} else {
return '<a href="' . $this->attachment->url . '" title="' .
$this->title . '">' . $this->title . '</a>';
}
} elseif ($this->hidden) {
return '<form class="protected" action="' . $this->security->getTokenUrl($this->permalink)
. '" method="post">' .
'<p class="word">' . _t('请输入密码访问') . '</p>' .
'<p><input type="password" class="text" name="protectPassword" />
<input type="hidden" name="protectCID" value="' . $this->cid . '" />
<input type="submit" class="submit" value="' . _t('提交') . '" /></p>' .
'</form>';
}
return $this->isMarkdown ? substr($this->row['text'], 15) : $this->row['text'];
}
/**
* @return bool
*/
protected function ___isMarkdown(): bool
{
return 0 === strpos($this->row['text'], '<!--markdown-->');
}
/**
* 是否为隐藏文章
*
* @return bool
*/
protected function ___hidden(): bool
{
if (
strlen($this->password) > 0 &&
$this->password !== Cookie::get('protectPassword_' . $this->cid) &&
$this->authorId != $this->user->uid &&
!$this->user->pass('editor', true)
) {
return true;
}
return false;
}
/**
* @return string
*/
protected function ___path(): string
{
return Router::url($this->type, $this);
}
/**
* @return string
*/
protected function ___permalink(): string
{
return Common::url($this->path, $this->options->index);
}
/**
* @return string
*/
protected function ___url(): string
{
return $this->permalink;
}
/**
* @return string
*/
protected function ___feedUrl(): string
{
return Router::url($this->type, $this, $this->options->feedUrl);
}
/**
* @return string
*/
protected function ___feedRssUrl(): string
{
return Router::url($this->type, $this, $this->options->feedRssUrl);
}
/**
* @return string
*/
protected function ___feedAtomUrl(): string
{
return Router::url($this->type, $this, $this->options->feedAtomUrl);
}
/**
* 多级目录结构
*
* @return array
*/
protected function ___directory(): array
{
$directory = [];
if (!empty($this->categories)) {
$directory = Rows::alloc()->getAllParentsSlug($this->categories[0]['mid']);
$directory[] = $this->categories[0]['slug'];
}
return $directory;
}
/**
* @return string
*/
protected function ___category(): string
{
return empty($this->categories) ? '' : $this->categories[0]['slug'];
}
/**
* @return array
*/
protected function ___categories(): array
{
return CategoryRelated::allocWithAlias($this->cid, ['cid' => $this->cid])
->toArray(['mid', 'name', 'slug', 'description', 'order', 'parent', 'count', 'permalink']);
}
/**
* 将tags取出
*
* @return array
*/
protected function ___tags(): array
{
return TagRelated::allocWithAlias($this->cid, ['cid' => $this->cid])
->toArray(['mid', 'name', 'slug', 'description', 'count', 'permalink']);
}
/**
* 文章作者
*
* @return Users
*/
protected function ___author(): Users
{
return Author::allocWithAlias($this->cid, ['uid' => $this->authorId]);
}
/**
* 获取词义化日期
*
* @return string
*/
protected function ___dateWord(): string
{
return $this->date->word();
}
/**
* 对文章的简短纯文本描述
*
* @deprecated
* @return string|null
*/
protected function ___description(): ?string
{
return $this->plainExcerpt;
}
/**
* @return Config|null
*/
protected function ___attachment(): ?Config
{
if ('attachment' == $this->type) {
$content = json_decode($this->row['text'], true);
//增加数据信息
$attachment = new Config($content);
$attachment->isImage = in_array($content['type'], [
'jpg', 'jpeg', 'gif', 'png', 'tiff', 'bmp', 'webp', 'avif'
]);
$attachment->url = Upload::attachmentHandle($attachment);
return $attachment;
}
return null;
}
/**
* ___fields
*
* @return Config
* @throws Exception
*/
protected function ___fields(): Config
{
$fields = [];
$rows = $this->db->fetchAll($this->db->select()->from('table.fields')
->where('cid = ?', $this->cid));
foreach ($rows as $row) {
$value = 'json' == $row['type'] ? json_decode($row['str_value'], true) : $row[$row['type'] . '_value'];
$fields[$row['name']] = $value;
}
return new Config($fields);
}
/**
* 获取文章内容摘要
*
* @return string|null
*/
protected function ___excerpt(): ?string
{
if ($this->hidden) {
return $this->text;
}
$content = Contents::pluginHandle()->filter('excerpt', $this->content, $this);
[$excerpt] = explode('<!--more-->', $content);
return Common::fixHtml(Contents::pluginHandle()->filter('excerptEx', $excerpt, $this));
}
/**
* 对文章的简短纯文本描述
*
* @return string|null
*/
protected function ___plainExcerpt(): ?string
{
$plainText = str_replace("\n", '', trim(strip_tags($this->excerpt)));
$plainText = $plainText ?: $this->title;
return Common::subStr($plainText, 0, 100);
}
/**
* markdown
*
* @param string|null $text
* @return string|null
*/
protected function markdown(?string $text): ?string
{
$html = Contents::pluginHandle()->trigger($parsed)->filter('markdown', $text);
if (!$parsed) {
$html = Markdown::convert($text);
}
return $html;
}
/**
* autoP
*
* @param string|null $text
* @return string|null
*/
protected function autoP(?string $text): ?string
{
$html = Contents::pluginHandle()->trigger($parsed)->filter('autoP', $text);
if (!$parsed && $text) {
static $parser;
if (empty($parser)) {
$parser = new AutoP();
}
$html = $parser->parse($text);
}
return $html;
}
/**
* 获取文章内容
*
* @return string|null
*/
protected function ___content(): ?string
{
if ($this->hidden) {
return $this->text;
}
$content = Contents::pluginHandle()->trigger($plugged)->filter('content', $this->text, $this);
if (!$plugged) {
$content = $this->isMarkdown ? $this->markdown($content)
: $this->autoP($content);
}
return Contents::pluginHandle()->filter('contentEx', $content, $this);
}
/**
* 输出文章的第一行作为摘要
*
* @return string|null
*/
protected function ___summary(): ?string
{
$content = $this->content;
$parts = preg_split("/(<\/\s*(?:p|blockquote|q|pre|table)\s*>)/i", $content, 2, PREG_SPLIT_DELIM_CAPTURE);
if (!empty($parts)) {
$content = $parts[0] . $parts[1];
}
return $content;
}
/**
* 锚点id
*
* @return string
*/
protected function ___theId(): string
{
return $this->type . '-' . $this->cid;
}
/**
* 回复框id
*
* @return string
*/
protected function ___respondId(): string
{
return 'respond-' . $this->theId;
}
/**
* 评论地址
*
* @return string
*/
protected function ___commentUrl(): string
{
/** 生成反馈地址 */
/** 评论 */
return Router::url(
'feedback',
['type' => 'comment', 'permalink' => $this->path],
$this->options->index
);
}
/**
* trackback地址
*
* @return string
*/
protected function ___trackbackUrl(): string
{
return Router::url(
'feedback',
['type' => 'trackback', 'permalink' => $this->path],
$this->options->index
);
}
/**
* 回复地址
*
* @return string
*/
protected function ___responseUrl(): string
{
return $this->permalink . '#' . $this->respondId;
}
/**
* @return string
*/
protected function ___year(): string
{
return $this->date->year;
}
/**
* @return string
*/
protected function ___month(): string
{
return $this->date->month;
}
/**
* @return string
*/
protected function ___day(): string
{
return $this->date->day;
}
}