* @author Bernhard Posselt * @copyright 2012 Alessandro Cosentino * @copyright 2012-2014 Bernhard Posselt */ namespace OCA\News\Service; use FeedIo\Explorer; use FeedIo\Reader\ReadErrorException; use HTMLPurifier; use OCA\News\Db\FeedMapperV2; use OCA\News\Fetcher\FeedFetcher; use OCA\News\Service\Exceptions\ServiceConflictException; use OCA\News\Service\Exceptions\ServiceNotFoundException; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\DoesNotExistException; use OCA\News\Db\Feed; use OCA\News\Db\Item; use Psr\Log\LoggerInterface; /** * Class FeedService * * @package OCA\News\Service */ class FeedServiceV2 extends Service { /** * Class to fetch feeds. * @var FeedFetcher */ protected $feedFetcher; /** * Items service. * * @var ItemServiceV2 */ protected $itemService; /** * HTML Purifier * @var HTMLPurifier */ protected $purifier; /** * Feed Explorer * @var Explorer */ protected $explorer; /** * FeedService constructor. * * @param FeedMapperV2 $mapper DB layer for feeds * @param FeedFetcher $feedFetcher FeedIO interface * @param ItemServiceV2 $itemService Service to manage items * @param Explorer $explorer Feed Explorer * @param HTMLPurifier $purifier HTML Purifier * @param LoggerInterface $logger Logger */ public function __construct( FeedMapperV2 $mapper, FeedFetcher $feedFetcher, ItemServiceV2 $itemService, Explorer $explorer, HTMLPurifier $purifier, LoggerInterface $logger ) { parent::__construct($mapper, $logger); $this->feedFetcher = $feedFetcher; $this->itemService = $itemService; $this->explorer = $explorer; $this->purifier = $purifier; } /** * Finds all feeds of a user * * @param string $userId the name/ID of the user * @param array $params Filter parameters * * @return Feed[] */ public function findAllForUser(string $userId, array $params = []): array { return $this->mapper->findAllFromUser($userId, $params); } /** * @param int|null $id * * @return Feed[] */ public function findAllFromFolder(?int $id): array { return $this->mapper->findAllFromFolder($id); } /** * Finds all feeds of a user and all items in it * * @param string $userId the name of the user * * @return Feed[] */ public function findAllForUserRecursive(string $userId): array { /** @var Feed[] $feeds */ $feeds = $this->mapper->findAllFromUser($userId); foreach ($feeds as &$feed) { $items = $this->itemService->findAllInFeed($userId, $feed->getId()); $feed->items = $items; } return $feeds; } /** * Finds all feeds * * @return Feed[] */ public function findAll(): array { return $this->mapper->findAll(); } /** * Check if a feed exists for a user * * @param string $userID the name of the user * @param string $url the feed URL * * @return bool */ public function existsForUser(string $userID, string $url): bool { return $this->findByURL($userID, $url) !== null; } /** * Check if a feed exists for a user * * @param string $userID the name of the user * @param string $url the feed URL * * @return Entity|Feed|null */ public function findByURL(string $userID, string $url): ?Entity { try { return $this->mapper->findByURL($userID, $url); } catch (DoesNotExistException $e) { return null; } } /** * Creates a new feed * * @param string $userId Feed owner * @param string $feedUrl Feed URL * @param int|null $folderId Target folder, defaults to root * @param bool $full_text Scrape the feed for full text * @param string|null $title The feed title * @param string|null $user Basic auth username, if set * @param string|null $password Basic auth password if username is set * * @return Feed|Entity * * @throws ServiceConflictException The feed already exists * @throws ServiceNotFoundException The url points to an invalid feed */ public function create( string $userId, string $feedUrl, ?int $folderId = null, bool $full_text = false, ?string $title = null, ?string $user = null, ?string $password = null, bool $full_discover = true ): Entity { if ($this->existsForUser($userId, $feedUrl)) { throw new ServiceConflictException('Feed with this URL exists'); } if ($full_discover) { $feeds = $this->explorer->discover($feedUrl); if ($feeds !== []) { $feedUrl = array_shift($feeds); } } try { /** * @var Feed $feed * @var Item[] $items */ list($feed, $items) = $this->feedFetcher->fetch($feedUrl, $full_text, $user, $password); } catch (ReadErrorException $ex) { $this->logger->debug($ex->getMessage()); throw new ServiceNotFoundException($ex->getMessage()); } if ($feed === null) { throw new ServiceNotFoundException('Failed to fetch feed'); } $feed->setFolderId($folderId) ->setUserId($userId) ->setHttpLastModified(null) ->setArticlesPerUpdate(count($items)); if (!is_null($title)) { $feed->setTitle($title); } if (!is_null($user)) { $feed->setBasicAuthUser($user) ->setBasicAuthPassword($password); } return $this->mapper->insert($feed); } /** * Update a feed * * @param Feed|Entity $feed Feed item * * @return Feed|Entity Database feed entity */ public function fetch(Entity $feed): Entity { if ($feed->getPreventUpdate() === true) { return $feed; } // for backwards compatibility it can be that the location is not set // yet, if so use the url $location = $feed->getLocation() ?? $feed->getUrl(); try { /** * @var Feed $feed * @var Item[] $items */ list($fetchedFeed, $items) = $this->feedFetcher->fetch( $location, $feed->getFullTextEnabled(), $feed->getBasicAuthUser(), $feed->getBasicAuthPassword() ); } catch (ReadErrorException $ex) { $feed->setUpdateErrorCount($feed->getUpdateErrorCount() + 1); $feed->setLastUpdateError($ex->getMessage()); return $this->mapper->update($feed); } // if there is no feed it means that no update took place if (!$fetchedFeed) { return $feed; } // update number of articles on every feed update $itemCount = count($items); // this is needed to adjust to updates that add more items // than when the feed was created. You can't update the count // if it's lower because it may be due to the caching headers // that were sent as the request and it might cause unwanted // deletion and reappearing of feeds if ($itemCount > $feed->getArticlesPerUpdate()) { $feed->setArticlesPerUpdate($itemCount); } $feed->setHttpLastModified($fetchedFeed->getHttpLastModified()) ->setLocation($fetchedFeed->getLocation()); foreach (array_reverse($items) as &$item) { $item->setFeedId($feed->getId()) ->setBody($this->purifier->purify($item->getBody())); // update modes: 0 nothing, 1 set unread if ($feed->getUpdateMode() === Feed::UPDATE_MODE_NORMAL) { $item->setUnread(true); } $item = $this->itemService->insertOrUpdate($item); } // mark feed as successfully updated $feed->setUpdateErrorCount(0); $feed->setLastUpdateError(null); $unreadCount = 0; array_map(function (Item $item) use (&$unreadCount): void { if ($item->isUnread()) { $unreadCount++; } }, $items); return $this->mapper->update($feed)->setUnreadCount($unreadCount); } /** * Remove deleted entities. * * @param string|null $userID The user to purge * @param int|null $minTimestamp The timestamp to purge from * * @return void */ public function purgeDeleted(?string $userID, ?int $minTimestamp): void { $this->mapper->purgeDeleted($userID, $minTimestamp); } /** * Fetch all feeds. * * @see FeedServiceV2::fetch() */ public function fetchAll(): void { foreach ($this->findAll() as $feed) { $this->fetch($feed); } } /** * Mark a feed as read * * @param string $userId Feed owner * @param int $id Feed ID * @param int|null $maxItemID Highest item ID to mark as read * * @return int * * @throws ServiceConflictException * @throws ServiceNotFoundException */ public function read(string $userId, int $id, ?int $maxItemID = null): int { $feed = $this->find($userId, $id); return $this->mapper->read($userId, $feed->getId(), $maxItemID); } }