WIP on document conversion

This commit is contained in:
Daniel J. Summers 2024-05-31 14:06:08 -04:00
parent df20936af2
commit 67747899ac
10 changed files with 310 additions and 285 deletions

View File

@ -2,6 +2,7 @@
/** The current Feed Reader Central version */ /** The current Feed Reader Central version */
use BitBadger\Documents\SQLite\Configuration;
use FeedReaderCentral\Data; use FeedReaderCentral\Data;
const FRC_VERSION = '1.0.0-alpha7'; const FRC_VERSION = '1.0.0-alpha7';
@ -34,6 +35,7 @@ require __DIR__ . '/vendor/autoload.php';
require 'user-config.php'; require 'user-config.php';
Configuration::useDbFileName(implode(DIRECTORY_SEPARATOR, [__DIR__, 'data', DATABASE_NAME]));
Data::ensureDb(); Data::ensureDb();
/** @var string The date the world wide web was created */ /** @var string The date the world wide web was created */

View File

@ -13,7 +13,9 @@
], ],
"require": { "require": {
"bit-badger/documents-sqlite": "dev-conversion", "bit-badger/documents-sqlite": "dev-conversion",
"ext-sqlite3": "*" "ext-sqlite3": "*",
"ext-dom": "*",
"ext-curl": "*"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

14
src/composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "d25fc7caa8f5a1ce4d45b8e111a481c7", "content-hash": "6919c5b5b8f417396276d24c8f8edbde",
"packages": [ "packages": [
{ {
"name": "bit-badger/documents-common", "name": "bit-badger/documents-common",
@ -12,10 +12,10 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/documents-common", "url": "https://git.bitbadger.solutions/bit-badger/documents-common",
"reference": "7372bbd4c297b9d79d982c7b7f5985e7bed7df51" "reference": "60bf3a7d97f06d49db3cacb9a6a84b129a83daa6"
}, },
"type": "library", "type": "library",
"time": "2024-05-30T02:56:55+00:00" "time": "2024-05-31T16:06:59+00:00"
}, },
{ {
"name": "bit-badger/documents-sqlite", "name": "bit-badger/documents-sqlite",
@ -23,14 +23,14 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://git.bitbadger.solutions/bit-badger/documents-sqlite", "url": "https://git.bitbadger.solutions/bit-badger/documents-sqlite",
"reference": "a5bae2dc4e6cf6c20d5b43aa721a5ca83cdf7c81" "reference": "9378a62e7ac190ef4bbffdd4330bf83bbe39def0"
}, },
"require": { "require": {
"bit-badger/documents-common": "dev-conversion", "bit-badger/documents-common": "dev-conversion",
"ext-sqlite3": "*" "ext-sqlite3": "*"
}, },
"type": "library", "type": "library",
"time": "2024-05-30T00:09:59+00:00" "time": "2024-05-31T16:07:51+00:00"
} }
], ],
"packages-dev": [], "packages-dev": [],
@ -42,7 +42,9 @@
"prefer-stable": false, "prefer-stable": false,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"ext-sqlite3": "*" "ext-sqlite3": "*",
"ext-dom": "*",
"ext-curl": "*"
}, },
"platform-dev": [], "platform-dev": [],
"plugin-api-version": "2.6.0" "plugin-api-version": "2.6.0"

View File

