user_id = $_SESSION[Key::USER_ID]; $it->url = $parsed->url; $it->title = $parsed->title; $it->updated_on = $parsed->updatedOn; $it->checked_on = Data::formatDate('now'); return $it; } /** * Update a feed's items * * @param int $feedId The ID of the feed to which these items belong * @param ParsedFeed $parsed The extracted Atom or RSS feed items * @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added) * @return array ['ok' => true] if successful, ['error' => message] if not */ public static function updateItems(int $feedId, ParsedFeed $parsed, DateTimeInterface $lastChecked): array { $results = array_map(function ($item) use ($feedId) { try { $existing = Find::firstByFields(Table::ITEM, [Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class); if ($existing) { if ($existing->published_on != $item->publishedOn || ($existing->updated_on != ($item->updatedOn ?? ''))) { Patch::byId(Table::ITEM, $existing->id, $item->patchFields()); } } else { Document::insert(Table::ITEM, Item::fromFeedItem($feedId, $item)); } return ['ok' => true]; } catch (DocumentException $ex) { return ['error' => "$ex"]; } }, array_filter($parsed->items, fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked)); $errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it))); return sizeof($errors) > 0 ? ['error' => implode("\n", $errors)] : ['ok' => true]; } /** * Purge items for a feed * * @param int $feedId The ID of the feed to be purged * @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not */ private static function purgeItems(int $feedId): array { if (!array_search(PURGE_TYPE, [self::PURGE_READ, self::PURGE_BY_DAYS, self::PURGE_BY_COUNT])) { return ['error' => 'Unrecognized purge type ' . PURGE_TYPE]; } $fields = [Field::EQ('feed_id', $feedId, ':feed'), Data::bookmarkField(false)]; $sql = Query\Delete::byFields(Table::ITEM, $fields); if (PURGE_TYPE == self::PURGE_READ) { $readField = Field::EQ('is_read', 1, ':read'); $fields[] = $readField; $sql .= ' AND ' . Query::whereByFields([$readField]); } elseif (PURGE_TYPE == self::PURGE_BY_DAYS) { $fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), ':oldest'); $sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on)) < date(:oldest)"; } elseif (PURGE_TYPE == self::PURGE_BY_COUNT) { $fields[] = Field::EQ('', PURGE_NUMBER, ':keep'); $id = Configuration::$idField; $table = Table::ITEM; $sql .= ' ' . <<>'$id' IN ( SELECT data->>'$id' FROM $table WHERE data->>'feed_id' = :feed ORDER BY date(coalesce(data->>'updated_on', data->>'published_on')) DESC LIMIT -1 OFFSET :keep ) SQL; } try { Custom::nonQuery($sql, Parameters::addFields($fields, [])); return ['ok' => true]; } catch (DocumentException $ex) { return ['error' => "$ex"]; } } /** * Refresh a feed * * @param int $feedId The ID of the feed to be refreshed * @param string $url The URL of the feed to be refreshed * @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not */ public static function refreshFeed(int $feedId, string $url): array { $feedRetrieval = ParsedFeed::retrieve($url); if (key_exists('error', $feedRetrieval)) return $feedRetrieval; $feed = $feedRetrieval['ok']; try { $feedDoc = Find::byId(Table::FEED, $feedId, self::class); if (!$feedDoc) return ['error' => 'Could not derive date last checked for feed']; $lastChecked = date_create_immutable($feedDoc->checked_on ?? WWW_EPOCH); $itemUpdate = self::updateItems($feedId, $feed, $lastChecked); if (key_exists('error', $itemUpdate)) return $itemUpdate; $patch = [ 'title' => $feed->title, 'updated_on' => $feed->updatedOn, 'checked_on' => Data::formatDate('now') ]; if ($url == $feed->url) $patch['url'] = $feed->url; Patch::byId(Table::FEED, $feedId, $patch); } catch (DocumentException $ex) { return ['error' => "$ex"]; } return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId); } /** * Add an RSS feed * * @param string $url The URL of the RSS feed to add * @return array ['ok' => feedId] if successful, ['error' => message] if not */ public static function add(string $url): array { $feedExtract = ParsedFeed::retrieve($url); if (key_exists('error', $feedExtract)) return $feedExtract; $feed = $feedExtract['ok']; try { $fields = [Field::EQ('user_id', $_SESSION[Key::USER_ID]), Field::EQ('url', $feed->url)]; if (Exists::byFields(Table::FEED, $fields)) { return ['error' => "Already subscribed to feed $feed->url"]; } Document::insert(Table::FEED, self::fromParsed($feed)); $doc = Find::firstByFields(Table::FEED, $fields, static::class); if (!$doc) return ['error' => 'Could not retrieve inserted feed']; $result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH)); if (key_exists('error', $result)) return $result; return ['ok' => $doc->id]; } catch (DocumentException $ex) { return ['error' => "$ex"]; } } /** * Update an RSS feed * * @param Feed $existing The existing feed * @param string $url The URL with which the existing feed should be modified * @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not */ public static function update(Feed $existing, string $url): array { try { Patch::byFields(Table::FEED, [Field::EQ(Configuration::$idField, $existing->id), Field::EQ('user_id', $_SESSION[Key::USER_ID])], ['url' => $url]); return self::refreshFeed($existing->id, $url); } catch (DocumentException $ex) { return ['error' => "$ex"]; } } /** * Retrieve all feeds, optionally for a specific user * * @param int $user The ID of the user whose feeds should be retrieved (optional, defaults to all feeds) * @return DocumentList A list of feeds * @throws DocumentException If any is encountered */ public static function retrieveAll(int $user = 0): DocumentList { return $user == 0 ? Find::all(Table::FEED, self::class) : Find::byFields(Table::FEED, [Field::EQ('user_id', $user)], self::class); } /** * Refresh all feeds * * @return array|true[]|string[] ['ok' => true] if successful, * ['error' => message] if not (may have multiple error lines) */ public static function refreshAll(): array { try { $feeds = self::retrieveAll($_SESSION[Key::USER_ID]); $errors = []; foreach ($feeds->items() as $feed) { $result = self::refreshFeed($feed->id, $feed->url); if (key_exists('error', $result)) $errors[] = $result['error']; } } catch (DocumentException $ex) { return ['error' => "$ex"]; } return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)]; } /** * Retrieve a feed by its ID for the current user * * @param int $feedId The ID of the feed to retrieve * @return static|false The data for the feed if found, false if not found * @throws DocumentException If any is encountered */ public static function retrieveById(int $feedId): static|false { $doc = Find::byId(Table::FEED, $feedId, static::class); return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false; } }