Documents and Documentation (beta 1) #23

Merged
danieljsummers merged 16 commits from doc-experiment into main 2024-06-12 02:07:36 +00:00
34 changed files with 674 additions and 204 deletions
Showing only changes of commit df20936af2 - Show all commits

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.idea .idea
vendor
src/data/*.db src/data/*.db
src/user-config.php src/user-config.php

View File

@ -1,6 +1,9 @@
<?php <?php
/** The current Feed Reader Central version */ /** The current Feed Reader Central version */
use FeedReaderCentral\Data;
const FRC_VERSION = '1.0.0-alpha7'; const FRC_VERSION = '1.0.0-alpha7';
/** /**
@ -19,14 +22,15 @@ function display_version(): string {
return "v$major$minor$rev"; return "v$major$minor$rev";
} }
spl_autoload_register(function ($class) { require __DIR__ . '/vendor/autoload.php';
$file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]); //spl_autoload_register(function ($class) {
if (file_exists($file)) { // $file = implode(DIRECTORY_SEPARATOR, [__DIR__, 'lib', "$class.php"]);
require $file; // if (file_exists($file)) {
return true; // require $file;
} // return true;
return false; // }
}); // return false;
//});
require 'user-config.php'; require 'user-config.php';

View File

@ -12,7 +12,8 @@ if (php_sapi_name() != 'cli') {
* @param string $format The format string * @param string $format The format string
* @param mixed ...$values The values for the placeholders in the string * @param mixed ...$values The values for the placeholders in the string
*/ */
function printfn(string $format, mixed ...$values): void { function printfn(string $format, mixed ...$values): void
{
printf($format . PHP_EOL, ...$values); printf($format . PHP_EOL, ...$values);
} }
@ -21,7 +22,8 @@ function printfn(string $format, mixed ...$values): void {
* *
* @param string $title The title to display on the command line * @param string $title The title to display on the command line
*/ */
function cli_title(string $title): void { function cli_title(string $title): void
{
$appTitle = 'Feed Reader Central ~ ' . display_version(); $appTitle = 'Feed Reader Central ~ ' . display_version();
$dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+'; $dashes = ' +' . str_repeat('-', strlen($title) + 2) . '+' . str_repeat('-', strlen($appTitle) + 2) . '+';
printfn($dashes); printfn($dashes);

23
src/composer.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "bit-badger/feed-reader-central",
"minimum-stability": "dev",
"repositories": [
{
"type": "vcs",
"url": "https://git.bitbadger.solutions/bit-badger/documents-common"
},
{
"type": "vcs",
"url": "https://git.bitbadger.solutions/bit-badger/documents-sqlite"
}
],
"require": {
"bit-badger/documents-sqlite": "dev-conversion",
"ext-sqlite3": "*"
},
"autoload": {
"psr-4": {
"FeedReaderCentral\\": "lib/"
}
}
}

49
src/composer.lock generated Normal file
View File

@ -0,0 +1,49 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d25fc7caa8f5a1ce4d45b8e111a481c7",
"packages": [
{
"name": "bit-badger/documents-common",
"version": "dev-conversion",
"source": {
"type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/documents-common",
"reference": "7372bbd4c297b9d79d982c7b7f5985e7bed7df51"
},
"type": "library",
"time": "2024-05-30T02:56:55+00:00"
},
{
"name": "bit-badger/documents-sqlite",
"version": "dev-conversion",
"source": {
"type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/documents-sqlite",
"reference": "a5bae2dc4e6cf6c20d5b43aa721a5ca83cdf7c81"
},
"require": {
"bit-badger/documents-common": "dev-conversion",
"ext-sqlite3": "*"
},
"type": "library",
"time": "2024-05-30T00:09:59+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"bit-badger/documents-sqlite": 20
},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"ext-sqlite3": "*"
},
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

View File

@ -1,4 +1,11 @@
<?php <?php
namespace FeedReaderCentral;
use DateTimeImmutable;
use DateTimeInterface;
use Exception;
use SQLite3;
/** /**
* A centralized place for data access for the application * A centralized place for data access for the application
*/ */

71
src/lib/Domain/Feed.php Normal file
View File

@ -0,0 +1,71 @@
<?php
namespace FeedReaderCentral\Domain;
use DateTimeImmutable;
use Exception;
use FeedReaderCentral\Data;
use FeedReaderCentral\Key;
/**
* An RSS or Atom feed
*/
class Feed
{
/** @var int The ID of the feed */
public int $id = 0;
/** @var int The ID of the user to whom this subscription belongs */
public int $user_id = 0;
/** @var string The URL of the feed */
public string $url = '';
/** @var string|null The title of this feed */
public ?string $title = null;
/** @var string|null The date/time items in this feed were last updated */
public ?string $updated_on = null;
/** @var string|null The date/time this feed was last checked */
public ?string $checked_on = null;
/**
* The date/time items in this feed were last updated
*
* @return DateTimeImmutable|null The updated date, or null if it is not set
* @throws Exception If the date/time is an invalid format
*/
public function updatedOn(): ?DateTimeImmutable
{
return is_null($this->updated_on) ? null : new DateTimeImmutable($this->updated_on);
}
/**
* The date/time this feed was last checked
*
* @return DateTimeImmutable|null The last checked date, or null if it is not set
* @throws Exception If the date/time is an invalid format
*/
public function checkedOn(): ?DateTimeImmutable
{
return is_null($this->checked_on) ? null : new DateTimeImmutable($this->checked_on);
}
/**
* Create a document from the parsed feed
*
* @param \FeedReaderCentral\Feed $feed The parsed feed
* @return static The document constructed from the parsed feed
*/
public static function fromParsed(\FeedReaderCentral\Feed $feed): static
{
$it = new static();
$it->user_id = $_SESSION[Key::USER_ID];
$it->url = $feed->url;
$it->title = $feed->title;
$it->updated_on = $feed->updatedOn;
$it->checked_on = Data::formatDate('now');
return $it;
}
}

78
src/lib/Domain/Item.php Normal file
View File

@ -0,0 +1,78 @@
<?php
namespace FeedReaderCentral\Domain;
use FeedReaderCentral\FeedItem;
class Item
{
/** @var int The ID of this item in the Feed Reader Central database */
public int $id = 0;
/** @var int The ID of the feed to which this item belongs */
public int $feed_id = 0;
/** @var string The title of this item */
public string $title = '';
/** @var string The Globally Unique ID (GUID) for this item (an attribute in the feed XML) */
public string $item_guid = '';
/** @var string The link to the item on its original site */
public string $item_link = '';
/** @var string The date/time this item was published */
public string $published_on = '';
/** @var string|null The date/time this item was last updated */
public ?string $updated_on = null;
/** @var string The content for this item */
public string $content = '';
/** @var int 1 if the item has been read, 0 if not */
public int $is_read = 0;
/** @var int 1 if the item is bookmarked, 0 if not */
public int $is_bookmarked = 0;
/**
* Has the item been read?
*
* @return bool True if the item has been read, false if not
*/
public function isRead(): bool
{
return $this->is_read <> 0;
}
/**
* Is the item bookmarked?
*
* @return bool True if the item is bookmarked, false if not
*/
public function isBookmarked(): bool
{
return $this->is_bookmarked <> 0;
}
/**
* Create an item document from a parsed feed item
*
* @param int $feedId The ID of the feed to which this item belongs
* @param FeedItem $item The parsed feed item
* @return static The item document
*/
public static function fromFeedItem(int $feedId, FeedItem $item): static
{
$it = new static();
$it->feed_id = $feedId;
$it->item_guid = $item->guid;
$it->item_link = $item->link;
$it->title = $item->title;
$it->published_on = $item->publishedOn;
$it->updated_on = $item->updatedOn;
$it->content = $item->content;
return $it;
}
}

17
src/lib/Domain/Table.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace FeedReaderCentral\Domain;
/**
* Constants to use when accessing tables
*/
class Table
{
/** @var string The user table */
public const string USER = 'frc_user';
/** @var string The feed table */
public const string FEED = 'feed';
/** @var string The item table */
public const string ITEM = 'item';
}

17
src/lib/Domain/User.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace FeedReaderCentral\Domain;
/**
* A user of Feed Reader Central
*/
class User
{
/** @var int The ID of the user */
public int $id = 0;
/** @var string The e-mail address for the user */
public string $email = '';
/** @var string The password for the user */
public string $password = '';
}

View File

@ -1,4 +1,24 @@
<?php <?php
namespace FeedReaderCentral;
use BitBadger\Documents\Field;
use BitBadger\Documents\Query;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Document;
use BitBadger\Documents\SQLite\Find;
use BitBadger\Documents\SQLite\Patch;
use BitBadger\Documents\SQLite\Results;
use DateTimeInterface;
use DOMDocument;
use DOMElement;
use DOMException;
use DOMNode;
use Exception;
use FeedReaderCentral\Domain\Feed as FeedDocument;
use FeedReaderCentral\Domain\Item;
use FeedReaderCentral\Domain\Table;
use SQLite3;
use SQLite3Result;
/** /**
* Feed retrieval, parsing, and manipulation * Feed retrieval, parsing, and manipulation
@ -110,7 +130,7 @@ class Feed {
} }
} }
$feed = new Feed(); $feed = new static();
$feed->title = self::rssValue($channel, 'title'); $feed->title = self::rssValue($channel, 'title');
$feed->url = $url; $feed->url = $url;
$feed->updatedOn = Data::formatDate($updatedOn); $feed->updatedOn = Data::formatDate($updatedOn);
@ -267,21 +287,23 @@ class Feed {
* @return bool|SQLite3Result The result if the update is successful, false if it failed * @return bool|SQLite3Result The result if the update is successful, false if it failed
*/ */
private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): bool|SQLite3Result { private static function updateItem(int $itemId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
$query = $db->prepare(<<<'SQL' Patch::byId(Table::ITEM, $itemId, $item->patchFields(), $db);
UPDATE item // $query = $db->prepare(<<<'SQL'
SET title = :title, // UPDATE item
published_on = :published, // SET title = :title,
updated_on = :updated, // published_on = :published,
content = :content, // updated_on = :updated,
is_read = 0 // content = :content,
WHERE id = :id // is_read = 0
SQL); // WHERE id = :id
$query->bindValue(':title', $item->title); // SQL);
$query->bindValue(':published', $item->publishedOn); // $query->bindValue(':title', $item->title);
$query->bindValue(':updated', $item->updatedOn); // $query->bindValue(':published', $item->publishedOn);
$query->bindValue(':content', $item->content); // $query->bindValue(':updated', $item->updatedOn);
$query->bindValue(':id', $itemId); // $query->bindValue(':content', $item->content);
return $query->execute(); // $query->bindValue(':id', $itemId);
// return $query->execute();
return true;
} }
/** /**
@ -293,21 +315,23 @@ class Feed {
* @return bool|SQLite3Result The result if the update is successful, false if it failed * @return bool|SQLite3Result The result if the update is successful, false if it failed
*/ */
private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): bool|SQLite3Result { private static function addItem(int $feedId, FeedItem $item, SQLite3 $db): bool|SQLite3Result {
$query = $db->prepare(<<<'SQL' Document::insert(Table::ITEM, Item::fromFeedItem($feedId, $item), $db);
INSERT INTO item ( // $query = $db->prepare(<<<'SQL'
feed_id, item_guid, item_link, title, published_on, updated_on, content // INSERT INTO item (
) VALUES ( // feed_id, item_guid, item_link, title, published_on, updated_on, content
:feed, :guid, :link, :title, :published, :updated, :content // ) VALUES (
) // :feed, :guid, :link, :title, :published, :updated, :content
SQL); // )
$query->bindValue(':feed', $feedId); // SQL);
$query->bindValue(':guid', $item->guid); // $query->bindValue(':feed', $feedId);
$query->bindValue(':link', $item->link); // $query->bindValue(':guid', $item->guid);
$query->bindValue(':title', $item->title); // $query->bindValue(':link', $item->link);
$query->bindValue(':published', $item->publishedOn); // $query->bindValue(':title', $item->title);
$query->bindValue(':updated', $item->updatedOn); // $query->bindValue(':published', $item->publishedOn);
$query->bindValue(':content', $item->content); // $query->bindValue(':updated', $item->updatedOn);
return $query->execute(); // $query->bindValue(':content', $item->content);
// return $query->execute();
return true;
} }
/** /**
@ -321,6 +345,7 @@ class Feed {
public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array { public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array {
$results = $results =
array_map(function ($item) use ($db, $feedId) { array_map(function ($item) use ($db, $feedId) {
// TODO: convert this query
$existsQuery = $db->prepare( $existsQuery = $db->prepare(
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); 'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid');
$existsQuery->bindValue(':feed', $feedId); $existsQuery->bindValue(':feed', $feedId);
@ -357,6 +382,7 @@ class Feed {
} }
try { try {
// TODO: convert this query
$sql = match (PURGE_TYPE) { $sql = match (PURGE_TYPE) {
self::PURGE_READ => 'AND is_read = 1', self::PURGE_READ => 'AND is_read = 1',
self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)', self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)',
@ -401,21 +427,24 @@ class Feed {
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db); $itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
if (key_exists('error', $itemUpdate)) return $itemUpdate; if (key_exists('error', $itemUpdate)) return $itemUpdate;
$urlUpdate = $url == $feed->url ? '' : ', url = :url'; $patch = ['title' => $feed->title, 'updated_on' => $feed->updatedOn, 'checked_on' => Data::formatDate('now')];
$feedUpdate = $db->prepare(<<<SQL if ($url == $feed->url) $patch['url'] = $feed->url;
UPDATE feed Patch::byId(Table::FEED, $feedId, $patch, $db);
SET title = :title, // $urlUpdate = $url == $feed->url ? '' : ', url = :url';
updated_on = :updated, // $feedUpdate = $db->prepare(<<<SQL
checked_on = :checked // UPDATE feed
$urlUpdate // SET title = :title,
WHERE id = :id // updated_on = :updated,
SQL); // checked_on = :checked
$feedUpdate->bindValue(':title', $feed->title); // $urlUpdate
$feedUpdate->bindValue(':updated', $feed->updatedOn); // WHERE id = :id
$feedUpdate->bindValue(':checked', Data::formatDate('now')); // SQL);
$feedUpdate->bindValue(':id', $feedId); // $feedUpdate->bindValue(':title', $feed->title);
if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url); // $feedUpdate->bindValue(':updated', $feed->updatedOn);
if (!$feedUpdate->execute()) return Data::error($db); // $feedUpdate->bindValue(':checked', Data::formatDate('now'));
// $feedUpdate->bindValue(':id', $feedId);
// if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url);
// if (!$feedUpdate->execute()) return Data::error($db);
return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db); return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db);
} }
@ -432,49 +461,55 @@ class Feed {
$feed = $feedExtract['ok']; $feed = $feedExtract['ok'];
$existsQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user AND url = :url'); $whereUserAndUrl = ' WHERE ' . Query::whereByField(Field::EQ('user_id', ''), '@user')
$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]); . ' AND ' . Query::whereByField(Field::EQ('url', ''), '@url');
$existsQuery->bindValue(':url', $feed->url); $userAndUrlParams = ['@user' => $_SESSION[Key::USER_ID], '@url' => $feed->url];
if (!($exists = $existsQuery->execute())) return Data::error($db); if (Custom::scalar('SELECT EXISTS (SELECT 1 FROM ' . Table::FEED . $whereUserAndUrl . ')', $userAndUrlParams,
if ($exists->fetchArray(SQLITE3_NUM)[0] > 0) return ['error' => "Already subscribed to feed $feed->url"]; Results::toExists(...), $db)) {
return ['error' => "Already subscribed to feed $feed->url"];
}
$query = $db->prepare(<<<'SQL' Document::insert(Table::FEED, FeedDocument::fromParsed($feed), $db);
INSERT INTO feed ( // $query = $db->prepare(<<<'SQL'
user_id, url, title, updated_on, checked_on // INSERT INTO feed (
) VALUES ( // user_id, url, title, updated_on, checked_on
:user, :url, :title, :updated, :checked // ) VALUES (
) // :user, :url, :title, :updated, :checked
SQL); // )
$query->bindValue(':user', $_SESSION[Key::USER_ID]); // SQL);
$query->bindValue(':url', $feed->url); // $query->bindValue(':user', $_SESSION[Key::USER_ID]);
$query->bindValue(':title', $feed->title); // $query->bindValue(':url', $feed->url);
$query->bindValue(':updated', $feed->updatedOn); // $query->bindValue(':title', $feed->title);
$query->bindValue(':checked', Data::formatDate('now')); // $query->bindValue(':updated', $feed->updatedOn);
if (!$query->execute()) return Data::error($db); // $query->bindValue(':checked', Data::formatDate('now'));
// if (!$query->execute()) return Data::error($db);
$doc = Custom::single(Query::selectFromTable(Table::FEED) . $whereUserAndUrl, $userAndUrlParams,
Results::fromData(...), Domain\Feed::class, $db);
if (!$doc) return ['error' => 'Could not retrieve inserted feed'];
$feedId = $db->lastInsertRowID(); $result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH), $db);
$result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db);
if (key_exists('error', $result)) return $result; if (key_exists('error', $result)) return $result;
return ['ok' => $feedId]; return ['ok' => $doc->id];
} }
/** /**
* Update an RSS feed * Update an RSS feed
* *
* @param array $existing The existing RSS feed * @param FeedDocument $existing The existing RSS feed
* @param string $url The URL with which the existing feed should be modified * @param string $url The URL with which the existing feed should be modified
* @param SQLite3 $db The database connection on which to execute the update * @param SQLite3 $db The database connection on which to execute the update
* @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not * @return bool[]|string[] [ 'ok' => true ] if successful, [ 'error' => message ] if not
*/ */
public static function update(array $existing, string $url, SQLite3 $db): array { public static function update(FeedDocument $existing, string $url, SQLite3 $db): array {
// TODO: convert this query (need to make Query\Patch::update visible)
$query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); $query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user');
$query->bindValue(':url', $url); $query->bindValue(':url', $url);
$query->bindValue(':id', $existing['id']); $query->bindValue(':id', $existing->id);
$query->bindValue(':user', $_SESSION[Key::USER_ID]); $query->bindValue(':user', $_SESSION[Key::USER_ID]);
if (!$query->execute()) return Data::error($db); if (!$query->execute()) return Data::error($db);
return self::refreshFeed($existing['id'], $url, $db); return self::refreshFeed($existing->id, $url, $db);
} }
/** /**
@ -520,12 +555,14 @@ class Feed {
* *
* @param int $feedId The ID of the feed to retrieve * @param int $feedId The ID of the feed to retrieve
* @param SQLite3 $db A database connection to use to retrieve the feed * @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 * @return FeedDocument|false The data for the feed if found, false if not found
*/ */
public static function retrieveById(int $feedId, SQLite3 $db): array|bool { public static function retrieveById(int $feedId, SQLite3 $db): FeedDocument|false {
$query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user'); $doc = Find::byId(Table::FEED, $feedId, FeedDocument::class, $db);
$query->bindValue(':id', $feedId); return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false;
$query->bindValue(':user', $_SESSION[Key::USER_ID]); // $query = $db->prepare('SELECT * FROM feed WHERE id = :id AND user_id = :user');
return ($result = $query->execute()) ? $result->fetchArray(SQLITE3_ASSOC) : false; // $query->bindValue(':id', $feedId);
// $query->bindValue(':user', $_SESSION[Key::USER_ID]);
// return ($result = $query->execute()) ? $result->fetchArray(SQLITE3_ASSOC) : false;
} }
} }