@ -1,9 +1,15 @@
<?php <?php
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\SQLite\Configuration;
use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Definition;
use BitBadger\Documents\StringMapper;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
use Exception; use Exception;
use FeedReaderCentral\Domain\Table;
use SQLite3; use SQLite3;
/** /**
@ -25,71 +31,92 @@ class Data {
* Create the search index and synchronization triggers for the item table * Create the search index and synchronization triggers for the item table
* *
* @param SQLite3 $db The database connection on which these will be created * @param SQLite3 $db The database connection on which these will be created
* @throws DocumentException If any is encountered
*/ */
public static function createSearchIndex(SQLite3 $db): void { 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' Custom::nonQuery("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')",
CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN [], $db);
INSERT INTO item_search (rowid, content) VALUES (new.id, new.content); Custom::nonQuery(<<<'SQL'
END; CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN
SQL); INSERT INTO item_search (rowid, content) VALUES (new.data->>'id', new.data->>'content');
$db->exec(<<<'SQL' END;
CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN SQL, [], $db);
INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content); Custom::nonQuery(<<<'SQL'
INSERT INTO item_search (rowid, content) VALUES (new.id, new.content); CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN
END; INSERT INTO item_search (
SQL); item_search, rowid, content
$db->exec(<<<'SQL' ) VALUES (
CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN 'delete', old.data->>'id', old.data->>'content'
INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content); );
END; INSERT INTO item_search (rowid, content) VALUES (new.data->>'id', new.data->>'content');
SQL); END;
SQL, [], $db);
Custom::nonQuery(<<<'SQL'
CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN
INSERT INTO item_search (
item_search, rowid, content
) VALUES (
'delete', old.data->>'id', old.data->>'content'
);
END;
SQL, [], $db);
} }
/** /**
* Make sure the expected tables exist * Make sure the expected tables exist
*
* @throws DocumentException If any is encountered
*/ */
public static function ensureDb(): void { public static function ensureDb(): void
$db = self::getConnection(); {
$tables = array(); $tables = Custom::array("SELECT name FROM sqlite_master WHERE type = 'table'", [], new StringMapper('name'));
$tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'"); $db = Configuration::dbConn();
while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0]; // $tables = array();
if (!in_array('frc_user', $tables)) { // $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'");
$db->exec(<<<'SQL' // while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0];
CREATE TABLE frc_user ( if (!in_array(Table::USER, $tables)) {
id INTEGER NOT NULL PRIMARY KEY, Definition::ensureTable(Table::USER, $db);
email TEXT NOT NULL, Definition::ensureFieldIndex(Table::USER, 'email', ['email'], $db);
password TEXT NOT NULL) // $db->exec(<<<'SQL'
SQL); // CREATE TABLE frc_user (
$db->exec('CREATE INDEX idx_user_email ON frc_user (email)'); // id INTEGER NOT NULL PRIMARY KEY,
// email TEXT NOT NULL,
// password TEXT NOT NULL)
// SQL);
// $db->exec('CREATE INDEX idx_user_email ON frc_user (email)');
} }
if (!in_array('feed', $tables)) { if (!in_array(Table::FEED, $tables)) {
$db->exec(<<<'SQL' Definition::ensureTable(Table::FEED, $db);
CREATE TABLE feed ( Definition::ensureFieldIndex(Table::FEED, 'user', ['user_id'], $db);
id INTEGER NOT NULL PRIMARY KEY, // $db->exec(<<<'SQL'
user_id INTEGER NOT NULL, // CREATE TABLE feed (
url TEXT NOT NULL, // id INTEGER NOT NULL PRIMARY KEY,
title TEXT, // user_id INTEGER NOT NULL,
updated_on TEXT, // url TEXT NOT NULL,
checked_on TEXT, // title TEXT,
FOREIGN KEY (user_id) REFERENCES frc_user (id)) // updated_on TEXT,
SQL); // checked_on TEXT,
// FOREIGN KEY (user_id) REFERENCES frc_user (id))
// SQL);
} }
if (!in_array('item', $tables)) { if (!in_array(Table::ITEM, $tables)) {
$db->exec(<<<'SQL' Definition::ensureTable(Table::ITEM, $db);
CREATE TABLE item ( Definition::ensureFieldIndex(Table::ITEM, 'feed', ['feed_id', 'item_link'], $db);
id INTEGER NOT NULL PRIMARY KEY, // $db->exec(<<<'SQL'
feed_id INTEGER NOT NULL, // CREATE TABLE item (
title TEXT NOT NULL, // id INTEGER NOT NULL PRIMARY KEY,
item_guid TEXT NOT NULL, // feed_id INTEGER NOT NULL,
item_link TEXT NOT NULL, // title TEXT NOT NULL,
published_on TEXT NOT NULL, // item_guid TEXT NOT NULL,
updated_on TEXT, // item_link TEXT NOT NULL,
content TEXT NOT NULL, // published_on TEXT NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT 0, // updated_on TEXT,
is_bookmarked BOOLEAN NOT NULL DEFAULT 0, // content TEXT NOT NULL,
FOREIGN KEY (feed_id) REFERENCES feed (id)) // is_read BOOLEAN NOT NULL DEFAULT 0,
SQL); // is_bookmarked BOOLEAN NOT NULL DEFAULT 0,
// FOREIGN KEY (feed_id) REFERENCES feed (id))
// SQL);
self::createSearchIndex($db); self::createSearchIndex($db);
} }
$db->close(); $db->close();

View File

@ -4,6 +4,7 @@ namespace FeedReaderCentral\Domain;
use DateTimeImmutable; use DateTimeImmutable;
use Exception; use Exception;
use FeedReaderCentral\Data; use FeedReaderCentral\Data;
use FeedReaderCentral\Feed as FeedParsed;
use FeedReaderCentral\Key; use FeedReaderCentral\Key;
/** /**
@ -54,15 +55,15 @@ class Feed
/** /**
* Create a document from the parsed feed * Create a document from the parsed feed
* *
* @param \FeedReaderCentral\Feed $feed The parsed feed * @param FeedParsed $feed The parsed feed
* @return static The document constructed from the parsed feed * @return static The document constructed from the parsed feed
*/ */
public static function fromParsed(\FeedReaderCentral\Feed $feed): static public static function fromParsed(FeedParsed $feed): static
{ {
$it = new static(); $it = new static();
$it->user_id = $_SESSION[Key::USER_ID]; $it->user_id = $_SESSION[Key::USER_ID];
$it->url = $feed->url; $it->url = $feed->url;
$it->title = $feed->title; $it->title = $feed->title;
$it->updated_on = $feed->updatedOn; $it->updated_on = $feed->updatedOn;
$it->checked_on = Data::formatDate('now'); $it->checked_on = Data::formatDate('now');

View File

@ -3,6 +3,9 @@ namespace FeedReaderCentral\Domain;
use FeedReaderCentral\FeedItem; use FeedReaderCentral\FeedItem;
/**
* An item from a feed
*/
class Item class Item
{ {
/** @var int The ID of this item in the Feed Reader Central database */ /** @var int The ID of this item in the Feed Reader Central database */

View File

@ -1,24 +1,25 @@
<?php <?php
namespace FeedReaderCentral; namespace FeedReaderCentral;
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\DocumentList;
use BitBadger\Documents\Field; use BitBadger\Documents\Field;
use BitBadger\Documents\Query; use BitBadger\Documents\Query;
use BitBadger\Documents\SQLite\Configuration;
use BitBadger\Documents\SQLite\Custom; use BitBadger\Documents\SQLite\Custom;
use BitBadger\Documents\SQLite\Document; use BitBadger\Documents\SQLite\Document;
use BitBadger\Documents\SQLite\Exists;
use BitBadger\Documents\SQLite\Find; use BitBadger\Documents\SQLite\Find;
use BitBadger\Documents\SQLite\Patch; use BitBadger\Documents\SQLite\Patch;
use BitBadger\Documents\SQLite\Results;
use DateTimeInterface; use DateTimeInterface;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use DOMException; use DOMException;
use DOMNode; use DOMNode;
use Exception;
use FeedReaderCentral\Domain\Feed as FeedDocument; use FeedReaderCentral\Domain\Feed as FeedDocument;
use FeedReaderCentral\Domain\Item; use FeedReaderCentral\Domain\Item;
use FeedReaderCentral\Domain\Table; use FeedReaderCentral\Domain\Table;
use SQLite3; use SQLite3;
use SQLite3Result;
/** /**
* Feed retrieval, parsing, and manipulation * Feed retrieval, parsing, and manipulation
@ -278,62 +279,6 @@ class Feed {
return $extract($parsed['ok'], $doc['url']); return $extract($parsed['ok'], $doc['url']);
} }
/**
* Update a feed item
*
* @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): bool|SQLite3Result {
Patch::byId(Table::ITEM, $itemId, $item->patchFields(), $db);
// $query = $db->prepare(<<<'SQL'
// UPDATE item
// SET title = :title,
// published_on = :published,
// updated_on = :updated,
// content = :content,
// is_read = 0
// WHERE id = :id
// SQL);
// $query->bindValue(':title', $item->title);
// $query->bindValue(':published', $item->publishedOn);
// $query->bindValue(':updated', $item->updatedOn);
// $query->bindValue(':content', $item->content);
// $query->bindValue(':id', $itemId);
// return $query->execute();
return true;
}
/**
* Add a feed item
*
* @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): bool|SQLite3Result {
Document::insert(Table::ITEM, Item::fromFeedItem($feedId, $item), $db);
// $query = $db->prepare(<<<'SQL'
// INSERT INTO item (
// feed_id, item_guid, item_link, title, published_on, updated_on, content
// ) VALUES (
// :feed, :guid, :link, :title, :published, :updated, :content
// )
// SQL);
// $query->bindValue(':feed', $feedId);
// $query->bindValue(':guid', $item->guid);
// $query->bindValue(':link', $item->link);
// $query->bindValue(':title', $item->title);
// $query->bindValue(':published', $item->publishedOn);
// $query->bindValue(':updated', $item->updatedOn);
// $query->bindValue(':content', $item->content);
// return $query->execute();
return true;
}
/** /**
* Update a feed's items * Update a feed's items
* *
@ -345,24 +290,21 @@ 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 try {
$existsQuery = $db->prepare( $existing = Find::firstByFields(Table::ITEM,
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid'); [Field::EQ('feed_id', $feedId), Field::EQ('item_guid', $item->guid)], Item::class);
$existsQuery->bindValue(':feed', $feedId); if ($existing) {
$existsQuery->bindValue(':guid', $item->guid); if ($existing->published_on != $item->publishedOn
if ($exists = $existsQuery->execute()) { || ($existing->updated_on != ($item->updatedOn ?? ''))) {
if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) { Patch::byId(Table::ITEM, $existing->id, $item->patchFields(), $db);
if ( $existing['published_on'] != $item->publishedOn
|| ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
} }
} else { } else {
if (!self::addItem($feedId, $item, $db)) return Data::error($db); Document::insert(Table::ITEM, Item::fromFeedItem($feedId, $item), $db);
} }
} else { return ['ok' => true];
return Data::error($db); } catch (DocumentException $ex) {
return ['error' => "$ex"];
} }
return ['ok' => true];
}, array_filter($feed->items, }, array_filter($feed->items,
fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked)); 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))); $errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it)));
@ -381,26 +323,33 @@ class Feed {
return ['error' => 'Unrecognized purge type ' . PURGE_TYPE]; return ['error' => 'Unrecognized purge type ' . PURGE_TYPE];
} }
$fields = [Field::EQ('feed_id', $feedId, '@feed'), Field::EQ('is_bookmarked', 0, '@book')];
$sql = Query\Delete::byFields(Table::ITEM, $fields);
if (PURGE_TYPE == self::PURGE_READ) {
$readField = Field::EQ('is_read', 1, '@read');
$fields[] = $readField;
$sql .= ' AND ' . Query::whereByFields([$readField]);
} elseif (PURGE_TYPE == self::PURGE_BY_DAYS) {
$fields[] = Field::EQ('', Data::formatDate('-' . PURGE_NUMBER . ' day'), '@oldest');
$sql .= " AND date(coalesce(data->>'updated_on', data->>'published_on)) < date(@oldest)";
} elseif (PURGE_TYPE == self::PURGE_BY_COUNT) {
$fields[] = Field::EQ('', PURGE_NUMBER, '@keep');
$id = Configuration::idField();
$table = Table::ITEM;
$sql .= ' ' . <<<SQL
AND data->>'$id' IN (
SELECT data->>'$id' FROM $table
WHERE data->>'feed_id' = @feed
ORDER BY date(coalesce(data->>'updated_on', data->>'published_on')) DESC
LIMIT -1 OFFSET @keep
)
SQL;
}
try { try {
// TODO: convert this query Custom::nonQuery($sql, array_merge(array_map($it -> $it->asParameter(), $fields)), $db);
$sql = match (PURGE_TYPE) { return ['ok' => true];
self::PURGE_READ => 'AND is_read = 1', } catch (DocumentException $ex) {
self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)', return ['error' => "$ex"];
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()];
} }
} }
@ -417,35 +366,25 @@ class Feed {
if (key_exists('error', $feedRetrieval)) return $feedRetrieval; if (key_exists('error', $feedRetrieval)) return $feedRetrieval;
$feed = $feedRetrieval['ok']; $feed = $feedRetrieval['ok'];
$lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id'); try {
$lastCheckedQuery->bindValue(':id', $feedId); $feedDoc = Find::byId(Table::FEED, $feedId, FeedDocument::class);
if (!($lastCheckedResult = $lastCheckedQuery->execute())) return Data::error($db); if (!$feedDoc) return ['error' => 'Could not derive date last checked for feed'];
if (!($lastChecked = date_create_immutable($lastCheckedResult->fetchArray(SQLITE3_NUM)[0] ?? WWW_EPOCH))) { $lastChecked = date_create_immutable($feedDoc->checked_on ?? WWW_EPOCH);
return ['error' => 'Could not derive date last checked for feed'];
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
if (key_exists('error', $itemUpdate)) return $itemUpdate;
$patch = [
'title' => $feed->title,
'updated_on' => $feed->updatedOn,
'checked_on' => Data::formatDate('now')
];
if ($url == $feed->url) $patch['url'] = $feed->url;
Patch::byId(Table::FEED, $feedId, $patch, $db);
} catch (DocumentException $ex) {
return ['error' => "$ex"];
} }
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
if (key_exists('error', $itemUpdate)) return $itemUpdate;
$patch = ['title' => $feed->title, 'updated_on' => $feed->updatedOn, 'checked_on' => Data::formatDate('now')];
if ($url == $feed->url) $patch['url'] = $feed->url;
Patch::byId(Table::FEED, $feedId, $patch, $db);
// $urlUpdate = $url == $feed->url ? '' : ', url = :url';
// $feedUpdate = $db->prepare(<<<SQL
// UPDATE feed
// SET title = :title,
// updated_on = :updated,
// checked_on = :checked
// $urlUpdate
// WHERE id = :id
// SQL);
// $feedUpdate->bindValue(':title', $feed->title);
// $feedUpdate->bindValue(':updated', $feed->updatedOn);
// $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);
} }
@ -461,31 +400,19 @@ class Feed {
$feed = $feedExtract['ok']; $feed = $feedExtract['ok'];
$whereUserAndUrl = ' WHERE ' . Query::whereByField(Field::EQ('user_id', ''), '@user') try {
. ' AND ' . Query::whereByField(Field::EQ('url', ''), '@url'); $fields = [Field::EQ('user_id', $_SESSION[Key::USER_ID]), Field::EQ('url', $feed->url)];
$userAndUrlParams = ['@user' => $_SESSION[Key::USER_ID], '@url' => $feed->url]; if (Exists::byFields(Table::FEED, $fields, $db)) {
if (Custom::scalar('SELECT EXISTS (SELECT 1 FROM ' . Table::FEED . $whereUserAndUrl . ')', $userAndUrlParams, return ['error' => "Already subscribed to feed $feed->url"];
Results::toExists(...), $db)) { }
return ['error' => "Already subscribed to feed $feed->url"];
}
Document::insert(Table::FEED, FeedDocument::fromParsed($feed), $db); Document::insert(Table::FEED, FeedDocument::fromParsed($feed), $db);
// $query = $db->prepare(<<<'SQL'
// INSERT INTO feed ( $doc = Find::firstByFields(Table::FEED, $fields, FeedDocument::class);
// user_id, url, title, updated_on, checked_on if (!$doc) return ['error' => 'Could not retrieve inserted feed'];
// ) VALUES ( } catch (DocumentException $ex) {
// :user, :url, :title, :updated, :checked return ['error' => "$ex"];
// ) }
// 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'));
// 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'];
$result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH), $db); $result = self::updateItems($doc->id, $feed, date_create_immutable(WWW_EPOCH), $db);
if (key_exists('error', $result)) return $result; if (key_exists('error', $result)) return $result;
@ -502,12 +429,13 @@ class Feed {
* @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(FeedDocument $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) try {
$query = $db->prepare('UPDATE feed SET url = :url WHERE id = :id AND user_id = :user'); Patch::byFields(Table::FEED,
$query->bindValue(':url', $url); [Field::EQ(Configuration::idField(), $existing->id), Field::EQ('user_id', $_SESSION[Key::USER_ID])],
$query->bindValue(':id', $existing->id); ['url' => $url], $db);
$query->bindValue(':user', $_SESSION[Key::USER_ID]); } catch (DocumentException $ex) {
if (!$query->execute()) return Data::error($db); return ['error' => "$ex"];
}
return self::refreshFeed($existing->id, $url, $db); return self::refreshFeed($existing->id, $url, $db);
} }
@ -515,19 +443,15 @@ class Feed {
/** /**
* Retrieve all feeds, optionally for a specific user * 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) * @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 * @return DocumentList<FeedDocument> A list of feeds
* @throws DocumentException If any is encountered
*/ */
public static function retrieveAll(SQLite3 $db, int $user = 0): array { public static function retrieveAll(int $user = 0): DocumentList
$extraSQL = $user > 0 ? ' WHERE u.id = :user' : ''; {
$query = $db->prepare( return $user == 0
"SELECT f.id, f.url, u.email FROM feed f INNER JOIN frc_user u ON u.id = f.user_id$extraSQL"); ? Find::all(Table::FEED, FeedDocument::class)
if ($user > 0) $query->bindValue(':user', $user); : Find::byFields(Table::FEED, [Field::EQ('user_id', $user)], FeedDocument::class);
if (!($result = $query->execute())) return Data::error($db);
$feeds = [];
while ($feed = $result->fetchArray(SQLITE3_ASSOC)) $feeds[] = $feed;
return $feeds;
} }
/** /**
@ -537,15 +461,19 @@ class Feed {
* @return array|true[]|string[] ['ok' => true] if successful, * @return array|true[]|string[] ['ok' => true] if successful,
* ['error' => message] if not (may have multiple error lines) * ['error' => message] if not (may have multiple error lines)
*/ */
public static function refreshAll(SQLite3 $db): array { public static function refreshAll(SQLite3 $db): array
$feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]); {
if (key_exists('error', $feeds)) return $feeds; try {
$feeds = self::retrieveAll($_SESSION[Key::USER_ID]);
$errors = []; $errors = [];
array_walk($feeds, function ($feed) use ($db, &$errors) { foreach ($feeds->items() as $feed) {
$result = self::refreshFeed($feed['id'], $feed['url'], $db); $result = self::refreshFeed($feed->id, $feed->url, $db);
if (key_exists('error', $result)) $errors[] = $result['error']; if (key_exists('error', $result)) $errors[] = $result['error'];
}); }
} catch (DocumentException $ex) {
return ['error' => "$ex"];
}
return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)]; return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)];
} }
@ -554,15 +482,11 @@ class Feed {
* Retrieve a feed by its ID for the current user * Retrieve a feed by its ID for the current user
* *
* @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
* @return FeedDocument|false 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
* @throws DocumentException If any is encountered
*/ */
public static function retrieveById(int $feedId, SQLite3 $db): FeedDocument|false { public static function retrieveById(int $feedId): FeedDocument|false {
$doc = Find::byId(Table::FEED, $feedId, FeedDocument::class, $db); $doc = Find::byId(Table::FEED, $feedId, FeedDocument::class);
return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false; return $doc && $doc->user_id == $_SESSION[Key::USER_ID] ? $doc : false;
// $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;
} }
} }

