14 Commits

Author SHA1 Message Date
da34f36530 Merge pull request 'Alpha 7' (#22) from alpha-7 into main
Reviewed-on: #22
2024-05-26 21:33:12 +00:00
58dd7a4ffb Add bookmark item search (#15)
- Implement form styling throughout
- Modify header links for narrower views
- Clean up CSS
2024-05-26 16:56:30 -04:00
9d59bfb1c6 Add search index (#15)
- Add utility rebuild script
- Add Search to header
- Add shell of search page
- Add search query support to ItemList
2024-05-26 14:18:20 -04:00
210377b4da Move list retrieve/render to class (#15)
- array_key_exists -> key_exists
2024-05-25 23:03:39 -04:00
c4e85e6734 Link name to bookmarked page (#14)
- Tweak CSS
2024-05-25 12:31:48 -04:00
2495136fc9 First cut of read bookmarked item page (#14)
- Added Bookmarked link to header if items exist
2024-05-23 23:02:07 -04:00
f4273935cb Add item bookmark buttons (#14)
Implemented as a toggle button

- Move init_cap func where web can see it
- Bump version to alpha7
2024-05-23 22:06:16 -04:00
4fa4dcb831 Alpha 6: Feed-level Pages (#21)
Reviewed-on: #21
2024-05-23 23:04:41 +00:00
93377ffa0e Merge pull request 'Alpha 5' (#20) from add-via-html into main
Reviewed-on: #20
2024-04-30 23:46:36 +00:00
b14399deb8 Add no purge, manual delete options (#12)
- Add htmx, hx attributes
- Only add/update items since last check
- Move messages to session to persist across redirects
- Polish styles a bit (still WIP)
2024-04-30 18:51:09 -04:00
d8ba178c55 Add pruning support (#12) 2024-04-29 23:01:49 -04:00
473dded4f9 Add docs for scheduled refresh (#11)
- Add user agent for feed requests
2024-04-28 21:16:42 -04:00
10638101d3 Add refresh utility script (#11) 2024-04-28 14:09:53 -04:00
1826ecd588 Derive feed from HTML (#10)
- Prevent duplicate feed subscriptions
- Move FeedItem to its own file
2024-04-27 22:05:39 -04:00
30 changed files with 1269 additions and 377 deletions

View File

@@ -43,3 +43,12 @@ Data is stored under the `/src/data` directory, and the default database name is
### Date/Time Format
The default format for dates and times look like "May 28, 2023 at 3:15pm". Changing the string there will alter the display on the main page and when reading an item. Any [supported PHP date or time token](https://www.php.net/manual/en/datetime.format.php) is supported.
### Item Purging
Feed Reader Central tries to keep the database tidy by purging items that have been read and are no longer required. There are four variants:
- `Feed::PURGE_NONE` does no purging (items have a "Delete" button, so they may be deleted manually)
- `Feed::PURGE_READ` purges non-bookmarked read items for a feed whenever it is refreshed. This is the most aggressive purging strategy, but it is also the only one that will not purge unread items.
- `Feed::PURGE_BY_DAYS` purges non-bookmarked items that are older than `PURGE_NUMBER` days old. This is the default value, and `PURGE_NUMBER`'s default value is 30; items will be kept for 30 days, read or unread.
- `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed.

View File

@@ -1,7 +1,7 @@
<?php
/** The current Feed Reader Central version */
const FRC_VERSION = '1.0.0-alpha4';
const FRC_VERSION = '1.0.0-alpha7';
spl_autoload_register(function ($class) {
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
@@ -15,3 +15,20 @@ spl_autoload_register(function ($class) {
require 'user-config.php';
Data::ensureDb();
/** @var string The date the world wide web was created */
const WWW_EPOCH = '1993-04-30T00:00:00+00:00';
/**
* Capitalize the first letter of the given string
*
* @param string $value The string to be capitalized
* @return string The given string with the first letter capitalized
*/
function init_cap(string $value): string {
return match (strlen($value)) {
0 => "",
1 => strtoupper($value),
default => strtoupper(substr($value, 0, 1)) . substr($value, 1),
};
}

View File

@@ -28,17 +28,3 @@ function cli_title(string $title): void {
printfn(' | %s | %s |', $title, $appTitle);
printfn($dashes . PHP_EOL);
}
/**
* Capitalize the first letter of the given string
*
* @param string $value The string to be capitalized
* @return string The given string with the first letter capitalized
*/
function init_cap(string $value): string {
return match (strlen($value)) {
0 => "",
1 => strtoupper($value),
default => strtoupper(substr($value, 0, 1)) . substr($value, 1),
};
}

View File

@@ -14,6 +14,31 @@ class Data {
return $db;
}
/**
* Create the search index and synchronization triggers for the item table
*
* @param SQLite3 $db The database connection on which these will be created
*/
public static function createSearchIndex(SQLite3 $db): void {
$db->exec("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')");
$db->exec(<<<'SQL'
CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN
INSERT INTO item_search (rowid, content) VALUES (new.id, new.content);
END;
SQL);
$db->exec(<<<'SQL'
CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN
INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content);
INSERT INTO item_search (rowid, content) VALUES (new.id, new.content);
END;
SQL);
$db->exec(<<<'SQL'
CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN
INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content);
END;
SQL);
}
/**
* Make sure the expected tables exist
*/
@@ -23,17 +48,16 @@ class Data {
$tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'");
while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0];
if (!in_array('frc_user', $tables)) {
$query = <<<'SQL'
$db->exec(<<<'SQL'
CREATE TABLE frc_user (
id INTEGER NOT NULL PRIMARY KEY,
email TEXT NOT NULL,
password TEXT NOT NULL)
SQL;
$db->exec($query);
SQL);
$db->exec('CREATE INDEX idx_user_email ON frc_user (email)');
}
if (!in_array('feed', $tables)) {
$query = <<<'SQL'
$db->exec(<<<'SQL'
CREATE TABLE feed (
id INTEGER NOT NULL PRIMARY KEY,
user_id INTEGER NOT NULL,
@@ -42,11 +66,10 @@ class Data {
updated_on TEXT,
checked_on TEXT,
FOREIGN KEY (user_id) REFERENCES frc_user (id))
SQL;
$db->exec($query);
SQL);
}
if (!in_array('item', $tables)) {
$query = <<<'SQL'
$db->exec(<<<'SQL'
CREATE TABLE item (
id INTEGER NOT NULL PRIMARY KEY,
feed_id INTEGER NOT NULL,
@@ -59,8 +82,8 @@ class Data {
is_read BOOLEAN NOT NULL DEFAULT 0,
is_bookmarked BOOLEAN NOT NULL DEFAULT 0,
FOREIGN KEY (feed_id) REFERENCES feed (id))
SQL;
$db->exec($query);
SQL);
self::createSearchIndex($db);
}
$db->close();
}
@@ -80,22 +103,12 @@ class Data {
}
/**
* Retrieve a feed by its ID for the current user
* Return the last SQLite error message as a result array
*
* @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
* @param SQLite3 $db The database connection on which the error has occurred
* @return string[] ['error' => message] for last SQLite error message
*/
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', $_SESSION[Key::USER_ID]);
$result = $query->execute();
return $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
} finally {
if (is_null($dbConn)) $db->close();
}
public static function error(SQLite3 $db): array {
return ['error' => 'SQLite error: ' . $db->lastErrorMsg()];
}
}

View File

@@ -1,84 +1,5 @@
<?php
/**
* Information for a feed item
*/
class FeedItem {
/** @var string The title of the feed item */
public string $title = '';
/** @var string The unique ID for the feed item */
public string $guid = '';
/** @var string The link to the original content */
public string $link = '';
/** @var string When this item was published */
public string $publishedOn = '';
/** @var ?string When this item was last updated */
public ?string $updatedOn = null;
/** @var string The content for the item */
public string $content = '';
/**
* Construct a feed item from an Atom feed's `<entry>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromAtom(DOMNode $node): FeedItem {
$guid = Feed::atomValue($node, 'id');
$link = '';
foreach ($node->getElementsByTagName('link') as $linkElt) {
if ($linkElt->hasAttributes()) {
$relAttr = $linkElt->attributes->getNamedItem('rel');
if ($relAttr && $relAttr->value == 'alternate') {
$link = $linkElt->attributes->getNamedItem('href')->value;
break;
}
}
}
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
$item = new FeedItem();
$item->guid = $guid;
$item->title = Feed::atomValue($node, 'title');
$item->link = $link;
$item->publishedOn = Data::formatDate(Feed::atomValue($node, 'published'));
$item->updatedOn = Data::formatDate(Feed::atomValue($node, 'updated'));
$item->content = Feed::atomValue($node, 'content');
return $item;
}
/**
* Construct a feed item from an RSS feed's `<item>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromRSS(DOMNode $node): FeedItem {
$itemGuid = Feed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem();
$item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid;
$item->title = Feed::rssValue($node, 'title');
$item->link = Feed::rssValue($node, 'link');
$item->publishedOn = Data::formatDate(Feed::rssValue($node, 'pubDate'));
$item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
$item->content = $encNodes->length > 0
? $encNodes->item(0)->textContent
: Feed::rssValue($node, 'description');
return $item;
}
}
/**
* Feed retrieval, parsing, and manipulation
*/
@@ -105,6 +26,22 @@ class Feed {
/** @var string The XML namespace for XHTML */
public const string XHTML_NS = 'http://www.w3.org/1999/xhtml';
/** @var string The user agent for Feed Reader Central's refresh requests */
private const string USER_AGENT =
'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central';
/** @var int Do not purge items */
public const int PURGE_NONE = 0;
/** @var int Purge all read items (will not purge unread items) */
public const int PURGE_READ = 1;
/** @var int Purge items older than the specified number of days */
public const int PURGE_BY_DAYS = 2;
/** @var int Purge items in number greater than the specified number of items to keep */
public const int PURGE_BY_COUNT = 3;
/**
* When parsing XML into a DOMDocument, errors are presented as warnings; this creates an exception for them
*
@@ -114,7 +51,7 @@ class Feed {
* @throws DOMException If the error is a warning
*/
private static function xmlParseError(int $errno, string $errstr): bool {
if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXml()') > 0) {
if ($errno == E_WARNING && substr_count($errstr, 'DOMDocument::loadXML()') > 0) {
throw new DOMException($errstr, $errno);
}
return false;
@@ -161,15 +98,16 @@ class Feed {
private static function fromRSS(DOMDocument $xml, string $url): array {
$channel = $xml->getElementsByTagName('channel')->item(0);
if (!($channel instanceof DOMElement)) {
return ['error' => "Channel element not found ($channel->nodeType)"];
$type = $channel?->nodeType ?? -1;
return ['error' => "Channel element not found ($type)"];
}
// The Atom namespace provides a lastBuildDate, which contains the last time an item in the feed was updated; if
// that is not present, use the pubDate element instead
$updatedOn = self::rssValue($channel, 'lastBuildDate');
if ($updatedOn == 'lastBuildDate not found') {
$updatedOn = self::rssValue($channel, 'pubDate');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
if (($updatedOn = self::rssValue($channel, 'lastBuildDate')) == 'lastBuildDate not found') {
if (($updatedOn = self::rssValue($channel, 'pubDate')) == 'pubDate not found') {
$updatedOn = null;
}
}
$feed = new Feed();
@@ -181,6 +119,17 @@ class Feed {
return ['ok' => $feed];
}
/**
* Get an attribute value from a DOM node
*
* @param DOMNode $node The node with an attribute value to obtain
* @param string $name The name of the attribute whose value should be obtained
* @return string The attribute value if it exists, an empty string if not
*/
private static function attrValue(DOMNode $node, string $name): string {
return ($node->hasAttributes() ? $node->attributes->getNamedItem($name)?->value : null) ?? '';
}
/**
* Get the value of a child element by its tag name for an Atom feed
*
@@ -196,7 +145,7 @@ class Feed {
if ($tags->length == 0) return "$tagName not found";
$tag = $tags->item(0);
if (!($tag instanceof DOMElement)) return $tag->textContent;
if ($tag->hasAttributes() && $tag->attributes->getNamedItem('type') == 'xhtml') {
if (self::attrValue($tag, 'type') == 'xhtml') {
$div = $tag->getElementsByTagNameNS(Feed::XHTML_NS, 'div');
if ($div->length == 0) return "-- invalid XHTML content --";
return $div->item(0)->textContent;
@@ -213,8 +162,7 @@ class Feed {
*/
private static function fromAtom(DOMDocument $xml, string $url): array {
$root = $xml->getElementsByTagNameNS(self::ATOM_NS, 'feed')->item(0);
$updatedOn = self::atomValue($root, 'updated');
if ($updatedOn == 'pubDate not found') $updatedOn = null;
if (($updatedOn = self::atomValue($root, 'updated')) == 'pubDate not found') $updatedOn = null;
$feed = new Feed();
$feed->title = self::atomValue($root, 'title');
@@ -225,6 +173,55 @@ class Feed {
return ['ok' => $feed];
}
/**
* Retrieve a document (http/https)
*
* @param string $url The URL of the document to retrieve
* @return array ['content' => document content, 'error' => error message, 'code' => HTTP response code,
* 'url' => effective URL]
*/
private static function retrieveDocument(string $url): array {
$docReq = curl_init($url);
curl_setopt($docReq, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($docReq, CURLOPT_RETURNTRANSFER, true);
curl_setopt($docReq, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($docReq, CURLOPT_TIMEOUT, 15);
curl_setopt($docReq, CURLOPT_USERAGENT, self::USER_AGENT);
$result = [
'content' => curl_exec($docReq),
'error' => curl_error($docReq),
'code' => curl_getinfo($docReq, CURLINFO_RESPONSE_CODE),
'url' => curl_getinfo($docReq, CURLINFO_EFFECTIVE_URL)
];
curl_close($docReq);
return $result;
}
/**
* Derive a feed URL from an HTML document
*
* @param string $content The HTML document content from which to derive a feed URL
* @return array|string[] ['ok' => feed URL] if successful, ['error' => message] if not
*/
private static function deriveFeedFromHTML(string $content): array {
$html = new DOMDocument();
$html->loadHTML(substr($content, 0, strpos($content, '</head>') + 7));
$headTags = $html->getElementsByTagName('head');
if ($headTags->length < 1) return ['error' => 'Cannot find feed at this URL'];
$head = $headTags->item(0);
foreach ($head->getElementsByTagName('link') as $link) {
if (self::attrValue($link, 'rel') == 'alternate') {
$type = self::attrValue($link, 'type');
if ($type == 'application/rss+xml' || $type == 'application/atom+xml') {
return ['ok' => self::attrValue($link, 'href')];
}
}
}
return ['error' => 'Cannot find feed at this URL'];
}
/**
* Retrieve the feed
*
@@ -232,34 +229,33 @@ class Feed {
* @return array|Feed[]|string[] ['ok' => feed] if successful, ['error' => message] if not
*/
public static function retrieveFeed(string $url): array {
$feedReq = curl_init($url);
curl_setopt($feedReq, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($feedReq, CURLOPT_RETURNTRANSFER, true);
curl_setopt($feedReq, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($feedReq, CURLOPT_TIMEOUT, 15);
$doc = self::retrieveDocument($url);
$feedContent = curl_exec($feedReq);
if ($doc['error'] != '') return ['error' => $doc['error']];
if ($doc['code'] != 200) {
return ['error' => "Prospective feed URL $url returned HTTP Code {$doc['code']}: {$doc['content']}"];
}
$start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']);
if ($start == '<!doctype' || str_starts_with($start, '<html')) {
$derivedURL = self::deriveFeedFromHTML($doc['content']);
if (key_exists('error', $derivedURL)) return ['error' => $derivedURL['error']];
$feedURL = $derivedURL['ok'];
if (!str_starts_with($feedURL, 'http')) {
// Relative URL; feed should be retrieved in the context of the original URL
$original = parse_url($url);
$port = key_exists('port', $original) ? ":{$original['port']}" : '';
$feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL;
}
$doc = self::retrieveDocument($feedURL);
}
$parsed = self::parseFeed($doc['content']);
if (key_exists('error', $parsed)) return ['error' => $parsed['error']];
$result = array();
$error = curl_error($feedReq);
$code = curl_getinfo($feedReq, CURLINFO_RESPONSE_CODE);
if ($error) {
$result['error'] = $error;
} elseif ($code == 200) {
$parsed = self::parseFeed($feedContent);
if (array_key_exists('error', $parsed)) {
$result['error'] = $parsed['error'];
} else {
$extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0
? self::fromAtom(...) : self::fromRSS(...);
$result = $extract($parsed['ok'], curl_getinfo($feedReq, CURLINFO_EFFECTIVE_URL));
}
} else {
$result['error'] = "Prospective feed URL $url returned HTTP Code $code: $feedContent";
}
curl_close($feedReq);
return $result;
return $extract($parsed['ok'], $doc['url']);
}
/**
@@ -268,8 +264,9 @@ class Feed {
* @param int $itemId The ID of the item to be updated
* @param FeedItem $item The item to be updated
* @param SQLite3 $db A database connection to use for the update
* @return bool|SQLite3Result The result if the update is successful, false if it failed
*/
private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): void {
private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
$query = $db->prepare(<<<'SQL'
UPDATE item
SET title = :title,
@@ -284,7 +281,7 @@ class Feed {
$query->bindValue(':updated', $item->updatedOn);
$query->bindValue(':content', $item->content);
$query->bindValue(':id', $itemId);
$query->execute();
return $query->execute();
}
/**
@@ -293,8 +290,9 @@ class Feed {
* @param int $feedId The ID of the feed to which the item should be added
* @param FeedItem $item The item to be added
* @param SQLite3 $db A database connection to use for the addition
* @return bool|SQLite3Result The result if the update is successful, false if it failed
*/
private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): void {
private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
$query = $db->prepare(<<<'SQL'
INSERT INTO item (
feed_id, item_guid, item_link, title, published_on, updated_on, content
@@ -309,7 +307,7 @@ class Feed {
$query->bindValue(':published', $item->publishedOn);
$query->bindValue(':updated', $item->updatedOn);
$query->bindValue(':content', $item->content);
$query->execute();
return $query->execute();
}
/**
@@ -317,57 +315,91 @@ class Feed {
*
* @param int $feedId The ID of the feed to which these items belong
* @param Feed $feed 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, Feed $feed, SQLite3 $db): array {
try {
foreach ($feed->items as $item) {
public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array {
$results =
array_map(function ($item) use ($db, $feedId) {
$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) {
if ($exists = $existsQuery->execute()) {
if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) {
if ( $existing['published_on'] != $item->publishedOn
|| ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
self::updateItem($existing['id'], $item, $db);
if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
}
} else {
self::addItem($feedId, $item, $db);
if (!self::addItem($feedId, $item, $db)) return Data::error($db);
}
} else {
throw new Exception($db->lastErrorMsg());
return Data::error($db);
}
return ['ok' => true];
}, array_filter($feed->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
* @param SQLite3 $db The database connection on which items should be purged
* @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not
*/
private static function purgeItems(int $feedId, SQLite3 $db): array {
if (!array_search(PURGE_TYPE, [self::PURGE_READ, self::PURGE_BY_DAYS, self::PURGE_BY_COUNT])) {
return ['error' => 'Unrecognized purge type ' . PURGE_TYPE];
}
try {
$sql = match (PURGE_TYPE) {
self::PURGE_READ => 'AND is_read = 1',
self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)',
self::PURGE_BY_COUNT => 'AND id IN (SELECT id FROM item WHERE feed_id = :feed
ORDER BY date(coalesce(updated_on, published_on)) DESC
LIMIT -1 OFFSET :keep)'
};
$purge = $db->prepare("DELETE FROM item WHERE feed_id = :feed AND is_bookmarked = 0 $sql");
$purge->bindValue(':feed', $feedId);
if (PURGE_TYPE == self::PURGE_BY_DAYS) {
$purge->bindValue(':oldest', Data::formatDate('-' . PURGE_NUMBER . ' day'));
} elseif (PURGE_TYPE == self::PURGE_BY_COUNT) {
$purge->bindValue(':keep', PURGE_NUMBER);
}
return $purge->execute() ? ['ok' => true] : Data::error($db);
} catch (Exception $ex) {
return ['error' => $ex->getMessage()];
}
return ['ok', true];
}
/**
* 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
* @param SQLite3 $db A database connection to use to refresh the feed
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not
*/
private static function refreshFeed(string $url, SQLite3 $db): array {
$feedQuery = $db->prepare('SELECT id FROM feed WHERE url = :url AND user_id = :user');
$feedQuery->bindValue(':url', $url);
$feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$feedResult = $feedQuery->execute();
$feedId = $feedResult ? $feedResult->fetchArray(SQLITE3_NUM)[0] : -1;
if ($feedId < 0) return ['error' => "No feed for URL $url found"];
public static function refreshFeed(int $feedId, string $url, SQLite3 $db): array {
$feedRetrieval = self::retrieveFeed($url);
if (key_exists('error', $feedRetrieval)) return $feedRetrieval;
$feed = $feedRetrieval['ok'];
$feedExtract = self::retrieveFeed($url);
if (array_key_exists('error', $feedExtract)) return $feedExtract;
$lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id');
$lastCheckedQuery->bindValue(':id', $feedId);
if (!($lastCheckedResult = $lastCheckedQuery->execute())) return Data::error($db);
if (!($lastChecked = date_create_immutable($lastCheckedResult->fetchArray(SQLITE3_NUM)[0] ?? WWW_EPOCH))) {
return ['error' => 'Could not derive date last checked for feed'];
}
$feed = $feedExtract['ok'];
$itemUpdate = self::updateItems($feedId, $feed, $db);
if (array_key_exists('error', $itemUpdate)) return $itemUpdate;
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
if (key_exists('error', $itemUpdate)) return $itemUpdate;
$urlUpdate = $url == $feed->url ? '' : ', url = :url';
$feedUpdate = $db->prepare(<<<SQL
@@ -383,9 +415,9 @@ class Feed {
$feedUpdate->bindValue(':checked', Data::formatDate('now'));
$feedUpdate->bindValue(':id', $feedId);
if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url);
$feedUpdate->execute();
if (!$feedUpdate->execute()) return Data::error($db);
return ['ok' => true];
return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db);
}
/**
@@ -396,25 +428,33 @@ class Feed {
*/
public static function add(string $url, SQLite3 $db): array {
$feedExtract = self::retrieveFeed($url);
if (array_key_exists('error', $feedExtract)) return $feedExtract;
if (key_exists('error', $feedExtract)) return $feedExtract;
$feed = $feedExtract['ok'];
$existsQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user AND url = :url');
$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$existsQuery->bindValue(':url', $feed->url);
if (!($exists = $existsQuery->execute())) return Data::error($db);
if ($exists->fetchArray(SQLITE3_NUM)[0] > 0) return ['error' => "Already subscribed to feed $feed->url"];
$query = $db->prepare(<<<'SQL'
INSERT INTO feed (user_id, url, title, updated_on, checked_on)
VALUES (:user, :url, :title, :updated, :checked)
INSERT INTO feed (
user_id, url, title, updated_on, checked_on
) VALUES (
:user, :url, :title, :updated, :checked
)
SQL);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->bindValue(':url', $feed->url);
$query->bindValue(':title', $feed->title);
$query->bindValue(':updated', $feed->updatedOn);
$query->bindValue(':checked', Data::formatDate('now'));
$result = $query->execute();
if (!$query->execute()) return Data::error($db);
$feedId = $result ? $db->lastInsertRowID() : -1;
if ($feedId < 0) return ['error' => $db->lastErrorMsg()];
$result = self::updateItems($feedId, $feed, $db);
if (array_key_exists('error', $result)) return $result;
$feedId = $db->lastInsertRowID();
$result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db);
if (key_exists('error', $result)) return $result;
return ['ok' => $feedId];
}
@@ -432,31 +472,60 @@ class Feed {
$query->bindValue(':url', $url);
$query->bindValue(':id', $existing['id']);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->execute();
if (!$query->execute()) return Data::error($db);
return self::refreshFeed($url, $db);
return self::refreshFeed($existing['id'], $url, $db);
}
/**
* Retrieve all feeds, optionally for a specific user
*
* @param SQLite3 $db The database connection to use to retrieve the feeds
* @param int $user The ID of the user whose feeds should be retrieved (optional, defaults to all feeds)
* @return array An array of arrays with ['id', 'url', 'email'] keys
*/
public static function retrieveAll(SQLite3 $db, int $user = 0): array {
$extraSQL = $user > 0 ? ' WHERE u.id = :user' : '';
$query = $db->prepare(
"SELECT f.id, f.url, u.email FROM feed f INNER JOIN frc_user u ON u.id = f.user_id$extraSQL");
if ($user > 0) $query->bindValue(':user', $user);
if (!($result = $query->execute())) return Data::error($db);
$feeds = [];
while ($feed = $result->fetchArray(SQLITE3_ASSOC)) $feeds[] = $feed;
return $feeds;
}
/**
* Refresh all feeds
*
* @param SQLite3 $db The database connection to use for refreshing feeds
* @return array|true[] ['ok => true] if successful, ['error' => message] if not (may have multiple error lines)
* @return array|true[]|string[] ['ok' => true] if successful,
* ['error' => message] if not (may have multiple error lines)
*/
public static function refreshAll(SQLite3 $db): array {
$query = $db->prepare('SELECT url FROM feed WHERE user_id = :user');
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$url = $result ? $result->fetchArray(SQLITE3_NUM) : false;
if ($url) {
$errors = array();
while ($url) {
$updateResult = self::refreshFeed($url[0], $db);
if (array_key_exists('error', $updateResult)) $errors[] = $updateResult['error'];
$url = $result->fetchArray(SQLITE3_NUM);
}
$feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]);
if (key_exists('error', $feeds)) return $feeds;
$errors = [];
array_walk($feeds, function ($feed) use ($db, &$errors) {
$result = self::refreshFeed($feed['id'], $feed['url'], $db);
if (key_exists('error', $result)) $errors[] = $result['error'];
});
return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)];
}
return ['error' => $db->lastErrorMsg()];
/**
* Retrieve a feed by its ID for the current user
*
* @param int $feedId The ID of the feed to retrieve
* @param SQLite3 $db A database connection to use to retrieve the feed
* @return array|bool The data for the feed if found, false if not found
*/
public static function retrieveById(int $feedId, SQLite3 $db): array|bool {
$query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user');
$query->bindValue(':id', $feedId);
$query->bindValue(':user', $_SESSION[Key::USER_ID]);
return ($result = $query->execute()) ? $result->fetchArray(SQLITE3_ASSOC) : false;
}
}

80
src/lib/FeedItem.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
/**
* Information for a feed item
*/
class FeedItem {
/** @var string The title of the feed item */
public string $title = '';
/** @var string The unique ID for the feed item */
public string $guid = '';
/** @var string The link to the original content */
public string $link = '';
/** @var string When this item was published */
public string $publishedOn = '';
/** @var ?string When this item was last updated */
public ?string $updatedOn = null;
/** @var string The content for the item */
public string $content = '';
/**
* Construct a feed item from an Atom feed's `<entry>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromAtom(DOMNode $node): FeedItem {
$guid = Feed::atomValue($node, 'id');
$link = '';
foreach ($node->getElementsByTagName('link') as $linkElt) {
if ($linkElt->hasAttributes()) {
$relAttr = $linkElt->attributes->getNamedItem('rel');
if ($relAttr && $relAttr->value == 'alternate') {
$link = $linkElt->attributes->getNamedItem('href')->value;
break;
}
}
}
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
$item = new FeedItem();
$item->guid = $guid;
$item->title = Feed::atomValue($node, 'title');
$item->link = $link;
$item->publishedOn = Data::formatDate(Feed::atomValue($node, 'published'));
$item->updatedOn = Data::formatDate(Feed::atomValue($node, 'updated'));
$item->content = Feed::atomValue($node, 'content');
return $item;
}
/**
* Construct a feed item from an RSS feed's `<item>` tag
*
* @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node
*/
public static function fromRSS(DOMNode $node): FeedItem {
$itemGuid = Feed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem();
$item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid;
$item->title = Feed::rssValue($node, 'title');
$item->link = Feed::rssValue($node, 'link');
$item->publishedOn = Data::formatDate(Feed::rssValue($node, 'pubDate'));
$item->updatedOn = Data::formatDate($updNodes->length > 0 ? $updNodes->item(0)->textContent : null);
$item->content = $encNodes->length > 0
? $encNodes->item(0)->textContent
: Feed::rssValue($node, 'description');
return $item;
}
}

191
src/lib/ItemList.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
/**
* A list of items to be displayed
*
* This is a wrapper for retrieval and display of arbitrary lists of items based on a SQLite result.
*/
class ItemList {
/** @var SQLite3Result The list of items to be displayed */
private SQLite3Result $items;
/** @var string The error message generated by executing a query */
public string $error = '';
/**
* Is there an error condition associated with this list?
*
* @return bool True if there is an error condition associated with this list, false if not
*/
public function isError(): bool {
return $this->error != '';
}
/** @var bool Whether to render a link to the feed to which the item belongs */
public bool $linkFeed = false;
/** @var bool Whether to display the feed to which the item belongs */
public bool $displayFeed = false;
/** @var bool Whether to show read / bookmarked indicators on posts */
public bool $showIndicators = false;
/**
* Constructor
*
* @param SQLite3 $db The database connection (used to retrieve error information if the query fails)
* @param SQLite3Stmt $query The query to retrieve the items for this list
* @param string $itemType The type of item being displayed (unread, bookmark, etc.)
* @param string $returnURL The URL to which the item page should return once the item has been viewed
*/
private function __construct(SQLite3 $db, SQLite3Stmt $query, public string $itemType, public string $returnURL = '') {
$result = $query->execute();
if (!$result) {
$this->error = Data::error($db)['error'];
} else {
$this->items = $result;
}
}
/**
* Create an item list query
*
* @param SQLite3 $db The database connection to use to obtain items
* @param array $criteria One or more SQL WHERE conditions (will be combined with AND)
* @param array $parameters Parameters to be added to the query (key index 0, value index 1; optional)
* @return SQLite3Stmt The query, ready to be executed
*/
private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt {
$where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria);
$sql = <<<SQL
SELECT item.id, item.feed_id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
item.is_read, item.is_bookmarked, feed.title AS feed_title
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE feed.user_id = :userId $where
ORDER BY coalesce(item.updated_on, item.published_on) DESC
SQL;
$query = $db->prepare($sql);
$query->bindValue(':userId', $_SESSION[Key::USER_ID]);
foreach ($parameters as $param) $query->bindValue($param[0], $param[1]);
return $query;
}
/**
* Create an item list with all the current user's bookmarked items
*
* @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all bookmarked items
*/
public static function allBookmarked(SQLite3 $db): static {
$list = new static($db, self::makeQuery($db, ['item.is_bookmarked = 1']), 'Bookmarked', '/?bookmarked');
$list->linkFeed = true;
return $list;
}
/**
* Create an item list with all the current user's unread items
*
* @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all unread items
*/
public static function allUnread(SQLite3 $db): static {
$list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread');
$list->linkFeed = true;
return $list;
}
/**
* Create an item list with all items for the given feed
*
* @param int $feedId The ID of the feed for which items should be retrieved
* @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all items for the given feed
*/
public static function allForFeed(int $feedId, SQLite3 $db): static {
$list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '',
"/feed/items?id=$feedId");
$list->showIndicators = true;
return $list;
}
/**
* Create an item list with unread items for the given feed
*
* @param int $feedId The ID of the feed for which items should be retrieved
* @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with unread items for the given feed
*/
public static function unreadForFeed(int $feedId, SQLite3 $db): static {
return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_read = 0'], [[':feed', $feedId]]),
'Unread', "/feed/items?id=$feedId&unread");
}
/**
* Create an item list with bookmarked items for the given feed
*
* @param int $feedId The ID of the feed for which items should be retrieved
* @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with bookmarked items for the given feed
*/
public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static {
return new static($db,
self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked',
"/feed/items?id=$feedId&bookmarked");
}
/**
* Create an item list with items matching given search terms
*
* @param string $search The item search terms / query
* @param bool $isBookmarked Whether to restrict the search to bookmarked items
* @param SQLite3 $db The database connection to use to obtain items
* @return static An item list match the given search terms
*/
public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static {
$where = $isBookmarked ? ['item.is_bookmarked = 1'] : [];
$where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)';
$list = new static($db, self::makeQuery($db, $where, [[':search', $search]]),
'Matching' . ($isBookmarked ? ' Bookmarked' : ''),
"/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all'));
$list->showIndicators = true;
$list->displayFeed = true;
return $list;
}
/**
* Render this item list
*/
public function render(): void {
if ($this->isError()) { ?>
<p>Error retrieving list:<br><?=$this->error?><?php
return;
}
$item = $this->items->fetchArray(SQLITE3_ASSOC);
$return = $this->returnURL == '' ? '' : '&from=' . urlencode($this->returnURL);
echo '<article>';
if ($item) {
while ($item) { ?>
<p><?=hx_get("/item?id={$item['id']}$return", strip_tags($item['item_title']))?><br>
<small><?php
if ($this->showIndicators) {
if (!$item['is_read']) echo '<strong>Unread</strong> &nbsp; ';
if ($item['is_bookmarked']) echo '<strong>Bookmarked</strong> &nbsp; ';
}
echo '<em>' . date_time($item['as_of']) . '</em>';
if ($this->linkFeed) {
echo ' &bull; ' .
hx_get("/feed/items?id={$item['feed_id']}&" . strtolower($this->itemType),
htmlentities($item['feed_title']));
} elseif ($this->displayFeed) {
echo ' &bull; ' . htmlentities($item['feed_title']);
} ?>
</small><?php
$item = $this->items->fetchArray(SQLITE3_ASSOC);
}
} else { ?>
<p><em>There are no <?=strtolower($this->itemType)?> items</em><?php
}
echo '</article>';
}
}

View File

@@ -131,7 +131,7 @@ class Security {
* @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on
*/
public static function verifyUser(SQLite3 $db, bool $redirectIfAnonymous = true): void {
if (array_key_exists(Key::USER_ID, $_SESSION)) return;
if (key_exists(Key::USER_ID, $_SESSION)) return;
if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db);

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/public/assets/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -22,31 +22,59 @@ header {
border-bottom-right-radius: .5rem;
color: white;
display: flex;
flex-flow: row nowrap;
flex-flow: row wrap;
justify-content: space-between;
align-items: baseline;
div, nav {
margin-bottom: .25rem;
}
.title {
font-size: 1.5rem;
}
.version {
font-size: .85rem;
padding-left: .5rem;
color: rgba(255, 255, 255, .75);
}
a:link, a:visited {
color: white;
}
nav {
display: flex;
flex-flow: row wrap;
gap: 0 .4rem;
}
}
main {
padding: 0 .5rem;
.item_heading {
margin-bottom: 0;
.refresh, .loading {
font-style: italic;
font-size: .9rem;
}
.htmx-request .refresh {
display: none;
}
.loading {
display: none;
}
.htmx-request .loading {
display: inline;
}
.user_messages {
display: flex;
flex-flow: column;
justify-content: center;
}
.user_message {
margin: .25rem auto;
border: solid 1px navy;
border-radius: .5rem;
background-color: rgba(255, 255, 255, .75);
padding: .25rem;
}
.user_messages + h1 {
margin-top: .25rem;
}
.item_published {
margin-bottom: 1rem;
line-height: 1.2;
@@ -55,33 +83,73 @@ main {
article {
max-width: 60rem;
margin: auto;
.item_content {
border: solid 1px navy;
border-radius: .5rem;
background-color: white;
padding: .5rem;
img {
max-width: 100%;
object-fit: contain;
height: unset;
width: unset;
}
}
}
article.docs {
.meta {
font-size: .9rem;
}
&.docs {
line-height: 1.4rem;
}
}
input[type=url],
input[type=text],
input[type=email],
input[type=password] {
width: 50%;
form {
display: flex;
flex-flow: row wrap;
justify-content: center;
gap: 0 2rem;
label {
font-size: .9rem;
font-weight: bold;
input, select {
display: block;
}
}
.break {
flex-basis: 100%;
height: 1rem;
width: 0;
}
input[type=url],
input[type=text],
input[type=email],
input[type=password],
select {
min-width: 12rem;
max-width: 100%;
font-size: 1rem;
padding: .25rem;
border-radius: .25rem;
background-color: white;
border: solid 2px navy;
}
select {
min-width: unset;
max-width: unset;
}
}
@media all and (min-width: 60rem) {
form {
input[type=url],
input[type=text],
input[type=email],
input[type=password] {
min-width: 25rem;
}
}
}
.action_buttons {
margin: 1rem 0;
display: flex;
flex-flow: row nowrap;
justify-content: space-evenly;
}
button,
.action_buttons a:link,
@@ -94,18 +162,11 @@ button,
border-radius: .25rem;
cursor: pointer;
border: none;
}
button:hover,
.action_buttons a:hover {
&:hover {
text-decoration: none;
cursor: pointer;
background: linear-gradient(navy, #000032);
}
.action_buttons {
margin: 1rem 0;
display: flex;
flex-flow: row nowrap;
justify-content: space-evenly;
}
}
code {
font-size: .9rem;
@@ -113,3 +174,28 @@ code {
p.back-link {
margin-top: -1rem;
}
.item_heading {
margin-bottom: 0;
.bookmark {
padding: 0;
border: solid 1px black;
border-radius: .5rem;
&.add {
background-color: lightgray;
&:hover {
background: linear-gradient(lightgreen, gray);
}
}
&.remove {
background: linear-gradient(lightgreen, green);
&:hover {
background: linear-gradient(gray, lightgreen);
}
}
img {
max-width: 1.5rem;
max-height: 1.5rem;
padding: .5rem;
}
}
}

49
src/public/bookmark.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
/**
* Bookmark Partial Handler
*
* This will display a button which will either add or remove a bookmark for a given item.
*/
include '../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
$id = $_GET['id'];
$existsQuery = $db->prepare(
'SELECT item.id FROM item INNER JOIN feed ON feed.id = item.feed_id WHERE item.id = :id AND feed.user_id = :user');
$existsQuery->bindValue(':id', $id);
$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$existsResult = $existsQuery->execute();
$exists = $existsResult ? $existsResult->fetchArray(SQLITE3_ASSOC) : false;
if (!$exists) not_found();
if (key_exists('action', $_GET)) {
if ($_GET['action'] == 'add') {
$flag = 1;
} elseif ($_GET['action'] == 'remove') {
$flag = 0;
}
if (isset($flag)) {
$update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id');
$update->bindValue(':id', $id);
$update->bindValue(':flag', $flag);
if (!$update->execute()) die(Data::error($db)['error']);
}
}
$bookQuery = $db->prepare('SELECT id, is_bookmarked FROM item WHERE id = :id');
$bookQuery->bindValue(':id', $id);
$bookResult = $bookQuery->execute();
$bookmark = $bookResult ? $bookResult->fetchArray(SQLITE3_ASSOC) : ['id' => $id, 'is_bookmarked' => 0];
$action = $bookmark['is_bookmarked'] ? 'remove' : 'add';
$icon = $bookmark['is_bookmarked'] ? 'added' : 'add'; ?>
<button class="bookmark <?=$action?>" type=button role=button hx-patch="/bookmark?id=<?=$id?>&action=<?=$action?>"
hx-target=this hx-swap=outerHTML hx-push-url=false title="<?=init_cap($action)?> Bookmark">
<img src=/assets/bookmark-<?=$icon?>.png alt="<?=$action?> bookmark">
</button><?php

View File

@@ -7,9 +7,11 @@ Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Documentation'); ?>
<h1>Documentation Home</h1>
<article>
<p><a href=./the-cli>About the CLI</a> provides orientation on Feed Reader Central&rsquo;s command line interface
<p><a href=./security-modes>Configuring Security Modes</a> describes the three security modes and how to manage each
of them
<p><?=hx_get('./the-cli', 'About the CLI')?> provides orientation on Feed Reader Central&rsquo;s command line
interface
<p><?=hx_get('./security-modes', 'Configuring Security Modes')?> describes the three security modes and how to
manage each of them
<p><?=hx_get('./refresh-feeds', 'Refresh Feeds')?> has instructions on how feeds can be refreshed on a schedule
</article><?php
page_foot();
$db->close();

View File

@@ -0,0 +1,52 @@
<?php
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Refresh Feeds | Documentation'); ?>
<h1>Refresh Feeds</h1>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs>
<h2>Manual Feed Refresh</h2>
<p>Next to the &ldquo;Your Unread Items&rdquo; heading on the main page, there is a link labeled &ldquo;Refresh All
Feeds&rdquo;. Clicking this link will reload the main page once the feeds have been refreshed. Depending on the
number and size of feeds, this may take a bit of time; each feed is refreshed individually.
<h2>Automatic Refreshing</h2>
<p>The <code>refresh</code> utility script will perform this refresh from the CLI. As it runs, it will list the
feeds as it processes them, and if it encounters any errors, that is noted as well. This process can be
automated via <code>cron</code> on Linux or Mac systems. This is most easily implemented by writing a small
shell script to provide some environment settings, then telling <code>cron</code> to run that script.
<pre class=item_content>
#!/bin/bash
exec 1> >(logger -t feed-reader-central) 2>&1
cd /path/to/frc
php-cli util/refresh.php all</pre>
<p>Save this (<code>frc-refresh.sh</code> might be a good name) and be sure it is executable
(<code>chmod +x ./frc-refresh.sh</code>). Before we put it in crontab, though, let&rsquo;s understand what each
line does:
<ul>
<li>Line 1 tells the operating system to use the <code>bash</code> shell.
<li>Line 2 directs all output to the system log (<code>/var/log/syslog</code>), labeling each entry with
<code>feed-reader-central</code>. This lets you review the output for its runs in a log that is already
maintained and rotated by the operating system.
<li>Line 3 changes the current directory to the one where Feed Reader Central is installed; modify it for where
you have installed it. Since we are setting up for a <a href=./the-cli>CLI execution</a>, this should place
us one directory up from <code>/public</code>.
<li>Line 4 executes the refresh script.
</ul>
<p>Finally, we are ready to add this to our crontab. Enter <code>crontab -e</code> to edit the file, then add a row
at the bottom that looks like this:
<pre class=item_content>
0 */6 * * * /path/to/job/frc-refresh.sh</pre>
<p>The items before the path specify when it should run. This example will run at the top of the hour every six
hours. Crontab schedules can be tricky to create; a full treatment is outside the scope of this documentation.
However, <a href=https://crontab.guru/#0_*/6_*_*_* target=_blank rel=noopener title="Crontab.guru">this site</a>
lets you put values in each position and it translates that to words; this lets you see if what you put is what
you meant.
<p>This should not require many resources; the majority of its time will be spent waiting for the websites to return
their feeds so it can process them. However, if you want it to yield to everything else happening on the server,
add <code>nice -n 1</code> (with a trailing space) before the path to the script.
</article><?php
page_foot();
$db->close();

View File

@@ -6,7 +6,7 @@ Security::verifyUser($db, redirectIfAnonymous: false);
page_head('Security Modes | Documentation'); ?>
<h1>Configuring Security Modes</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs>
<h2>Security Modes</h2>
<p><strong>Single-User</strong> mode assumes that every connection to the application is the same person. It is

View File

@@ -6,7 +6,7 @@ Security::verifyUser($db, redirectIfAnonymous: false);
page_head('About the CLI | Documentation'); ?>
<h1>About the CLI</h1>
<p class=back-link><a href=./>&lang;&lang; Documentation Home</a>
<p class=back-link><?=hx_get('./', '&lang;&lang; Documentation Home')?>
<article class=docs>
<p>Feed Reader Central&rsquo;s low-friction design includes having many administrative tasks run in a terminal or
shell. &ldquo;CLI&rdquo; is short for &ldquo;Command Line Interface&rdquo;, and refers to commands that are run

View File

@@ -1,61 +0,0 @@
<?php
/**
* Add/Edit Feed Page
*
* Allows users to add or edit RSS feeds
*/
include '../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
$feedId = $_GET['id'] ?? '';
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isNew = $_POST['id'] == 'new';
if ($isNew) {
$result = Feed::add($_POST['url'], $db);
} else {
$toEdit = Data::retrieveFeedById($_POST['id'], $db);
$result = $toEdit ? Feed::update($toEdit, $_POST['url'], $db) : ['error' => "Feed {$_POST['id']} not found"];
}
if (array_key_exists('ok', $result)) {
add_info('Feed saved successfully');
$feedId = $isNew ? $result['ok'] : $_POST['id'];
} else {
add_error($result['error']);
$feedId = 'error';
}
}
if ($feedId == 'new') {
$title = 'Add RSS Feed';
$feed = [ 'id' => $_GET['id'], 'url' => ''];
} else {
$title = 'Edit RSS Feed';
if ($feedId == 'error') {
$feed = ['id' => $_POST['id'] ?? '', 'url' => $_POST['url'] ?? ''];
} else {
$feed = Data::retrieveFeedById((int) $feedId, $db);
if (!$feed) {
http_response_code(404);
die();
}
}
}
page_head($title); ?>
<h1><?=$title?></h1>
<article>
<form method=POST action=/feed hx-post=/feed>
<input type=hidden name=id value=<?=$feed['id']?>>
<label>
Feed URL
<input type=url name=url required autofocus value="<?=$feed['url']?>">
</label><br>
<button type=submit>Save</button>
</form>
</article><?php
page_foot();
$db->close();

70
src/public/feed/index.php Normal file
View File

@@ -0,0 +1,70 @@
<?php
/**
* Add/Edit/Delete Feed Page
*
* Allows users to add, edit, and delete feeds
*/
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
$feedId = $_GET['id'] ?? '';
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
if (!($feed = Feed::retrieveById($feedId, $db))) not_found();
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id = :feed');
$itemDelete->bindValue(':feed', $feed['id']);
if (!$itemDelete->execute()) add_error(Data::error($db)['error']);
$feedDelete = $db->prepare('DELETE FROM feed WHERE id = :feed');
$feedDelete->bindValue(':feed', $feed['id']);
if ($feedDelete->execute()) {
add_info('Feed &ldquo;' . htmlentities($feed['title']) . '&rdquo; deleted successfully');
} else {
add_error(Data::error($db)['error']);
}
frc_redirect('/feeds');
}
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isNew = $_POST['id'] == 'new';
if ($isNew) {
$result = Feed::add($_POST['url'], $db);
} else {
$toEdit = Feed::retrieveById($_POST['id'], $db);
$result = $toEdit ? Feed::update($toEdit, $_POST['url'], $db) : ['error' => "Feed {$_POST['id']} not found"];
}
if (key_exists('ok', $result)) {
add_info('Feed saved successfully');
frc_redirect('/feeds');
}
add_error($result['error']);
$feedId = 'error';
}
if ($feedId == 'new') {
$title = 'Add RSS Feed';
$feed = [ 'id' => $_GET['id'], 'url' => ''];
} else {
$title = 'Edit RSS Feed';
if ($feedId == 'error') {
$feed = ['id' => $_POST['id'] ?? '', 'url' => $_POST['url'] ?? ''];
} elseif (!($feed = Feed::retrieveById((int) $feedId, $db))) not_found();
}
page_head($title); ?>
<h1><?=$title?></h1>
<article>
<form method=POST action=/feed/ hx-post=/feed/>
<input type=hidden name=id value=<?=$feed['id']?>>
<label>
Feed URL
<input type=url name=url required autofocus value="<?=$feed['url']?>">
</label>
<span class=break></span>
<button type=submit>Save</button>
</form>
</article><?php
page_foot();
$db->close();

30
src/public/feed/items.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
/**
* Feed Item List Page
*
* Lists items in a given feed (all, unread, or bookmarked)
*/
include '../../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
if (!($feed = Feed::retrieveById($_GET['id'], $db))) not_found();
$list = match (true) {
key_exists('unread', $_GET) => ItemList::unreadForFeed($feed['id'], $db),
key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed['id'], $db),
default => ItemList::allForFeed($feed['id'], $db)
};
page_head(($list->itemType != '' ? "$list->itemType Items | " : '') . strip_tags($feed['title']));
if ($list->itemType == '') {
echo '<h1>' . htmlentities($feed['title']) . '</h1>';
} else {
echo '<h1 class=item_heading>' . htmlentities($feed['title']) . '</h1>';
echo "<div class=item_published>$list->itemType Items</div>";
}
$list->render();
page_foot();
$db->close();

51
src/public/feeds.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
/**
* Feed Maintenance Page
*
* List feeds and provide links for maintenance actions
*/
include '../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
$feedQuery = $db->prepare('SELECT * FROM feed WHERE user_id = :user ORDER BY lower(title)');
$feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
if (!($feedResult = $feedQuery->execute())) {
add_error(Data::error($db)['error']);
}
page_head('Your Feeds'); ?>
<h1>Your Feeds</h1>
<article>
<p class=action_buttons><?=hx_get('/feed/?id=new', 'Add Feed')?></p><?php
if ($feedResult) {
while ($feed = $feedResult->fetchArray(SQLITE3_ASSOC)) {
$feedId = $feed['id'];
$countQuery = $db->prepare(<<<'SQL'
SELECT (SELECT COUNT(*) FROM item WHERE feed_id = :feed) AS total,
(SELECT COUNT(*) FROM item WHERE feed_id = :feed AND is_read = 0) AS unread,
(SELECT COUNT(*) FROM item WHERE feed_id = :feed AND is_bookmarked = 1) AS marked
SQL);
$countQuery->bindValue(':feed', $feed['id']);
$countResult = $countQuery->execute();
$counts = $countResult
? $countResult->fetchArray(SQLITE3_ASSOC) : ['total' => 0, 'unread' => 0, 'marked' => 0]; ?>
<p><strong><?=htmlentities($feed['title'])?></strong><br>
<span class=meta><em>Last Updated <?=date_time($feed['updated_on'])?> &bull;
As of <?=date_time($feed['checked_on'])?></em><br>
<?=hx_get("/feed/?id=$feedId", 'Edit')?> &bull; Read
<?=$counts['unread'] > 0 ? hx_get("/feed/items?id=$feedId&unread", 'Unread') : 'Unread'?>
(<?=$counts['unread']?>) |
<?=$counts['total'] > 0 ? hx_get("/feed/items?id=$feedId", 'All') : 'All'?> (<?=$counts['total']?>) |
<?=$counts['marked'] > 0 ? hx_get("/feed/items?id=$feedId&bookmarked", 'Bookmarked') : 'Bookmarked'?>
(<?=$counts['marked']?>) &bull;
<a href=/feed/?id=<?=$feedId?> hx-delete=/feed/?id=<?=$feedId?>
hx-confirm="Are you sure you want to delete &ldquo;<?=htmlentities($feed['title'], ENT_QUOTES)?>&rdquo;? This will remove the feed and all its items, including unread and bookmarked.">Delete</a>
</span><?php
}
} ?>
</article><?php
page_foot();
$db->close();

View File

@@ -2,7 +2,7 @@
/**
* Home Page
*
* Displays a list of unread feed items for the current user
* Displays a list of unread or bookmarked items for the current user
*/
include '../start.php';
@@ -10,41 +10,28 @@ include '../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
if (array_key_exists('refresh', $_GET)) {
if (key_exists('refresh', $_GET)) {
$refreshResult = Feed::refreshAll($db);
if (array_key_exists('ok', $refreshResult)) {
if (key_exists('ok', $refreshResult)) {
add_info('All feeds refreshed successfully');
} else {
add_error(nl2br($refreshResult['error']));
}
}
$query = $db->prepare(<<<'SQL'
SELECT item.id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
feed.title AS feed_title
FROM item
INNER JOIN feed ON feed.id = item.feed_id
WHERE feed.user_id = :userId
AND item.is_read = 0
ORDER BY coalesce(item.updated_on, item.published_on) DESC
SQL);
$query->bindValue(':userId', $_SESSION[Key::USER_ID]);
$result = $query->execute();
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
page_head('Welcome'); ?>
<h1>Your Unread Items &nbsp; <a href=/?refresh><small><small><em>(Refresh All Feeds)</em></small></small></a></h1>
<article><?php
if ($item) {
while ($item) { ?>
<p><a href=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a><br>
<?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
$item = $result->fetchArray(SQLITE3_ASSOC);
}
} else { ?>
<p>There are no unread items</p><?php
} ?>
</article><?php
$list = match (true) {
key_exists('bookmarked', $_GET) => ItemList::allBookmarked($db),
default => ItemList::allUnread($db)
};
$title = "Your $list->itemType Items";
page_head($title);
echo "<h1>$title";
if ($list->itemType == 'Unread') {
echo ' &nbsp; ' . hx_get('/?refresh', '(Refresh All Feeds)', 'class=refresh hx-indicator="closest h1"')
. '<span class=loading>Refreshing&hellip;</span>';
}
echo '</h1>';
$list->render();
page_foot();
$db->close();

View File

@@ -27,7 +27,29 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$keepUnread->execute();
}
$db->close();
frc_redirect('/');
frc_redirect($_POST['from']);
}
$from = $_GET['from'] ?? '/';
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
$deleteQuery = $db->prepare(<<<'SQL'
DELETE FROM item
WHERE id IN (
SELECT item.id
FROM item INNER JOIN feed ON feed.id = item.feed_id
WHERE item.id = :id
AND feed.user_id = :user)
SQL);
$deleteQuery->bindValue(':id', $_GET['id']);
$deleteQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
if ($deleteQuery->execute()) {
add_info('Item deleted');
} else {
add_error(Data::error($db)['error']);
}
$db->close();
frc_redirect($from);
}
$query = $db->prepare(<<<'SQL'
@@ -53,6 +75,8 @@ $updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null;
page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
<h1 class=item_heading>
<span class=bookmark hx-get="/bookmark?id=<?=$_GET['id']?>" hx-trigger=load hx-target=this hx-swap=outerHTML
hx-push-url=false></span>
<a href="<?=$item['item_link']?>" target=_blank rel=noopener><?=strip_tags($item['item_title'])?></a><br>
</h1>
<div class=item_published>
@@ -61,10 +85,12 @@ page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
</div>
<article>
<div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div>
<form class=action_buttons action=/item method=POST>
<form class=action_buttons action=/item method=POST hx-post=/item>
<input type=hidden name=id value=<?=$_GET['id']?>>
<a href="/">Done</a>
<input type=hidden name=from value="<?=$from?>">
<?=hx_get($from, 'Done')?>
<button type=submit>Keep as New</button>
<button type=button hx-delete=/item>Delete</button>
</form>
</article><?php
page_foot();

45
src/public/search.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* Item Search Page
*
* Search for items across all feeds
*/
include '../start.php';
$db = Data::getConnection();
Security::verifyUser($db);
$search = $_GET['search'] ?? '';
$items = $_GET['items'] ?? 'all';
if ($search != '') {
$list = ItemList::matchingSearch($search, $items == 'bookmarked', $db);
}
page_head('Item Search'); ?>
<h1>Item Search</h1>
<article>
<form method=GET action=/search>
<label>
Search Criteria
<input type=text name=search required autofocus value="<?=htmlspecialchars($search)?>">
</label>
<label>
Items to Search
<select name=items>
<option value=all <?=$items == 'all' ? ' selected' : ''?>>All</option>
<option value=bookmarked <?=$items == 'bookmarked' ? ' selected' : ''?>>Bookmarked</option>
</select>
</label>
<span class=break></span>
<button type=submit>Search</button>
</form><?php
if (isset($list)) { ?>
<hr><?php
$list->render();
} ?>
</article><?php
page_foot();
$db->close();

View File

@@ -5,6 +5,6 @@
include '../../start.php';
if (array_key_exists(Key::USER_ID, $_SESSION)) session_destroy();
if (key_exists(Key::USER_ID, $_SESSION)) session_destroy();
frc_redirect('/');

View File

@@ -5,7 +5,7 @@ $db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false);
// Users already logged on have no need of this page
if (array_key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/');
if (key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/');
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
Security::logOnUser($_POST['email'] ?? '', $_POST['password'], $_POST['returnTo'] ?? null, $db);
@@ -18,7 +18,7 @@ $isSingle = SECURITY_MODEL == Security::SINGLE_USER_WITH_PASSWORD;
page_head('Log On'); ?>
<h1>Log On</h1>
<article>
<form method=POST action=/user/log-on hx-post=/user/log-on><?php
<form method=POST action=/user/log-on><?php
if (($_GET['returnTo'] ?? '') != '') { ?>
<input type=hidden name=returnTo value="<?=$_GET['returnTo']?>"><?php
}
@@ -26,12 +26,13 @@ page_head('Log On'); ?>
<label>
E-mail Address
<input type=email name=email required autofocus>
</label><br><?php
</label><?php
} ?>
<label>
Password
<input type=password name=password required<?=$isSingle ? ' autofocus' : ''?>>
</label><br>
</label>
<span class=break></span>
<button type=submit>Log On</button>
</form>
</article><?php

View File

@@ -16,8 +16,8 @@ session_start([
* @param string $message The message itself
*/
function add_message(string $level, string $message): void {
if (!array_key_exists(Key::USER_MSG, $_REQUEST)) $_REQUEST[Key::USER_MSG] = array();
$_REQUEST[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
if (!key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array();
$_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
}
/**
@@ -38,41 +38,87 @@ function add_info(string $message): void {
add_message('INFO', $message);
}
/** @var bool $is_htmx True if this request was initiated by htmx, false if not */
$is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
function nav_link(string $link, bool $isFirst = false) {
$sep = $isFirst ? '' : ' | ';
echo "<span>$sep$link</span>";
}
/**
* Render the title bar for the page
*/
function title_bar(): void {
$version = match (true) {
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4),
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2),
default => FRC_VERSION
}; ?>
<header hx-target=#main hx-push-url=true>
<div><a href=/ class=title>Feed Reader Central</a><span class=version>v<?=$version?></span></div>
<nav><?php
if (key_exists(Key::USER_ID, $_SESSION)) {
$db = Data::getConnection();
try {
$bookQuery = $db->prepare(<<<'SQL'
SELECT EXISTS(
SELECT 1
FROM item INNER JOIN feed ON item.feed_id = feed.id
WHERE feed.user_id = :id AND item.is_bookmarked = 1)
SQL);
$bookQuery->bindValue(':id', $_SESSION[Key::USER_ID]);
$bookResult = $bookQuery->execute();
$hasBookmarks = $bookResult && $bookResult->fetchArray(SQLITE3_NUM)[0];
nav_link(hx_get('/feeds', 'Feeds'), true);
if ($hasBookmarks) nav_link(hx_get('/?bookmarked', 'Bookmarked'));
nav_link(hx_get('/search', 'Search'));
nav_link(hx_get('/docs/', 'Docs'));
nav_link('<a href=/user/log-off>Log Off</a>');
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) {
nav_link($_SESSION[Key::USER_EMAIL]);
}
} finally {
$db->close();
}
} else {
nav_link(hx_get('/user/log-on', 'Log On'), true);
nav_link(hx_get('/docs/', 'Docs'));
} ?>
</nav>
</header>
<main id=main hx-target=this hx-push-url=true hx-swap="innerHTML show:window:top"><?php
}
/**
* Render the page title
* @param string $title The title of the page being displayed
*/
function page_head(string $title): void {
$version = match (true) {
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4),
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2),
default => FRC_VERSION
} ?>
global $is_htmx;
?>
<!DOCTYPE html>
<html lang=en>
<head>
<title><?=$title?> | Feed Reader Central</title><?php
if (!$is_htmx) { ?>
<meta name=viewport content="width=device-width, initial-scale=1">
<title><?=$title?> | Feed Reader Central</title>
<link href=/assets/style.css rel=stylesheet>
</head>
<body>
<header>
<div><a class=title href="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
<div><?php
if (array_key_exists(Key::USER_ID, $_SESSION)) {
echo '<a href=/feed?id=new>Add Feed</a> | <a href=/docs/>Docs</a> | <a href=/user/log-off>Log Off</a>';
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}";
} else {
echo '<a href=/user/log-on>Log On</a> | <a href=/docs/>Docs</a>';
<meta name=htmx-config content='{"historyCacheSize":0}'>
<link href=/assets/style.css rel=stylesheet><?php
} ?>
</div>
</header>
<main hx-target=this><?php
foreach ($_REQUEST[Key::USER_MSG] ?? [] as $msg) { ?>
<div>
</head>
<body><?php
if (!$is_htmx) title_bar();
if (sizeof($messages = $_SESSION[Key::USER_MSG] ?? []) > 0) { ?>
<div class=user_messages><?php
array_walk($messages, function ($msg) { ?>
<div class=user_message>
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
<?=$msg['message']?>
</div><?php
}); ?>
</div><?php
$_SESSION[Key::USER_MSG] = [];
}
}
@@ -80,7 +126,11 @@ function page_head(string $title): void {
* Render the end of the page
*/
function page_foot(): void {
echo '</main></body></html>';
global $is_htmx; ?>
</main><?php
if (!$is_htmx) echo '<script src=/assets/htmx.min.js></script>'; ?>
</body>
</html><?php
session_commit();
}
@@ -113,3 +163,25 @@ function date_time(string $value): string {
return '(invalid date)';
}
}
/**
* Create an anchor tag with both `href` and `hx-get` attributes
*
* @param string $url The URL to which navigation should occur
* @param string $text The text for the link
* @param string $extraAttrs Extra attributes for the anchor tag (must be attribute-encoded)
* @return string The anchor tag with both `href` and `hx-get` attributes
*/
function hx_get(string $url, string $text, string $extraAttrs = ''): string {
$attrs = $extraAttrs != '' ? " $extraAttrs" : '';
return "<a href=\"$url\" hx-get=\"$url\"$attrs>$text</a>";
}
/**
* Return a 404 Not Found
*/
#[NoReturn]
function not_found(): void {
http_response_code(404);
die('Not Found');
}

View File

@@ -25,3 +25,17 @@ const DATABASE_NAME = 'frc.db';
* The default, 'F j, Y \a\t g:ia', equates to "August 17, 2023 at 4:45pm"
*/
const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia';
/**
* How should item purging be done? (Purging never applies to bookmarked items.) Options are:
* - Feed::PURGE_NONE - Do not purge items
* - Feed::PURGE_READ - Purge all read items whenever purging is run (will not purge unread items)
* - Feed::PURGE_BY_DAYS - Purge read and unread items older than a number of days (PURGE_NUMBER below)
* - Feed::PURGE_BY_COUNT - Purge read and unread items beyond the number to keep (PURGE_NUMBER below)
*/
const PURGE_TYPE = Feed::PURGE_BY_DAYS;
/**
* For purge-by-days, how many days of items should be kept; for purge-by-count, how many items should be kept
*/
const PURGE_NUMBER = 30;

53
src/util/refresh.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
use JetBrains\PhpStorm\NoReturn;
require __DIR__ . '/../cli-start.php';
cli_title('FEED REFRESH');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'all':
refresh_all();
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
#[NoReturn]
function display_help(): void {
printfn('Options:');
printfn(' - all');
printfn(' Refreshes all feeds');
exit(0);
}
function refresh_all(): void {
$db = Data::getConnection();
try {
$feeds = Feed::retrieveAll($db);
if (array_key_exists('error', $feeds)) {
printfn('SQLite error: %s', $feeds['error']);
return;
}
array_walk($feeds, function ($feed) use ($db) {
$result = Feed::refreshFeed($feed['id'], $feed['url'], $db);
if (array_key_exists('error', $result)) {
printfn('ERR (%s) %s', $feed['email'], $feed['url']);
printfn(' %s', $result['error']);
} else {
printfn('OK (%s) %s', $feed['email'], $feed['url']);
}
});
printfn(PHP_EOL . 'All feeds refreshed');
} finally {
$db->close();
}
}

49
src/util/search.php Normal file
View File

@@ -0,0 +1,49 @@
<?php
use JetBrains\PhpStorm\NoReturn;
require __DIR__ . '/../cli-start.php';
cli_title('SEARCH MAINTENANCE');
if ($argc < 2) display_help();
switch ($argv[1]) {
case 'rebuild':
rebuild_index();
break;
default:
printfn('Unrecognized option "%s"', $argv[1]);
display_help();
}
/**
* Display the options for this utility and exit
*/
#[NoReturn]
function display_help(): void {
printfn('Options:');
printfn(' - rebuild');
printfn(' Rebuilds search index');
exit(0);
}
/**
* Rebuild the search index, creating it if it does not already exist
*/
function rebuild_index(): void {
$db = Data::getConnection();
try {
$hasIndex = $db->query("SELECT COUNT(*) FROM sqlite_master WHERE name = 'item_ai'");
if ($hasIndex->fetchArray(SQLITE3_NUM)[0] == 0) {
printfn('Creating search index....');
Data::createSearchIndex($db);
}
printfn('Rebuilding search index...');
$db->exec("INSERT INTO item_search (item_search) VALUES ('rebuild')");
printfn(PHP_EOL . 'Search index rebuilt');
} finally {
$db->close();
}
}