View File

@ -1,4 +1,7 @@
<?php <?php
namespace FeedReaderCentral;
use DOMNode;
/** /**
* Information for a feed item * Information for a feed item
@ -23,13 +26,30 @@ class FeedItem {
/** @var string The content for the item */ /** @var string The content for the item */
public string $content = ''; public string $content = '';
/**
* Get the fields needed to update the item in the database
*
* @return array The fields needed tu update an item
*/
public function patchFields(): array
{
return [
'title' => $this->title,
'published_on' => $this->publishedOn,
'updated_on' => $this->updatedOn,
'content' => $this->content,
'is_read' => 0
];
}
/** /**
* Construct a feed item from an Atom feed's `<entry>` tag * 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 * @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node * @return static A feed item constructed from the given node
*/ */
public static function fromAtom(DOMNode $node): FeedItem { public static function fromAtom(DOMNode $node): static
{
$guid = Feed::atomValue($node, 'id'); $guid = Feed::atomValue($node, 'id');
$link = ''; $link = '';
foreach ($node->getElementsByTagName('link') as $linkElt) { foreach ($node->getElementsByTagName('link') as $linkElt) {
@ -43,7 +63,7 @@ class FeedItem {
} }
if ($link == '' && str_starts_with($guid, 'http')) $link = $guid; if ($link == '' && str_starts_with($guid, 'http')) $link = $guid;
$item = new FeedItem(); $item = new static();
$item->guid = $guid; $item->guid = $guid;
$item->title = Feed::atomValue($node, 'title'); $item->title = Feed::atomValue($node, 'title');
$item->link = $link; $item->link = $link;
@ -58,14 +78,15 @@ class FeedItem {
* Construct a feed item from an RSS feed's `<item>` tag * 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 * @param DOMNode $node The XML node from which a feed item should be constructed
* @return FeedItem A feed item constructed from the given node * @return static A feed item constructed from the given node
*/ */
public static function fromRSS(DOMNode $node): FeedItem { public static function fromRSS(DOMNode $node): static
{
$itemGuid = Feed::rssValue($node, 'guid'); $itemGuid = Feed::rssValue($node, 'guid');
$updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated'); $updNodes = $node->getElementsByTagNameNS(Feed::ATOM_NS, 'updated');
$encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded'); $encNodes = $node->getElementsByTagNameNS(Feed::CONTENT_NS, 'encoded');
$item = new FeedItem(); $item = new static();
$item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid; $item->guid = $itemGuid == 'guid not found' ? Feed::rssValue($node, 'link') : $itemGuid;
$item->title = Feed::rssValue($node, 'title'); $item->title = Feed::rssValue($node, 'title');
$item->link = Feed::rssValue($node, 'link'); $item->link = Feed::rssValue($node, 'link');

View File

@ -1,4 +1,9 @@
<?php <?php
namespace FeedReaderCentral;
use SQLite3;
use SQLite3Result;
use SQLite3Stmt;
/** /**
* A list of items to be displayed * A list of items to be displayed
@ -39,7 +44,8 @@ class ItemList {
* @param string $itemType The type of item being displayed (unread, bookmark, etc.) * @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 * @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 = '') { private function __construct(SQLite3 $db, SQLite3Stmt $query, public string $itemType, public string $returnURL = '')
{
$result = $query->execute(); $result = $query->execute();
if (!$result) { if (!$result) {
$this->error = Data::error($db)['error']; $this->error = Data::error($db)['error'];
@ -56,7 +62,8 @@ class ItemList {
* @param array $parameters Parameters to be added to the query (key index 0, value index 1; optional) * @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 * @return SQLite3Stmt The query, ready to be executed
*/ */
private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt { private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt
{
$where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria); $where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria);
$sql = <<<SQL $sql = <<<SQL
SELECT item.id, item.feed_id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of, SELECT item.id, item.feed_id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of,
@ -77,7 +84,8 @@ class ItemList {
* @param SQLite3 $db The database connection to use to obtain items * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all bookmarked items * @return static An item list with all bookmarked items
*/ */
public static function allBookmarked(SQLite3 $db): static { public static function allBookmarked(SQLite3 $db): static
{
$list = new static($db, self::makeQuery($db, ['item.is_bookmarked = 1']), 'Bookmarked', '/?bookmarked'); $list = new static($db, self::makeQuery($db, ['item.is_bookmarked = 1']), 'Bookmarked', '/?bookmarked');
$list->linkFeed = true; $list->linkFeed = true;
return $list; return $list;
@ -89,7 +97,8 @@ class ItemList {
* @param SQLite3 $db The database connection to use to obtain items * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all unread items * @return static An item list with all unread items
*/ */
public static function allUnread(SQLite3 $db): static { public static function allUnread(SQLite3 $db): static
{
$list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread'); $list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread');
$list->linkFeed = true; $list->linkFeed = true;
return $list; return $list;
@ -102,7 +111,8 @@ class ItemList {
* @param SQLite3 $db The database connection to use to obtain items * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with all items for the given feed * @return static An item list with all items for the given feed
*/ */
public static function allForFeed(int $feedId, SQLite3 $db): static { public static function allForFeed(int $feedId, SQLite3 $db): static
{
$list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '', $list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '',
"/feed/items?id=$feedId"); "/feed/items?id=$feedId");
$list->showIndicators = true; $list->showIndicators = true;
@ -116,7 +126,8 @@ class ItemList {
* @param SQLite3 $db The database connection to use to obtain items * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with unread items for the given feed * @return static An item list with unread items for the given feed
*/ */
public static function unreadForFeed(int $feedId, SQLite3 $db): static { 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]]), return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_read = 0'], [[':feed', $feedId]]),
'Unread', "/feed/items?id=$feedId&unread"); 'Unread', "/feed/items?id=$feedId&unread");
} }
@ -128,7 +139,8 @@ class ItemList {
* @param SQLite3 $db The database connection to use to obtain items * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list with bookmarked items for the given feed * @return static An item list with bookmarked items for the given feed
*/ */
public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static { public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static
{
return new static($db, return new static($db,
self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked', self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked',
"/feed/items?id=$feedId&bookmarked"); "/feed/items?id=$feedId&bookmarked");
@ -142,7 +154,8 @@ class ItemList {
* @param SQLite3 $db The database connection to use to obtain items * @param SQLite3 $db The database connection to use to obtain items
* @return static An item list match the given search terms * @return static An item list match the given search terms
*/ */
public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static { public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static
{
$where = $isBookmarked ? ['item.is_bookmarked = 1'] : []; $where = $isBookmarked ? ['item.is_bookmarked = 1'] : [];
$where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)'; $where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)';
$list = new static($db, self::makeQuery($db, $where, [[':search', $search]]), $list = new static($db, self::makeQuery($db, $where, [[':search', $search]]),
@ -156,7 +169,8 @@ class ItemList {
/** /**
* Render this item list * Render this item list
*/ */
public function render(): void { public function render(): void
{
if ($this->isError()) { ?> if ($this->isError()) { ?>
<p>Error retrieving list:<br><?=$this->error?><?php <p>Error retrieving list:<br><?=$this->error?><?php
return; return;

View File

@ -1,5 +1,9 @@
<?php <?php
namespace FeedReaderCentral;
/**
* Session and other keys used for array indexes
*/
class Key { class Key {
/** @var string The $_SESSION key for the current user's e-mail address */ /** @var string The $_SESSION key for the current user's e-mail address */

View File

@ -1,4 +1,13 @@
<?php <?php
namespace FeedReaderCentral;
use BitBadger\Documents\Field;
use BitBadger\Documents\SQLite\Document;
use BitBadger\Documents\SQLite\Find;
use BitBadger\Documents\SQLite\Patch;
use FeedReaderCentral\Domain\Table;
use FeedReaderCentral\Domain\User;
use SQLite3;
/** /**
* Security functions * Security functions
@ -28,13 +37,14 @@ class Security {
* *
* @param string $email The e-mail address of the user to retrieve * @param string $email The e-mail address of the user to retrieve
* @param SQLite3 $db The data connection to use to retrieve the user * @param SQLite3 $db The data connection to use to retrieve the user
* @return array|false The user information, or null if the user is not found * @return User|false The user information, or null if the user is not found
*/ */
public static function findUserByEmail(string $email, SQLite3 $db): array|false { public static function findUserByEmail(string $email, SQLite3 $db): User|false {
$query = $db->prepare('SELECT * FROM frc_user WHERE email = :email'); return Find::firstByField(Table::USER, Field::EQ('email', $email), User::class, $db);
$query->bindValue(':email', $email); // $query = $db->prepare('SELECT * FROM frc_user WHERE email = :email');
$result = $query->execute(); // $query->bindValue(':email', $email);
return $result ? $result->fetchArray(SQLITE3_ASSOC) : false; // $result = $query->execute();
// return $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
} }
/** /**
@ -45,27 +55,32 @@ class Security {
* @param SQLite3 $db The data connection to use to add the user * @param SQLite3 $db The data connection to use to add the user
*/ */
public static function addUser(string $email, string $password, SQLite3 $db): void { public static function addUser(string $email, string $password, SQLite3 $db): void {
$query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)'); $user = new User();
$query->bindValue(':email', $email); $user->email = $email;
$query->bindValue(':password', password_hash($password, self::PW_ALGORITHM)); $user->password = $password;
$query->execute(); Document::insert(Table::USER, $user, $db);
// $query = $db->prepare('INSERT INTO frc_user (email, password) VALUES (:email, :password)');
// $query->bindValue(':email', $email);
// $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
// $query->execute();
} }
/** /**
* Verify a user's password * Verify a user's password
* *
* @param array $user The user information retrieved from the database * @param User $user The user information retrieved from the database
* @param string $password The password provided by the user * @param string $password The password provided by the user
* @param string|null $returnTo The URL to which the user should be redirected * @param string|null $returnTo The URL to which the user should be redirected
* @param SQLite3 $db The database connection to use to verify the user's credentials * @param SQLite3 $db The database connection to use to verify the user's credentials
*/ */
private static function verifyPassword(array $user, string $password, ?string $returnTo, SQLite3 $db): void { private static function verifyPassword(User $user, string $password, ?string $returnTo, SQLite3 $db): void {
if (password_verify($password, $user['password'])) { if (password_verify($password, $user->password)) {
if (password_needs_rehash($user['password'], self::PW_ALGORITHM)) { if (password_needs_rehash($user->password, self::PW_ALGORITHM)) {
$rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id'); Patch::byId(Table::USER, $user->id, ['password' => password_hash($password, self::PW_ALGORITHM)], $db);
$rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM)); // $rehash = $db->prepare('UPDATE frc_user SET password = :hash WHERE id = :id');
$rehash->bindValue(':id', $user['id']); // $rehash->bindValue(':hash', password_hash($password, self::PW_ALGORITHM));
$rehash->execute(); // $rehash->bindValue(':id', $user['id']);
// $rehash->execute();
} }
$_SESSION[Key::USER_ID] = $user['id']; $_SESSION[Key::USER_ID] = $user['id'];
$_SESSION[Key::USER_EMAIL] = $user['email']; $_SESSION[Key::USER_EMAIL] = $user['email'];
@ -104,10 +119,12 @@ class Security {
* @param SQLite3 $db The database connection to use in updating the password * @param SQLite3 $db The database connection to use in updating the password
*/ */
public static function updatePassword(string $email, string $password, SQLite3 $db): void { public static function updatePassword(string $email, string $password, SQLite3 $db): void {
$query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email'); Patch::byField(Table::USER, Field::EQ('email', $email),
$query->bindValue(':password', password_hash($password, self::PW_ALGORITHM)); ['password' => password_hash($password, self::PW_ALGORITHM)], $db);
$query->bindValue(':email', $email); // $query = $db->prepare('UPDATE frc_user SET password = :password WHERE email = :email');
$query->execute(); // $query->bindValue(':password', password_hash($password, self::PW_ALGORITHM));
// $query->bindValue(':email', $email);
// $query->execute();
} }
/** /**

View File

@ -6,6 +6,12 @@
* This will display a button which will either add or remove a bookmark for a given item. * This will display a button which will either add or remove a bookmark for a given item.
*/ */
use BitBadger\Documents\SQLite\Patch;
use FeedReaderCentral\Data;
use FeedReaderCentral\Domain\Table;
use FeedReaderCentral\Key;
use FeedReaderCentral\Security;
include '../start.php'; include '../start.php';
$db = Data::getConnection(); $db = Data::getConnection();
@ -13,6 +19,7 @@ Security::verifyUser($db);
$id = $_GET['id']; $id = $_GET['id'];
// TODO: adapt query once "by fields" is available
$existsQuery = $db->prepare( $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'); '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(':id', $id);
@ -29,10 +36,11 @@ if (key_exists('action', $_GET)) {
$flag = 0; $flag = 0;
} }
if (isset($flag)) { if (isset($flag)) {
$update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id'); Patch::byId(Table::ITEM, $id, ['is_bookmarked' => $flag], $db);
$update->bindValue(':id', $id); // $update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id');
$update->bindValue(':flag', $flag); // $update->bindValue(':id', $id);
if (!$update->execute()) die(Data::error($db)['error']); // $update->bindValue(':flag', $flag);
// if (!$update->execute()) die(Data::error($db)['error']);
} }
} }

View File

@ -1,4 +1,8 @@
<?php <?php
use FeedReaderCentral\Data;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

@ -1,4 +1,8 @@
<?php <?php
use FeedReaderCentral\Data;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

@ -1,4 +1,8 @@
<?php <?php
use FeedReaderCentral\Data;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

@ -1,4 +1,8 @@
<?php <?php
use FeedReaderCentral\Data;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

@ -1,4 +1,8 @@
<?php <?php
use FeedReaderCentral\Data;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

@ -5,6 +5,13 @@
* Allows users to add, edit, and delete feeds * Allows users to add, edit, and delete feeds
*/ */
use BitBadger\Documents\Field;
use BitBadger\Documents\SQLite\Delete;
use FeedReaderCentral\Data;
use FeedReaderCentral\Domain\Table;
use FeedReaderCentral\Feed;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();
@ -14,16 +21,18 @@ $feedId = $_GET['id'] ?? '';
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
if (!($feed = Feed::retrieveById($feedId, $db))) not_found(); if (!($feed = Feed::retrieveById($feedId, $db))) not_found();
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id = :feed'); Delete::byField(Table::ITEM, Field::EQ('feed_id', $feed->id), $db);
$itemDelete->bindValue(':feed', $feed['id']); // $itemDelete = $db->prepare('DELETE FROM item WHERE feed_id = :feed');
if (!$itemDelete->execute()) add_error(Data::error($db)['error']); // $itemDelete->bindValue(':feed', $feed['id']);
$feedDelete = $db->prepare('DELETE FROM feed WHERE id = :feed'); // if (!$itemDelete->execute()) add_error(Data::error($db)['error']);
$feedDelete->bindValue(':feed', $feed['id']); Delete::byId(Table::FEED, $feed->id, $db);
if ($feedDelete->execute()) { // $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'); add_info('Feed &ldquo;' . htmlentities($feed['title']) . '&rdquo; deleted successfully');
} else { // } else {
add_error(Data::error($db)['error']); // add_error(Data::error($db)['error']);
} // }
frc_redirect('/feeds'); frc_redirect('/feeds');
} }

View File

@ -5,6 +5,11 @@
* Lists items in a given feed (all, unread, or bookmarked) * Lists items in a given feed (all, unread, or bookmarked)
*/ */
use FeedReaderCentral\Data;
use FeedReaderCentral\Feed;
use FeedReaderCentral\ItemList;
use FeedReaderCentral\Security;
include '../../start.php'; include '../../start.php';
$db = Data::getConnection(); $db = Data::getConnection();
@ -13,9 +18,9 @@ Security::verifyUser($db);
if (!($feed = Feed::retrieveById($_GET['id'], $db))) not_found(); if (!($feed = Feed::retrieveById($_GET['id'], $db))) not_found();
$list = match (true) { $list = match (true) {
key_exists('unread', $_GET) => ItemList::unreadForFeed($feed['id'], $db), key_exists('unread', $_GET) => ItemList::unreadForFeed($feed->id, $db),
key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed['id'], $db), key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed->id, $db),
default => ItemList::allForFeed($feed['id'], $db) default => ItemList::allForFeed($feed->id, $db)
}; };
page_head(($list->itemType != '' ? "$list->itemType Items | " : '') . strip_tags($feed['title'])); page_head(($list->itemType != '' ? "$list->itemType Items | " : '') . strip_tags($feed['title']));

View File

@ -5,11 +5,16 @@
* List feeds and provide links for maintenance actions * List feeds and provide links for maintenance actions
*/ */
use FeedReaderCentral\Data;
use FeedReaderCentral\Key;
use FeedReaderCentral\Security;
include '../start.php'; include '../start.php';
$db = Data::getConnection(); $db = Data::getConnection();
Security::verifyUser($db); Security::verifyUser($db);
// TODO: adapt query when document list is done
$feedQuery = $db->prepare('SELECT * FROM feed WHERE user_id = :user ORDER BY lower(title)'); $feedQuery = $db->prepare('SELECT * FROM feed WHERE user_id = :user ORDER BY lower(title)');
$feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]); $feedQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
if (!($feedResult = $feedQuery->execute())) { if (!($feedResult = $feedQuery->execute())) {

View File

@ -5,6 +5,11 @@
* Displays a list of unread or bookmarked items for the current user * Displays a list of unread or bookmarked items for the current user
*/ */
use FeedReaderCentral\Data;
use FeedReaderCentral\Feed;
use FeedReaderCentral\ItemList;
use FeedReaderCentral\Security;
include '../start.php'; include '../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

@ -6,6 +6,12 @@
* Retrieves and displays an item from a feed belonging to the current user * Retrieves and displays an item from a feed belonging to the current user
*/ */
use BitBadger\Documents\SQLite\Patch;
use FeedReaderCentral\Data;
use FeedReaderCentral\Domain\Table;
use FeedReaderCentral\Key;
use FeedReaderCentral\Security;
include '../start.php'; include '../start.php';
$db = Data::getConnection(); $db = Data::getConnection();
@ -22,9 +28,10 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]); $isValidQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
$isValidResult = $isValidQuery->execute(); $isValidResult = $isValidQuery->execute();
if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) { if ($isValidResult && $isValidResult->fetchArray(SQLITE3_NUM)[0] == 1) {
$keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id'); Patch::byId(Table::ITEM, $_POST['id'], ['is_read' => 0], $db);
$keepUnread->bindValue(':id', $_POST['id']); // $keepUnread = $db->prepare('UPDATE item SET is_read = 0 WHERE id = :id');
$keepUnread->execute(); // $keepUnread->bindValue(':id', $_POST['id']);
// $keepUnread->execute();
} }
$db->close(); $db->close();
frc_redirect($_POST['from']); frc_redirect($_POST['from']);

View File

@ -6,6 +6,10 @@
* Search for items across all feeds * Search for items across all feeds
*/ */
use FeedReaderCentral\Data;
use FeedReaderCentral\ItemList;
use FeedReaderCentral\Security;
include '../start.php'; include '../start.php';
$db = Data::getConnection(); $db = Data::getConnection();

View File

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

View File

@ -1,6 +1,9 @@
<?php <?php
include '../../start.php'; include '../../start.php';
use FeedReaderCentral\Key;
use FeedReaderCentral\Security;
$db = Data::getConnection(); $db = Data::getConnection();
Security::verifyUser($db, redirectIfAnonymous: false); Security::verifyUser($db, redirectIfAnonymous: false);

View File

@ -1,5 +1,8 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn;
use FeedReaderCentral\Data;
use FeedReaderCentral\Key;
use FeedReaderCentral\Security;
require 'app-config.php'; require 'app-config.php';
@ -15,7 +18,8 @@ session_start([
* @param string $level The level (type) of the message * @param string $level The level (type) of the message
* @param string $message The message itself * @param string $message The message itself
*/ */
function add_message(string $level, string $message): void { function add_message(string $level, string $message): void
{
if (!key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array(); if (!key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array();
$_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message]; $_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
} }
@ -25,7 +29,8 @@ function add_message(string $level, string $message): void {
* *
* @param string $message The message to be displayed * @param string $message The message to be displayed
*/ */
function add_error(string $message): void { function add_error(string $message): void
{
add_message('ERROR', $message); add_message('ERROR', $message);
} }
@ -34,14 +39,22 @@ function add_error(string $message): void {
* *
* @param string $message The message to be displayed * @param string $message The message to be displayed
*/ */
function add_info(string $message): void { function add_info(string $message): void
{
add_message('INFO', $message); add_message('INFO', $message);
} }
/** @var bool $is_htmx True if this request was initiated by htmx, false if not */ /** @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); $is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
function nav_link(string $link, bool $isFirst = false) { /**
* Create a navigation link in the top right nav bar
*
* @param string $link The link to be placed
* @param bool $isFirst True if this is the first link being placed, false if not
*/
function nav_link(string $link, bool $isFirst = false): void
{
$sep = $isFirst ? '' : ' | '; $sep = $isFirst ? '' : ' | ';
echo "<span>$sep$link</span>"; echo "<span>$sep$link</span>";
} }
@ -49,8 +62,9 @@ function nav_link(string $link, bool $isFirst = false) {
/** /**
* Render the title bar for the page * Render the title bar for the page
*/ */
function title_bar(): void { function title_bar(): void
$version = display_version();; ?> {
$version = display_version(); ?>
<header hx-target=#main hx-push-url=true> <header hx-target=#main hx-push-url=true>
<div><a href=/ class=title>Feed Reader Central</a><span class=version><?=$version?></span></div> <div><a href=/ class=title>Feed Reader Central</a><span class=version><?=$version?></span></div>
<nav><?php <nav><?php
@ -90,9 +104,9 @@ function title_bar(): void {
* Render the page title * Render the page title
* @param string $title The title of the page being displayed * @param string $title The title of the page being displayed
*/ */
function page_head(string $title): void { function page_head(string $title): void
global $is_htmx; {
?> global $is_htmx; ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang=en> <html lang=en>
<head> <head>
@ -121,7 +135,8 @@ function page_head(string $title): void {
/** /**
* Render the end of the page * Render the end of the page
*/ */
function page_foot(): void { function page_foot(): void
{
global $is_htmx; ?> global $is_htmx; ?>
</main><?php </main><?php
if (!$is_htmx) echo '<script src=/assets/htmx.min.js></script>'; ?> if (!$is_htmx) echo '<script src=/assets/htmx.min.js></script>'; ?>
@ -135,8 +150,8 @@ function page_foot(): void {
* *
* @param string $value A local URL to which the user should be redirected * @param string $value A local URL to which the user should be redirected
*/ */
#[NoReturn] function frc_redirect(string $value): never
function frc_redirect(string $value): void { {
if (str_starts_with($value, 'http')) { if (str_starts_with($value, 'http')) {
http_response_code(400); http_response_code(400);
die(); die();
@ -152,7 +167,8 @@ function frc_redirect(string $value): void {
* @param string $value The date/time string * @param string $value The date/time string
* @return string The standard format of a date/time, or '(invalid date)' if the date could not be parsed * @return string The standard format of a date/time, or '(invalid date)' if the date could not be parsed
*/ */
function date_time(string $value): string { function date_time(string $value): string
{
try { try {
return (new DateTimeImmutable($value))->format(DATE_TIME_FORMAT); return (new DateTimeImmutable($value))->format(DATE_TIME_FORMAT);
} catch (Exception) { } catch (Exception) {
@ -168,7 +184,8 @@ function date_time(string $value): string {
* @param string $extraAttrs Extra attributes for the anchor tag (must be attribute-encoded) * @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 * @return string The anchor tag with both `href` and `hx-get` attributes
*/ */
function hx_get(string $url, string $text, string $extraAttrs = ''): string { function hx_get(string $url, string $text, string $extraAttrs = ''): string
{
$attrs = $extraAttrs != '' ? " $extraAttrs" : ''; $attrs = $extraAttrs != '' ? " $extraAttrs" : '';
return "<a href=\"$url\" hx-get=\"$url\"$attrs>$text</a>"; return "<a href=\"$url\" hx-get=\"$url\"$attrs>$text</a>";
} }
@ -176,8 +193,8 @@ function hx_get(string $url, string $text, string $extraAttrs = ''): string {
/** /**
* Return a 404 Not Found * Return a 404 Not Found
*/ */
#[NoReturn] function not_found(): never
function not_found(): void { {
http_response_code(404); http_response_code(404);
die('Not Found'); die('Not Found');
} }

View File

@ -7,6 +7,7 @@
* On initial installation, rename this file to user-config.php and configure it as desired * On initial installation, rename this file to user-config.php and configure it as desired
*/ */
use FeedReaderCentral\Feed;
/** /**
* Which security model should the application use? Options are: * Which security model should the application use? Options are:

View File

@ -1,5 +1,7 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn;
use FeedReaderCentral\Data;
use FeedReaderCentral\Feed;
require __DIR__ . '/../cli-start.php'; require __DIR__ . '/../cli-start.php';
@ -20,15 +22,16 @@ switch ($argv[1]) {
/** /**
* Display the options for this utility and exit * Display the options for this utility and exit
*/ */
#[NoReturn] function display_help(): never
function display_help(): void { {
printfn('Options:'); printfn('Options:');
printfn(' - all'); printfn(' - all');
printfn(' Refreshes all feeds'); printfn(' Refreshes all feeds');
exit(0); exit(0);
} }
function refresh_all(): void { function refresh_all(): void
{
$db = Data::getConnection(); $db = Data::getConnection();
try { try {

View File

@ -1,5 +1,8 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Results;
use FeedReaderCentral\Data;
require __DIR__ . '/../cli-start.php'; require __DIR__ . '/../cli-start.php';
@ -20,8 +23,8 @@ switch ($argv[1]) {
/** /**
* Display the options for this utility and exit * Display the options for this utility and exit
*/ */
#[NoReturn] function display_help(): never
function display_help(): void { {
printfn('Options:'); printfn('Options:');
printfn(' - rebuild'); printfn(' - rebuild');
printfn(' Rebuilds search index'); printfn(' Rebuilds search index');
@ -31,17 +34,20 @@ function display_help(): void {
/** /**
* Rebuild the search index, creating it if it does not already exist * Rebuild the search index, creating it if it does not already exist
*/ */
function rebuild_index(): void { function rebuild_index(): void
{
$db = Data::getConnection(); $db = Data::getConnection();
try { try {
$hasIndex = $db->query("SELECT COUNT(*) FROM sqlite_master WHERE name = 'item_ai'"); $hasIndex = Custom::scalar("SELECT COUNT(*) FROM sqlite_master WHERE name = 'item_ai'", [],
if ($hasIndex->fetchArray(SQLITE3_NUM)[0] == 0) { Results::toCount(...), $db);
if ($hasIndex == 0) {
printfn('Creating search index....'); printfn('Creating search index....');
Data::createSearchIndex($db); Data::createSearchIndex($db);
} }
printfn('Rebuilding search index...'); printfn('Rebuilding search index...');
$db->exec("INSERT INTO item_search (item_search) VALUES ('rebuild')"); Custom::nonQuery("INSERT INTO item_search (item_search) VALUES ('rebuild')", [], $db);
// $db->exec("INSERT INTO item_search (item_search) VALUES ('rebuild')");
printfn(PHP_EOL . 'Search index rebuilt'); printfn(PHP_EOL . 'Search index rebuilt');
} finally { } finally {
$db->close(); $db->close();

View File

@ -1,5 +1,12 @@
<?php <?php
use JetBrains\PhpStorm\NoReturn;
use BitBadger\Documents\Field;
use BitBadger\Documents\SQLite\Count;
use BitBadger\Documents\SQLite\Delete;
use BitBadger\Documents\SQLite\Patch;
use FeedReaderCentral\Data;
use FeedReaderCentral\Domain\Table;
use FeedReaderCentral\Security;
require __DIR__ . '/../cli-start.php'; require __DIR__ . '/../cli-start.php';
@ -58,8 +65,8 @@ switch ($argv[1]) {
/** /**
* Display the options for this utility and exit * Display the options for this utility and exit
*/ */
#[NoReturn] function display_help(): never
function display_help(): void { {
printfn('Options:'); printfn('Options:');
printfn(' - add-user [e-mail] [password]'); printfn(' - add-user [e-mail] [password]');
printfn(' Adds a new user to this instance'); printfn(' Adds a new user to this instance');
@ -83,7 +90,8 @@ function display_help(): void {
/** /**
* Add a new user * Add a new user
*/ */
function add_user(): void { function add_user(): void
{
global $argv; global $argv;
$db = Data::getConnection(); $db = Data::getConnection();
@ -110,14 +118,16 @@ function add_user(): void {
* @param string $email The e-mail address of the user * @param string $email The e-mail address of the user
* @return string The string to use when displaying results * @return string The string to use when displaying results
*/ */
function display_user(string $email): string { function display_user(string $email): string
{
return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\""; return $email == Security::SINGLE_USER_EMAIL ? 'single-user mode user' : "user \"$email\"";
} }
/** /**
* Set a user's password * Set a user's password
*/ */
function set_password(string $email, string $password): void { function set_password(string $email, string $password): void
{
$db = Data::getConnection(); $db = Data::getConnection();
try { try {
$displayUser = display_user($email); $displayUser = display_user($email);
@ -144,8 +154,8 @@ function set_password(string $email, string $password): void {
* *
* @param string $email The e-mail address of the user to be deleted * @param string $email The e-mail address of the user to be deleted
*/ */
function delete_user(string $email): void { function delete_user(string $email): void
{
$db = Data::getConnection(); $db = Data::getConnection();
try { try {
@ -158,32 +168,35 @@ function delete_user(string $email): void {
return; return;
} }
$feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user'); $feedCount = Count::byField(Table::FEED, Field::EQ('user_id', $user->id));
$feedCountQuery->bindValue(':user', $user['id']); // $feedCountQuery = $db->prepare('SELECT COUNT(*) FROM feed WHERE user_id = :user');
$feedCountResult = $feedCountQuery->execute(); // $feedCountQuery->bindValue(':user', $user['id']);
if (!$feedCountResult) { // $feedCountResult = $feedCountQuery->execute();
printfn('SQLite error: %s', $db->lastErrorMsg()); // if (!$feedCountResult) {
return; // printfn('SQLite error: %s', $db->lastErrorMsg());
} // return;
$feedCount = $feedCountResult->fetchArray(SQLITE3_NUM); // }
// $feedCount = $feedCountResult->fetchArray(SQLITE3_NUM);
$proceed = readline("Delete the $displayUser and their $feedCount[0] feed(s)? (y/N)" . PHP_EOL); $proceed = readline("Delete the $displayUser and their $feedCount feed(s)? (y/N)" . PHP_EOL);
if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) { if (!$proceed || !str_starts_with(strtolower($proceed), 'y')) {
printfn('Deletion canceled'); printfn('Deletion canceled');
return; return;
} }
$itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)'); $itemDelete = $db->prepare('DELETE FROM item WHERE feed_id IN (SELECT id FROM feed WHERE user_id = :user)');
$itemDelete->bindValue(':user', $user['id']); $itemDelete->bindValue(':user', $user->id);
$itemDelete->execute(); $itemDelete->execute();
$feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user'); Delete::byField(Table::FEED, Field::EQ('user_id', $user['id']), $db);
$feedDelete->bindValue(':user', $user['id']); // $feedDelete = $db->prepare('DELETE FROM feed WHERE user_id = :user');
$feedDelete->execute(); // $feedDelete->bindValue(':user', $user['id']);
// $feedDelete->execute();
$userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user'); Delete::byId(Table::USER, $user->id, $db);
$userDelete->bindValue(':user', $user['id']); // $userDelete = $db->prepare('DELETE FROM frc_user WHERE id = :user');
$userDelete->execute(); // $userDelete->bindValue(':user', $user['id']);
// $userDelete->execute();
printfn('%s deleted successfully', init_cap($displayUser)); printfn('%s deleted successfully', init_cap($displayUser));
} finally { } finally {
@ -194,23 +207,25 @@ function delete_user(string $email): void {
/** /**
* Change the single-user mode user to a different e-mail address and password * Change the single-user mode user to a different e-mail address and password
*/ */
function migrate_single_user(): void { function migrate_single_user(): void
{
global $argv; global $argv;
$db = Data::getConnection(); $db = Data::getConnection();
try { try {
$single = Security::findUserByEmail(Security::SINGLE_USER_EMAIL, $db); if (!$single = Security::findUserByEmail(Security::SINGLE_USER_EMAIL, $db)) {
if (!$single) {
printfn('There is no single-user mode user to be migrated'); printfn('There is no single-user mode user to be migrated');
return; return;
} }
$migrateQuery = $db->prepare('UPDATE frc_user SET email = :email, password = :password WHERE id = :id'); Patch::byId(Table::USER, $single->id,
$migrateQuery->bindValue(':email', $argv[2]); ['email' => $argv[2], 'password' => password_hash($argv[3], Security::PW_ALGORITHM)], $db);
$migrateQuery->bindValue(':password', password_hash($argv[3], Security::PW_ALGORITHM)); // $migrateQuery = $db->prepare('UPDATE frc_user SET email = :email, password = :password WHERE id = :id');
$migrateQuery->bindValue(':id', $single['id']); // $migrateQuery->bindValue(':email', $argv[2]);
$migrateQuery->execute(); // $migrateQuery->bindValue(':password', password_hash($argv[3], Security::PW_ALGORITHM));
// $migrateQuery->bindValue(':id', $single['id']);
// $migrateQuery->execute();
printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]); printfn('The single user has been moved to "%s", with password "%s"', $argv[2], $argv[3]);
} finally { } finally {