53
src/lib/ItemAndFeed.php Normal file
View File

@ -0,0 +1,53 @@
<?php
namespace FeedReaderCentral;
use BitBadger\Documents\JsonMapper;
use BitBadger\Documents\Mapper;
use FeedReaderCentral\Domain\Feed;
use FeedReaderCentral\Domain\Item;
use FeedReaderCentral\Domain\Table;
/**
* A combined item and feed (used for lists)
*/
class ItemAndFeed
{
/** @var Item The item to be manipulated */
public Item $item;
/** @var Feed The feed to which the item belongs */
public Feed $feed;
/**
* Create a mapper for this item
* @return Mapper<ItemAndFeed> A mapper to deserialize this from the query
*/
public static function mapper(): Mapper
{
return new class implements Mapper {
public function map(array $result): ItemAndFeed
{
$it = new ItemAndFeed();
$it->item = (new JsonMapper(Item::class, 'item_data'))->map($result);
$it->feed = (new JsonMapper(Feed::class, 'feed_data'))->map($result);
return $it;
}
};
}
/**
* Generate the `SELECT` and `FROM` clauses for the query to retrieve this item
*
* @return string The `SELECT` and `FROM` clauses to retrieve these items
*/
public static function selectFrom(): string
{
$item = Table::ITEM;
$feed = Table::FEED;
return <<<SQL
SELECT $item.data AS item_data, $feed.data AS feed_data
FROM $item INNER JOIN $feed ON $item.data->>'feed_id' = $feed.data->>'id'
SQL;
}
}

