From da9a569e4ac3dc1352c14be0a6c750e70c04dfb3 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 11 Apr 2024 22:01:36 -0400 Subject: [PATCH] Add editing of feed URL (#4) - Move feed-specific database calls to Feed class - Detect when feed items have been updated - Add const keys for $_REQUEST values --- src/lib/Data.php | 119 ++++++++---------------- src/lib/Feed.php | 211 +++++++++++++++++++++++++++++++++++-------- src/lib/Key.php | 13 +++ src/lib/Security.php | 6 +- src/public/feed.php | 34 +++++-- src/public/index.php | 6 +- src/start.php | 30 ++++-- 7 files changed, 275 insertions(+), 144 deletions(-) create mode 100644 src/lib/Key.php diff --git a/src/lib/Data.php b/src/lib/Data.php index 59b9595..7ac179d 100644 --- a/src/lib/Data.php +++ b/src/lib/Data.php @@ -8,7 +8,7 @@ class Data { * Obtain a new connection to the database * @return SQLite3 A new connection to the database */ - private static function getConnection(): SQLite3 { + public static function getConnection(): SQLite3 { $db = new SQLite3('../data/' . DATABASE_NAME); $db->exec('PRAGMA foreign_keys = ON;'); return $db; @@ -74,15 +74,19 @@ class Data { */ public static function findUserByEmail(string $email): ?array { $db = self::getConnection(); - $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); - $query->bindValue(':email', $email); - $result = $query->execute(); - if ($result) { - $user = $result->fetchArray(SQLITE3_ASSOC); - if ($user) return $user; + try { + $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); + $query->bindValue(':email', $email); + $result = $query->execute(); + if ($result) { + $user = $result->fetchArray(SQLITE3_ASSOC); + if ($user) return $user; + return null; + } return null; + } finally { + $db->close(); } - return null; } /** @@ -93,10 +97,14 @@ class Data { */ public static function addUser(string $email, string $password): void { $db = self::getConnection(); - $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)'); - $query->bindValue(':email', $email); - $query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT)); - $query->execute(); + try { + $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)'); + $query->bindValue(':email', $email); + $query->bindValue(':password', password_hash($password, PASSWORD_DEFAULT)); + $query->execute(); + } finally { + $db->close(); + } } /** @@ -105,7 +113,7 @@ class Data { * @param ?string $value The date/time to be parsed and formatted * @return string|null The date/time in `DateTimeInterface::ATOM` format, or `null` if the input cannot be parsed */ - private static function formatDate(?string $value): ?string { + public static function formatDate(?string $value): ?string { try { return $value ? (new DateTimeImmutable($value))->format(DateTimeInterface::ATOM) : null; } catch (Exception) { @@ -114,77 +122,22 @@ class Data { } /** - * Add an RSS feed + * Retrieve a feed by its ID for the current user * - * @param string $url The URL for the RSS feed - * @param string $title The title of the RSS feed - * @param ?string $updatedOn The date/time the RSS feed was last updated (from the XML, not when we checked) - * @return int The ID of the added feed + * @param int $feedId The ID of the feed to retrieve + * @param ?SQLite3 $dbConn A database connection to use (optional; will use standalone if not provided) + * @return array|bool The data for the feed if found, false if not found */ - public static function addFeed(string $url, string $title, ?string $updatedOn): int { - $db = self::getConnection(); - $query = $db->prepare(<<<'SQL' - INSERT INTO feed ( - user_id, url, title, updated_on, checked_on - ) VALUES ( - :user, :url, :title, :updated, :checked - ) - SQL); - $query->bindValue(':user', $_REQUEST['FRC_USER_ID']); - $query->bindValue(':url', $url); - $query->bindValue(':title', $title); - $query->bindValue(':updated', self::formatDate($updatedOn)); - $query->bindValue(':checked', self::formatDate('now')); - $result = $query->execute(); - return $result ? $db->lastInsertRowID() : -1; - } - - /** - * Does a feed item already exist? - * - * @param int $feedId The ID of the feed to which the item belongs - * @param string $guid The GUID from the RSS feed, uniquely identifying the item - * @return bool True if the item exists, false if not - */ - public static function itemExists(int $feedId, string $guid): bool { - $db = self::getConnection(); - $query = $db->prepare('SELECT COUNT(*) FROM item WHERE feed_id = :feed AND item_guid = :guid'); - $query->bindValue(':feed', $feedId); - $query->bindValue(':guid', $guid); - $result = $query->execute(); - return $result && $result->fetchArray(SQLITE3_NUM)[0] == 1; - } - - /** - * Add a feed item - * - * @param int $feedId The ID of the feed to which the item should be added - * @param string $guid The GUID from the RSS feed (uses link if `` not specified) - * @param string $link The link to this item - * @param string $title The title of the item - * @param string $publishedOn The date/time the item was published - * @param ?string $updatedOn The date/time the item was last updated - * @param string $content The content of the item - * @param bool $isEncoded Whether the content has HTML (true) or is plaintext (false) - */ - public static function addItem(int $feedId, string $guid, string $link, string $title, string $publishedOn, - ?string $updatedOn, string $content, bool $isEncoded): void { - $db = self::getConnection(); - $query = $db->prepare(<<<'SQL' - INSERT INTO item ( - feed_id, item_guid, item_link, title, published_on, updated_on, content, is_encoded - ) VALUES ( - :feed, :guid, :link, :title, :published, :updated, :content, :encoded - ) - SQL); - $query->bindValue(':feed', $feedId); - $query->bindValue(':guid', $guid); - $query->bindValue(':link', $link); - $query->bindValue(':title', $title); - $query->bindValue(':published', self::formatDate($publishedOn)); - $query->bindValue(':updated', self::formatDate($updatedOn)); - $query->bindValue(':content', $content); - $query->bindValue(':encoded', $isEncoded); - $query->execute(); + public static function retrieveFeedById(int $feedId, ?SQLite3 $dbConn = null): array|bool { + $db = $dbConn ?? self::getConnection(); + try { + $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); + $query->bindValue(':id', $feedId); + $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $result = $query->execute(); + return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; + } finally { + if (is_null($dbConn)) $db->close(); + } } } diff --git a/src/lib/Feed.php b/src/lib/Feed.php index 70574d8..cfbcfbd 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -29,16 +29,16 @@ class Feed { * Parse a feed into an XML tree * * @param string $content The feed's RSS content - * @return array|DOMDocument[]|string[] [ 'ok' => feed ] if successful, [ 'error' => message] if not + * @return array|DOMDocument[]|string[] ['ok' => feed] if successful, ['error' => message] if not */ public static function parseFeed(string $content): array { set_error_handler(self::xmlParseError(...)); try { $feed = new DOMDocument(); $feed->loadXML($content); - return [ 'ok' => $feed ]; + return ['ok' => $feed]; } catch (DOMException $ex) { - return [ 'error' => $ex->getMessage() ]; + return ['error' => $ex->getMessage()]; } finally { restore_error_handler(); } @@ -48,8 +48,8 @@ class Feed { * Retrieve the feed * * @param string $url - * @return array|DOMDocument[]|string[] [ 'ok' => feedXml, 'url' => actualUrl ] if successful, - * [ 'error' => message ] if not + * @return array|DOMDocument[]|string[] ['ok' => feedXml, 'url' => actualUrl] if successful, ['error' => message] if + * not */ public static function retrieveFeed(string $url): array { $feedReq = curl_init($url); @@ -93,53 +93,123 @@ class Feed { return $tags->length == 0 ? "$tagName not found" : $tags->item(0)->textContent; } + /** + * Extract the fields we need to keep from the feed + * + * @param DOMElement $item The item from the feed + * @return array The fields for the item as an associative array + */ + private static function itemFields(DOMElement $item): array { + $itemGuid = self::eltValue($item, 'guid'); + $updNodes = $item->getElementsByTagNameNS(self::ATOM_NS, 'updated'); + $encNodes = $item->getElementsByTagNameNS(self::CONTENT_NS, 'encoded'); + return [ + 'guid' => $itemGuid == 'guid not found' ? self::eltValue($item, 'link') : $itemGuid, + 'title' => self::eltValue($item, 'title'), + 'link' => self::eltValue($item, 'link'), + 'published' => Data::formatDate(self::eltValue($item, 'pubDate')), + 'updated' => Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null), + 'content' => $encNodes->length > 0 ? $encNodes->item(0)->textContent + : self::eltValue($item, 'description'), + 'isEncoded' => $encNodes->length > 0 + ]; + } + + /** + * Update a feed item + * + * @param int $itemId The ID of the item to be updated + * @param array $item The fields from the updated item + * @param SQLite3 $db A database connection to use for the update + */ + private static function updateItem(int $itemId, array $item, SQLite3 $db): void { + $query = $db->prepare(<<<'SQL' + UPDATE item + SET title = :title, + published_on = :published, + updated_on = :updated, + content = :content, + is_encoded = :encoded, + is_read = 0 + WHERE id = :id + SQL); + $query->bindValue(':title', $item['title']); + $query->bindValue(':published', $item['published']); + $query->bindValue(':updated', $item['updated']); + $query->bindValue(':content', $item['content']); + $query->bindValue(':encoded', $item['isEncoded']); + $query->bindValue(':id', $itemId); + $query->execute(); + } + + /** + * Add a feed item + * + * @param int $feedId The ID of the feed to which the item should be added + * @param array $item The fields for the item + * @param SQLite3 $db A database connection to use for the addition + */ + private static function addItem(int $feedId, array $item, SQLite3 $db): void { + $query = $db->prepare(<<<'SQL' + INSERT INTO item ( + feed_id, item_guid, item_link, title, published_on, updated_on, content, is_encoded + ) VALUES ( + :feed, :guid, :link, :title, :published, :updated, :content, :encoded + ) + SQL); + $query->bindValue(':feed', $feedId); + $query->bindValue(':guid', $item['guid']); + $query->bindValue(':link', $item['link']); + $query->bindValue(':title', $item['title']); + $query->bindValue(':published', $item['published']); + $query->bindValue(':updated', $item['updated']); + $query->bindValue(':content', $item['content']); + $query->bindValue(':encoded', $item['isEncoded']); + $query->execute(); + } + /** * Update a feed's items * * @param int $feedId The ID of the feed to which these items belong * @param DOMElement $channel The RSS feed items - * @return array [ 'ok' => true ] if successful, [ 'error' => message ] if not + * @return array ['ok' => true] if successful, ['error' => message] if not */ - public static function updateItems(int $feedId, DOMElement $channel): array { + public static function updateItems(int $feedId, DOMElement $channel, SQLite3 $db): array { try { - foreach ($channel->getElementsByTagName('item') as $item) { - $itemGuid = self::eltValue($item, 'guid'); - if ($itemGuid == 'guid not found') $itemGuid = self::eltValue($item, 'link'); - $isNew = !Data::itemExists($feedId, $itemGuid); - if ($isNew) { - $title = self::eltValue($item, 'title'); - $link = self::eltValue($item, 'link'); - $published = self::eltValue($item, 'pubDate'); - $updNodes = $item->getElementsByTagNameNS(self::ATOM_NS, 'updated'); - $updated = $updNodes->length > 0 ? $updNodes->item(0)->textContent : null; - $encNodes = $item->getElementsByTagNameNS(self::CONTENT_NS, 'encoded'); - if ($encNodes->length > 0) { - $content = $encNodes->item(0)->textContent; - $isEncoded = true; - } else { - $content = self::eltValue($item, 'description'); - $isEncoded = false; + foreach ($channel->getElementsByTagName('item') as $rawItem) { + $item = self::itemFields($rawItem); + $existsQuery = $db->prepare( + 'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); + $existsQuery->bindValue(':feed', $feedId); + $existsQuery->bindValue(':guid', $item['guid']); + $exists = $existsQuery->execute(); + if ($exists) { + $existing = $exists->fetchArray(SQLITE3_ASSOC); + if ( $existing + && ( $existing['published_on'] != $item['published'] + || $existing['updated_on'] ?? '' != $item['updated'] ?? '')) { + self::updateItem($existing['id'], $item, $db); } - Data::addItem($feedId, $itemGuid, $link, $title, $published, $updated, $content, $isEncoded); - } // TODO: else check updated date; may want to return that from the isNew check instead + } else { + self::addItem($feedId, $item, $db); + } } } catch (Exception $ex) { - return [ 'error' => $ex->getMessage() ]; + return ['error' => $ex->getMessage()]; } - return [ 'ok', true ]; + return ['ok', true]; } /** - * Add an RSS feed + * Find the `` element and derive the published/last updated date from the feed * - * @param string $url The URL of the RSS feed to add - * @return array [ 'ok' => true ] if successful, [ 'error' => message ] if not + * @param DOMDocument $feed The feed from which the information should be extracted + * @return array|string[]|DOMElement[] ['channel' => channel, 'updated' => date] if successful, ['error' => message] + * if not */ - public static function add(string $url): array { - $feed = self::retrieveFeed($url); - if (array_key_exists('error', $feed)) return $feed; - - $channel = $feed['ok']->getElementsByTagName('channel')->item(0); + private static function findChannelAndDate(DOMDocument $feed): array { + $channel = $feed->getElementsByTagName('channel')->item(0); if (!$channel instanceof DOMElement) return [ 'error' => "Channel element not found ($channel->nodeType)" ]; // In Atom feeds, lastBuildDate contains the last time an item in the feed was updated; if that is not present, @@ -149,11 +219,74 @@ class Feed { $updated = self::eltValue($channel, 'pubDate'); if ($updated == 'pubDate not found') $updated = null; } - $feedId = Data::addFeed($feed['url'], self::eltValue($channel, 'title'), $updated); + return ['channel' => $channel, 'updated' => Data::formatDate($updated)]; + } - $result = self::updateItems($feedId, $channel); + /** + * 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, SQLite3 $db): array { + $feed = self::retrieveFeed($url); + if (array_key_exists('error', $feed)) return $feed; + + $channelAndDate = self::findChannelAndDate($feed['ok']); + if (array_key_exists('error', $channelAndDate)) return $channelAndDate; + $channel = $channelAndDate['channel']; + + $query = $db->prepare(<<<'SQL' + INSERT INTO feed (user_id, url, title, updated_on, checked_on) + VALUES (:user, :url, :title, :updated, :checked) + SQL); + $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->bindValue(':url', $feed['url']); + $query->bindValue(':title', self::eltValue($channel, 'title')); + $query->bindValue(':updated', $channelAndDate['updated']); + $query->bindValue(':checked', Data::formatDate('now')); + $result = $query->execute(); + + $feedId = $result ? $db->lastInsertRowID() : -1; + if ($feedId < 0) return ['error' => $db->lastErrorMsg()]; + + $result = self::updateItems($feedId, $channel, $db); if (array_key_exists('error', $result)) return $result; - return [ 'ok' => true ]; + return ['ok' => $feedId]; + } + + /** + * Update an RSS feed + * + * @param array $existing The existing RSS 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(array $existing, string $url, SQLite3 $db): array { + $feed = self::retrieveFeed($url); + if (array_key_exists('error', $feed)) return $feed; + + $channelAndDate = self::findChannelAndDate($feed['ok']); + if (array_key_exists('error', $channelAndDate)) return $channelAndDate; + $channel = $channelAndDate['channel']; + + $query = $db->prepare(<<<'SQL' + UPDATE feed + SET url = :url, title = :title, updated_on = :updated, checked_on = :checked + WHERE id = :id AND user_id = :user + SQL); + $query->bindValue(':url', $feed['url']); + $query->bindValue(':title', self::eltValue($channel, 'title')); + $query->bindValue(':updated', $channelAndDate['updated']); + $query->bindValue(':checked', Data::formatDate('now')); + $query->bindValue(':id', $existing['id']); + $query->bindValue(':user', $_REQUEST[Key::USER_ID]); + $query->execute(); + + $result = self::updateItems($existing['id'], $channel, $db); + if (array_key_exists('error', $result)) return $result; + + return ['ok' => true]; } } diff --git a/src/lib/Key.php b/src/lib/Key.php new file mode 100644 index 0000000..39e7bfd --- /dev/null +++ b/src/lib/Key.php @@ -0,0 +1,13 @@ + "Feed {$_POST['id']} not found" ]; } - $feed = [ 'id' => $_POST['id'], 'url' => $_POST['url'] ]; - $title = 'TODO'; -} else { - // TODO: Retrieve feed by ID if not new + if (array_key_exists('ok', $result)) { + add_info('Feed saved successfully'); + $feedId = $isNew ? $result['ok'] : $_POST['id']; + } else { + add_error($result['error']); + } +} + +if ($feedId == 'new') { $feed = [ 'id' => $_GET['id'], 'url' => '' ]; $title = 'Add RSS Feed'; +} else { + $feed = Data::retrieveFeedById((int) $feedId, $db); + if (!$feed) { + http_response_code(404); + die(); + } + $title = 'Edit RSS Feed'; } page_head($title); ?> @@ -38,3 +53,4 @@ page_head($title); ?> close(); diff --git a/src/public/index.php b/src/public/index.php index f077ba3..b4a1f38 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -9,8 +9,6 @@ include '../start.php'; Security::verifyUser(); -page_head('Welcome'); -?> - - +

Unread items go here

$level, 'message' => $message]; + if (!array_key_exists(Key::USER_MSG, $_REQUEST)) $_REQUEST[Key::USER_MSG] = array(); + $_REQUEST[Key::USER_MSG][] = ['level' => $level, 'message' => $message]; +} + +/** + * Add an error message to be displayed at the top of the page + * + * @param string $message The message to be displayed + */ +function add_error(string $message): void { + add_message('ERROR', $message); +} + +/** + * Add an error message to be displayed at the top of the page + * + * @param string $message The message to be displayed + */ +function add_info(string $message): void { + add_message('INFO', $message); } /** @@ -38,15 +56,15 @@ function page_head(string $title): void {
Feed Reader Central
Add Feed'; - if ($_REQUEST['FRC_USER_EMAIL'] != 'solouser@example.com') echo " | {$_REQUEST['FRC_USER_EMAIL']}"; + if ($_REQUEST[Key::USER_EMAIL] != 'solouser@example.com') echo " | {$_REQUEST[Key::USER_EMAIL]}"; } ?>
+ if (array_key_exists(Key::USER_MSG, $_REQUEST)) { + foreach ($_REQUEST[Key::USER_MSG] as $msg) { ?>
{$msg['level']}
"?>