mirror of
https://github.com/chylex/Nextcloud-News.git
synced 2024-11-24 22:42:46 +01:00
771 lines
20 KiB
PHP
771 lines
20 KiB
PHP
<?php
|
|
/**
|
|
* Nextcloud - News
|
|
*
|
|
* This file is licensed under the Affero General Public License version 3 or
|
|
* later. See the COPYING file.
|
|
*
|
|
* @author Alessandro Cosentino <cosenal@gmail.com>
|
|
* @author Bernhard Posselt <dev@bernhard-posselt.com>
|
|
* @author Paul Tirk <paultirk@paultirk.com>
|
|
* @copyright 2012 Alessandro Cosentino
|
|
* @copyright 2012-2014 Bernhard Posselt
|
|
* @copyright 2020 Paul Tirk
|
|
*/
|
|
|
|
namespace OCA\News\Db;
|
|
|
|
use OCP\AppFramework\Db\Entity;
|
|
|
|
/**
|
|
* Class Item
|
|
*
|
|
* @package OCA\News\Db
|
|
* @Embeddable
|
|
*/
|
|
class Item extends Entity implements IAPI, \JsonSerializable
|
|
{
|
|
use EntityJSONSerializer;
|
|
|
|
/** @var string|null */
|
|
protected $contentHash;
|
|
/** @var string */
|
|
protected $guidHash;
|
|
/** @var string */
|
|
protected $guid;
|
|
/** @var string|null */
|
|
protected $url;
|
|
/** @var string|null */
|
|
protected $title;
|
|
/** @var string|null */
|
|
protected $author;
|
|
/** @var int|null */
|
|
protected $pubDate;
|
|
/** @var string|null */
|
|
protected $body;
|
|
/** @var string|null */
|
|
protected $enclosureMime;
|
|
/** @var string|null */
|
|
protected $enclosureLink;
|
|
/** @var string|null */
|
|
protected $mediaThumbnail;
|
|
/** @var string|null */
|
|
protected $mediaDescription;
|
|
/** @var int */
|
|
protected $feedId;
|
|
/** @var string|null */
|
|
protected $lastModified = '0';
|
|
/** @var string|null */
|
|
protected $searchIndex;
|
|
/** @var bool */
|
|
protected $rtl = false;
|
|
/** @var string|null */
|
|
protected $fingerprint;
|
|
/** @var bool */
|
|
protected $unread = false;
|
|
/** @var bool */
|
|
protected $starred = false;
|
|
/** @var string|null */
|
|
protected $categoriesJson;
|
|
/** @var string|null */
|
|
protected $sharedBy;
|
|
/** @var string|null */
|
|
protected $sharedByDisplayName;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->addType('contentHash', 'string');
|
|
$this->addType('guidHash', 'string');
|
|
$this->addType('guid', 'string');
|
|
$this->addType('url', 'string');
|
|
$this->addType('title', 'string');
|
|
$this->addType('author', 'string');
|
|
$this->addType('pubDate', 'integer');
|
|
$this->addType('body', 'string');
|
|
$this->addType('enclosureMime', 'string');
|
|
$this->addType('enclosureLink', 'string');
|
|
$this->addType('mediaThumbnail', 'string');
|
|
$this->addType('mediaDescription', 'string');
|
|
$this->addType('feedId', 'integer');
|
|
$this->addType('lastModified', 'string');
|
|
$this->addType('searchIndex', 'string');
|
|
$this->addType('rtl', 'boolean');
|
|
$this->addType('fingerprint', 'string');
|
|
$this->addType('unread', 'boolean');
|
|
$this->addType('starred', 'boolean');
|
|
$this->addType('categoriesJson', 'string');
|
|
$this->addType('sharedBy', 'string');
|
|
}
|
|
|
|
public function __clone()
|
|
{
|
|
$this->setId(null);
|
|
|
|
/**
|
|
* Removes 'id' from updatedFields; this will avoid explicitly
|
|
* inserting the value NULL into the DB, and will instead allow a new
|
|
* id to be generated.
|
|
* */
|
|
$this->resetUpdatedFields();
|
|
$this->markFieldUpdated('contentHash');
|
|
$this->markFieldUpdated('guidHash');
|
|
$this->markFieldUpdated('guid');
|
|
$this->markFieldUpdated('url');
|
|
$this->markFieldUpdated('title');
|
|
$this->markFieldUpdated('author');
|
|
$this->markFieldUpdated('pubDate');
|
|
$this->markFieldUpdated('body');
|
|
$this->markFieldUpdated('enclosureMime');
|
|
$this->markFieldUpdated('enclosureLink');
|
|
$this->markFieldUpdated('mediaThumbnail');
|
|
$this->markFieldUpdated('mediaDescription');
|
|
$this->markFieldUpdated('feedId');
|
|
$this->markFieldUpdated('lastModified');
|
|
$this->markFieldUpdated('searchIndex');
|
|
$this->markFieldUpdated('rtl');
|
|
$this->markFieldUpdated('fingerprint');
|
|
$this->markFieldUpdated('unread');
|
|
$this->markFieldUpdated('starred');
|
|
$this->markFieldUpdated('categoriesJson');
|
|
$this->markFieldUpdated('sharedBy');
|
|
}
|
|
|
|
/**
|
|
* @return int
|
|
*/
|
|
public function cropApiLastModified(): int
|
|
{
|
|
$lastModified = $this->getLastModified();
|
|
if (strlen((string)$lastModified) > 10) {
|
|
return (int)substr($lastModified, 0, -6);
|
|
} else {
|
|
return (int)$lastModified;
|
|
}
|
|
}
|
|
|
|
public static function fromImport($import): Item
|
|
{
|
|
$item = new Item();
|
|
$item->setGuid($import['guid']);
|
|
$item->setGuidHash(md5($import['guid']));
|
|
$item->setUrl($import['url']);
|
|
$item->setTitle($import['title']);
|
|
$item->setAuthor($import['author']);
|
|
$item->setPubDate($import['pubDate']);
|
|
$item->setBody($import['body']);
|
|
$item->setEnclosureMime($import['enclosureMime']);
|
|
$item->setEnclosureLink($import['enclosureLink']);
|
|
$item->setMediaThumbnail($import['mediaThumbnail']);
|
|
$item->setMediaDescription($import['mediaDescription']);
|
|
$item->setRtl($import['rtl']);
|
|
$item->setUnread($import['unread']);
|
|
$item->setStarred($import['starred']);
|
|
|
|
return $item;
|
|
}
|
|
|
|
public function generateSearchIndex(): void
|
|
{
|
|
$categoriesString = !is_null($this->getCategories())
|
|
? implode('', $this->getCategories())
|
|
: '';
|
|
|
|
$stripedBody = "";
|
|
if (!is_null($this->getBody())) {
|
|
$stripedBody = strip_tags($this->getBody());
|
|
}
|
|
|
|
$input_list = array($stripedBody, $this->getAuthor(), $this->getTitle(), $categoriesString);
|
|
|
|
$search_string = "";
|
|
|
|
foreach ($input_list as $value) {
|
|
if (!is_null($value)) {
|
|
$search_string .= html_entity_decode($value);
|
|
}
|
|
}
|
|
|
|
$search_string .= $this->getUrl();
|
|
|
|
$this->setSearchIndex(mb_strtolower($search_string, 'UTF-8'));
|
|
$this->setFingerprint($this->computeFingerprint());
|
|
$this->setContentHash($this->computeContentHash());
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getAuthor(): ?string
|
|
{
|
|
return $this->author;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getBody(): ?string
|
|
{
|
|
return $this->body;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getContentHash(): ?string
|
|
{
|
|
return $this->contentHash;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getEnclosureLink(): ?string
|
|
{
|
|
return $this->enclosureLink;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getMediaThumbnail(): ?string
|
|
{
|
|
return $this->mediaThumbnail;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getMediaDescription(): ?string
|
|
{
|
|
return $this->mediaDescription;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getEnclosureMime(): ?string
|
|
{
|
|
return $this->enclosureMime;
|
|
}
|
|
|
|
public function getFeedId(): int
|
|
{
|
|
return $this->feedId;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getFingerprint(): ?string
|
|
{
|
|
return $this->fingerprint;
|
|
}
|
|
|
|
public function getGuid(): string
|
|
{
|
|
return $this->guid;
|
|
}
|
|
|
|
public function getGuidHash(): string
|
|
{
|
|
return $this->guidHash;
|
|
}
|
|
|
|
public function getIntro(): string
|
|
{
|
|
return strip_tags($this->getBody());
|
|
}
|
|
|
|
/**
|
|
* @return string|null
|
|
*/
|
|
public function getLastModified(): ?string
|
|
{
|
|
return $this->lastModified;
|
|
}
|
|
|
|
/**
|
|
* @return int|null
|
|
*/
|
|
public function getPubDate(): ?int
|
|
{
|
|
return $this->pubDate;
|
|
}
|
|
|
|
public function getRtl(): bool
|
|
{
|
|
return $this->rtl;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getSearchIndex(): ?string
|
|
{
|
|
return $this->searchIndex;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getTitle(): ?string
|
|
{
|
|
return $this->title;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getUrl(): ?string
|
|
{
|
|
return $this->url;
|
|
}
|
|
|
|
public function isStarred(): bool
|
|
{
|
|
return $this->starred;
|
|
}
|
|
|
|
public function isUnread(): bool
|
|
{
|
|
return $this->unread;
|
|
}
|
|
|
|
public function getSharedBy(): ?string
|
|
{
|
|
return $this->sharedBy;
|
|
}
|
|
|
|
public function getSharedByDisplayName(): ?string
|
|
{
|
|
return $this->sharedByDisplayName;
|
|
}
|
|
|
|
/**
|
|
* @return null|string
|
|
*/
|
|
public function getCategoriesJson(): ?string
|
|
{
|
|
return $this->categoriesJson;
|
|
}
|
|
|
|
/**
|
|
* @return null|array
|
|
*/
|
|
public function getCategories(): ?array
|
|
{
|
|
if (is_null($this->getCategoriesJson())) {
|
|
return null;
|
|
}
|
|
return json_decode($this->getCategoriesJson());
|
|
}
|
|
|
|
public function getWordCount(): int {
|
|
return self::countWords(strip_tags($this->getBody()));
|
|
}
|
|
|
|
/**
|
|
* Turns entity attributes into an array
|
|
*/
|
|
public function jsonSerialize(): array
|
|
{
|
|
return [
|
|
'id' => $this->getId(),
|
|
'guid' => $this->getGuid(),
|
|
'guidHash' => $this->getGuidHash(),
|
|
'url' => $this->getUrl(),
|
|
'title' => $this->getTitle(),
|
|
'author' => $this->getAuthor(),
|
|
'pubDate' => $this->getPubDate(),
|
|
'updatedDate' => null,
|
|
'body' => $this->getBody(),
|
|
'enclosureMime' => $this->getEnclosureMime(),
|
|
'enclosureLink' => $this->getEnclosureLink(),
|
|
'mediaThumbnail' => $this->getMediaThumbnail(),
|
|
'mediaDescription' => $this->getMediaDescription(),
|
|
'feedId' => $this->getFeedId(),
|
|
'unread' => $this->isUnread(),
|
|
'starred' => $this->isStarred(),
|
|
'lastModified' => $this->getLastModified(),
|
|
'rtl' => $this->getRtl(),
|
|
'intro' => mb_substr($this->getIntro(), 0, 500),
|
|
'fingerprint' => $this->getFingerprint(),
|
|
'categories' => $this->getCategories(),
|
|
'sharedBy' => $this->getSharedBy(),
|
|
'sharedByDisplayName' => $this->getSharedByDisplayName(),
|
|
'wordCount' => $this->getWordCount(),
|
|
];
|
|
}
|
|
|
|
public function setAuthor(string $author = null): self
|
|
{
|
|
if (!is_null($author)) {
|
|
$author = strip_tags($author);
|
|
}
|
|
|
|
if ($this->author !== $author) {
|
|
$this->author = $author;
|
|
$this->markFieldUpdated('author');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setBody(string $body = null): self
|
|
{
|
|
// FIXME: this should not happen if the target="_blank" is already
|
|
// on the link
|
|
$body = str_replace('<a ', '<a target="_blank" rel="noreferrer" ', $body);
|
|
|
|
if ($this->body !== $body) {
|
|
$this->body = $body;
|
|
$this->markFieldUpdated('body');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setContentHash(string $contentHash = null): self
|
|
{
|
|
if ($this->contentHash !== $contentHash) {
|
|
$this->contentHash = $contentHash;
|
|
$this->markFieldUpdated('contentHash');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setEnclosureLink(string $enclosureLink = null): self
|
|
{
|
|
if ($this->enclosureLink !== $enclosureLink) {
|
|
$this->enclosureLink = $enclosureLink;
|
|
$this->markFieldUpdated('enclosureLink');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setEnclosureMime(string $enclosureMime = null): self
|
|
{
|
|
if ($this->enclosureMime !== $enclosureMime) {
|
|
$this->enclosureMime = $enclosureMime;
|
|
$this->markFieldUpdated('enclosureMime');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setMediaThumbnail(string $mediaThumbnail = null): self
|
|
{
|
|
if ($this->mediaThumbnail !== $mediaThumbnail) {
|
|
$this->mediaThumbnail = $mediaThumbnail;
|
|
$this->markFieldUpdated('mediaThumbnail');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setMediaDescription(string $mediaDescription = null): self
|
|
{
|
|
if ($this->mediaDescription !== $mediaDescription) {
|
|
$this->mediaDescription = $mediaDescription;
|
|
$this->markFieldUpdated('mediaDescription');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setFeedId(int $feedId): self
|
|
{
|
|
if ($this->feedId !== $feedId) {
|
|
$this->feedId = $feedId;
|
|
$this->markFieldUpdated('feedId');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setFingerprint(string $fingerprint = null): self
|
|
{
|
|
if ($this->fingerprint !== $fingerprint) {
|
|
$this->fingerprint = $fingerprint;
|
|
$this->markFieldUpdated('fingerprint');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setGuid(string $guid): self
|
|
{
|
|
if ($this->guid !== $guid) {
|
|
$this->guid = $guid;
|
|
$this->markFieldUpdated('guid');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setGuidHash(string $guidHash): self
|
|
{
|
|
if ($this->guidHash !== $guidHash) {
|
|
$this->guidHash = $guidHash;
|
|
$this->markFieldUpdated('guidHash');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setLastModified(string $lastModified = null): self
|
|
{
|
|
if ($this->lastModified !== $lastModified) {
|
|
$this->lastModified = $lastModified;
|
|
$this->markFieldUpdated('lastModified');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setPubDate(int $pubDate = null): self
|
|
{
|
|
if ($this->pubDate !== $pubDate) {
|
|
$this->pubDate = $pubDate;
|
|
$this->markFieldUpdated('pubDate');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setRtl(bool $rtl): self
|
|
{
|
|
if ($this->rtl !== $rtl) {
|
|
$this->rtl = $rtl;
|
|
$this->markFieldUpdated('rtl');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setSearchIndex(string $searchIndex = null): self
|
|
{
|
|
if ($this->searchIndex !== $searchIndex) {
|
|
$this->searchIndex = $searchIndex;
|
|
$this->markFieldUpdated('searchIndex');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setStarred(bool $starred): self
|
|
{
|
|
if ($this->starred !== $starred) {
|
|
$this->starred = $starred;
|
|
$this->markFieldUpdated('starred');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setTitle(string $title = null): self
|
|
{
|
|
if (!is_null($title)) {
|
|
$title = trim(strip_tags($title));
|
|
}
|
|
|
|
if ($this->title !== $title) {
|
|
$this->title = $title;
|
|
$this->markFieldUpdated('title');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setSharedBy(string $sharedBy = null): self
|
|
{
|
|
if ($this->sharedBy !== $sharedBy) {
|
|
$this->sharedBy = $sharedBy;
|
|
$this->markFieldUpdated('sharedBy');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setSharedByDisplayName(string $sharedByDisplayName = null): self
|
|
{
|
|
if ($this->sharedByDisplayName !== $sharedByDisplayName) {
|
|
$this->sharedByDisplayName = $sharedByDisplayName;
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setUnread(bool $unread): self
|
|
{
|
|
if ($this->unread !== $unread) {
|
|
$this->unread = $unread;
|
|
$this->markFieldUpdated('unread');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setUrl(string $url = null): self
|
|
{
|
|
if (is_null($url)) {
|
|
return $this;
|
|
}
|
|
|
|
$url = trim($url);
|
|
if ((strpos($url, 'http') === 0 || strpos($url, 'magnet') === 0)
|
|
&& $this->url !== $url
|
|
) {
|
|
$this->url = $url;
|
|
$this->markFieldUpdated('url');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setCategoriesJson(string $categoriesJson = null): self
|
|
{
|
|
if ($this->categoriesJson !== $categoriesJson) {
|
|
$this->categoriesJson = $categoriesJson;
|
|
$this->markFieldUpdated('categoriesJson');
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setCategories(array $categories = null): self
|
|
{
|
|
$categoriesJson = !is_null($categories) ? json_encode($categories) : null;
|
|
$this->setCategoriesJson($categoriesJson);
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function toAPI(): array
|
|
{
|
|
return [
|
|
'id' => $this->getId(),
|
|
'guid' => $this->getGuid(),
|
|
'guidHash' => $this->getGuidHash(),
|
|
'url' => $this->getUrl(),
|
|
'title' => $this->getTitle(),
|
|
'author' => $this->getAuthor(),
|
|
'pubDate' => $this->getPubDate(),
|
|
'updatedDate' => null,
|
|
'body' => $this->getBody(),
|
|
'enclosureMime' => $this->getEnclosureMime(),
|
|
'enclosureLink' => $this->getEnclosureLink(),
|
|
'mediaThumbnail' => $this->getMediaThumbnail(),
|
|
'mediaDescription' => $this->getMediaDescription(),
|
|
'feedId' => $this->getFeedId(),
|
|
'unread' => $this->isUnread(),
|
|
'starred' => $this->isStarred(),
|
|
'lastModified' => $this->cropApiLastModified(),
|
|
'rtl' => $this->getRtl(),
|
|
'fingerprint' => $this->getFingerprint(),
|
|
'contentHash' => $this->getContentHash()
|
|
];
|
|
}
|
|
|
|
public function toAPI2(bool $reduced = false): array
|
|
{
|
|
if ($reduced) {
|
|
return [
|
|
'id' => $this->getId(),
|
|
'isUnread' => $this->isUnread(),
|
|
'isStarred' => $this->isStarred()
|
|
];
|
|
}
|
|
|
|
return [
|
|
'id' => $this->getId(),
|
|
'url' => $this->getUrl(),
|
|
'title' => $this->getTitle(),
|
|
'author' => $this->getAuthor(),
|
|
'publishedAt' => date('c', $this->getPubDate()),
|
|
'lastModifiedAt' => date('c', $this->cropApiLastModified()),
|
|
'enclosure' => [
|
|
'mimeType' => $this->getEnclosureMime(),
|
|
'url' => $this->getEnclosureLink()
|
|
],
|
|
'body' => $this->getBody(),
|
|
'feedId' => $this->getFeedId(),
|
|
'isUnread' => $this->isUnread(),
|
|
'isStarred' => $this->isStarred(),
|
|
'fingerprint' => $this->getFingerprint(),
|
|
'contentHash' => $this->getContentHash()
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Format for exporting.
|
|
*
|
|
* @param array $feeds List of feeds
|
|
*
|
|
* @return array
|
|
*/
|
|
public function toExport(array $feeds): array
|
|
{
|
|
return [
|
|
'guid' => $this->getGuid(),
|
|
'url' => $this->getUrl(),
|
|
'title' => $this->getTitle(),
|
|
'author' => $this->getAuthor(),
|
|
'pubDate' => $this->getPubDate(),
|
|
'updatedDate' => null,
|
|
'body' => $this->getBody(),
|
|
'enclosureMime' => $this->getEnclosureMime(),
|
|
'enclosureLink' => $this->getEnclosureLink(),
|
|
'mediaThumbnail' => $this->getMediaThumbnail(),
|
|
'mediaDescription' => $this->getMediaDescription(),
|
|
'unread' => $this->isUnread(),
|
|
'starred' => $this->isStarred(),
|
|
'feedLink' => $feeds['feed' . $this->getFeedId()]->getLink(),
|
|
'rtl' => $this->getRtl(),
|
|
];
|
|
}
|
|
|
|
private function computeContentHash(): string
|
|
{
|
|
return md5(
|
|
$this->getTitle() . $this->getUrl() . $this->getBody() .
|
|
$this->getEnclosureLink() . $this->getEnclosureMime() .
|
|
$this->getAuthor()
|
|
);
|
|
}
|
|
|
|
private function computeFingerprint(): string
|
|
{
|
|
return md5(
|
|
$this->getTitle() . $this->getUrl() . $this->getBody() .
|
|
$this->getEnclosureLink()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a given mimetype is supported
|
|
*
|
|
* @param string|null $mime mimetype to check
|
|
*
|
|
* @return boolean
|
|
*/
|
|
public function isSupportedMime(?string $mime): bool
|
|
{
|
|
|
|
return (
|
|
$mime !== null && (
|
|
stripos($mime, 'audio/') !== false ||
|
|
stripos($mime, 'image/') !== false ||
|
|
stripos($mime, 'video/') !== false));
|
|
}
|
|
|
|
private static function countWords(string $text): int
|
|
{
|
|
$text = preg_replace('/[\pP\pM]/u', '', $text); // strip punctuation (P) and combination marks (M)
|
|
$text = preg_replace('/[^\pL\pN]+/u', ' ', $text); // convert every sequence of non-letters (L) and non-numbers (N) to a space
|
|
return 1 + substr_count($text, ' ');
|
|
}
|
|
}
|