View File

@ -5,6 +5,7 @@
* Allows users to add, edit, and delete feeds * Allows users to add, edit, and delete feeds
*/ */
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\Field; use BitBadger\Documents\Field;
use BitBadger\Documents\SQLite\Delete; use BitBadger\Documents\SQLite\Delete;
use FeedReaderCentral\Data; use FeedReaderCentral\Data;
@ -20,46 +21,49 @@ Security::verifyUser($db);
$feedId = $_GET['id'] ?? ''; $feedId = $_GET['id'] ?? '';
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
if (!($feed = Feed::retrieveById($feedId, $db))) not_found(); try {
Delete::byField(Table::ITEM, Field::EQ('feed_id', $feed->id), $db); if (!($feed = Feed::retrieveById($feedId))) not_found();
// $itemDelete = $db->prepare('DELETE FROM item WHERE feed_id = :feed'); Delete::byFields(Table::ITEM, [Field::EQ('feed_id', $feed->id)], $db);
// $itemDelete->bindValue(':feed', $feed['id']); Delete::byId(Table::FEED, $feed->id, $db);
// if (!$itemDelete->execute()) add_error(Data::error($db)['error']); add_info('Feed &ldquo;' . htmlentities($feed->title) . '&rdquo; deleted successfully');
Delete::byId(Table::FEED, $feed->id, $db); $db->close();
// $feedDelete = $db->prepare('DELETE FROM feed WHERE id = :feed'); frc_redirect('/feeds');
// $feedDelete->bindValue(':feed', $feed['id']); } catch (DocumentException $ex) {
// if ($feedDelete->execute()) { add_error("$ex");
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') { if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$isNew = $_POST['id'] == 'new'; try {
if ($isNew) { $isNew = $_POST['id'] == 'new';
$result = Feed::add($_POST['url'], $db); if ($isNew) {
} else { $result = Feed::add($_POST['url'], $db);
$toEdit = Feed::retrieveById($_POST['id'], $db); } else {
$result = $toEdit ? Feed::update($toEdit, $_POST['url'], $db) : ['error' => "Feed {$_POST['id']} not found"]; $toEdit = Feed::retrieveById($_POST['id']);
$result = $toEdit
? Feed::update($toEdit, $_POST['url'], $db)
: ['error' => "Feed {$_POST['id']} not found"];
}
if (key_exists('ok', $result)) {
add_info('Feed saved successfully');
$db->close();
frc_redirect('/feeds');
}
add_error($result['error']);
$feedId = 'error';
} catch (DocumentException $ex) {
add_error("$ex");
} }
if (key_exists('ok', $result)) {
add_info('Feed saved successfully');
frc_redirect('/feeds');
}
add_error($result['error']);
$feedId = 'error';
} }
if ($feedId == 'new') { if ($feedId == 'new') {
$title = 'Add RSS Feed'; $title = 'Add RSS Feed';
$feed = [ 'id' => $_GET['id'], 'url' => '']; $feed = ['id' => $_GET['id'], 'url' => ''];
} else { } else {
$title = 'Edit RSS Feed'; $title = 'Edit RSS Feed';
if ($feedId == 'error') { if ($feedId == 'error') {
$feed = ['id' => $_POST['id'] ?? '', 'url' => $_POST['url'] ?? '']; $feed = ['id' => $_POST['id'] ?? '', 'url' => $_POST['url'] ?? ''];
} elseif (!($feed = Feed::retrieveById((int) $feedId, $db))) not_found(); } elseif (!($feed = Feed::retrieveById((int)$feedId))) not_found();
} }
page_head($title); ?> page_head($title); ?>

View File

@ -1,6 +1,10 @@
<?php <?php
use BitBadger\Documents\DocumentException;
use BitBadger\Documents\SQLite\Find;
use FeedReaderCentral\Data; use FeedReaderCentral\Data;
use FeedReaderCentral\Domain\Table;
use FeedReaderCentral\Domain\User;
use FeedReaderCentral\Feed; use FeedReaderCentral\Feed;
require __DIR__ . '/../cli-start.php'; require __DIR__ . '/../cli-start.php';
@ -32,25 +36,28 @@ function display_help(): never
function refresh_all(): void function refresh_all(): void
{ {
$db = Data::getConnection(); $db = Data::getConnection();
$users = [];
try { try {
$feeds = Feed::retrieveAll($db); $feeds = Feed::retrieveAll();
if (array_key_exists('error', $feeds)) { foreach ($feeds->items() as /** @var Feed $feed */ $feed) {
printfn('SQLite error: %s', $feeds['error']); $result = Feed::refreshFeed($feed->id, $feed->url, $db);
return; $userKey = "$feed->user_id";
} if (!key_exists($userKey, $users)) $users[$userKey] = Find::byId(Table::USER, $feed->user_id, User::class);
array_walk($feeds, function ($feed) use ($db) {
$result = Feed::refreshFeed($feed['id'], $feed['url'], $db);
if (array_key_exists('error', $result)) { if (array_key_exists('error', $result)) {
printfn('ERR (%s) %s', $feed['email'], $feed['url']); printfn('ERR (%s) %s', $users[$userKey]->email, $feed->url);
printfn(' %s', $result['error']); printfn(' %s', $result['error']);
} else { } else {
printfn('OK (%s) %s', $feed['email'], $feed['url']); printfn('OK (%s) %s', $users[$userKey]->email, $feed->url);
} }
}); }
printfn(PHP_EOL . 'All feeds refreshed'); printfn(PHP_EOL . 'All feeds refreshed');
} finally { } catch (DocumentException $ex) {
printfn("ERR $ex");
return;
}
finally {
$db->close(); $db->close();
} }
} }