Alpha 5 #20
@ -46,7 +46,8 @@ The default format for dates and times look like "May 28, 2023 at 3:15pm". Chang
|
||||
|
||||
### Item Purging
|
||||
|
||||
Feed Reader Central tries to keep the database tidy by purging items that have been read and are no longer required. There are three variants:
|
||||
Feed Reader Central tries to keep the database tidy by purging items that have been read and are no longer required. There are four variants:
|
||||
- `Feed::PURGE_NONE` does no purging (items have a "Delete" button, so they may be deleted manually)
|
||||
- `Feed::PURGE_READ` purges non-bookmarked read items for a feed whenever it is refreshed. This is the most aggressive purging strategy, but it is also the only one that will not purge unread items.
|
||||
- `Feed::PURGE_BY_DAYS` purges non-bookmarked items that are older than `PURGE_NUMBER` days old. This is the default value, and `PURGE_NUMBER`'s default value is 30; items will be kept for 30 days, read or unread.
|
||||
- `Feed::PURGE_BY_COUNT` purges items to preserve at most `PURGE_NUMBER` non-bookmarked items for each feed.
|
||||
|
@ -15,3 +15,6 @@ spl_autoload_register(function ($class) {
|
||||
require 'user-config.php';
|
||||
|
||||
Data::ensureDb();
|
||||
|
||||
/** @var string The date the world wide web was created */
|
||||
const WWW_EPOCH = '1993-04-30T00:00:00+00:00';
|
||||
|
@ -30,6 +30,9 @@ class Feed {
|
||||
private const string USER_AGENT =
|
||||
'FeedReaderCentral/' . FRC_VERSION . ' +https://bitbadger.solutions/open-source/feed-reader-central';
|
||||
|
||||
/** @var int Do not purge items */
|
||||
public const int PURGE_NONE = 0;
|
||||
|
||||
/** @var int Purge all read items (will not purge unread items) */
|
||||
public const int PURGE_READ = 1;
|
||||
|
||||
@ -312,38 +315,33 @@ class Feed {
|
||||
*
|
||||
* @param int $feedId The ID of the feed to which these items belong
|
||||
* @param Feed $feed The extracted Atom or RSS feed items
|
||||
* @param DateTimeInterface $lastChecked When this feed was last checked (only new items will be added)
|
||||
* @return array ['ok' => true] if successful, ['error' => message] if not
|
||||
*/
|
||||
public static function updateItems(int $feedId, Feed $feed, SQLite3 $db): array {
|
||||
|
||||
// Do not add items that are older than the oldest we currently have; this keeps us from re-adding items that
|
||||
// have been purged already
|
||||
$oldestQuery = $db->prepare(
|
||||
'SELECT MIN(coalesce(updated_on, published_on)) FROM item where feed_id = :feed AND is_bookmarked = 0');
|
||||
$oldestQuery->bindValue(':feed', $feedId);
|
||||
if (!($oldest = $oldestQuery->execute())) return Data::error($db);
|
||||
$minDate = date_create_immutable($oldest->fetchArray(SQLITE3_NUM)[0] ?? '1993-04-30T00:00:00+00:00');
|
||||
|
||||
foreach ($feed->items as $item) {
|
||||
if (date_create_immutable($item->updatedOn ?? $item->publishedOn) < $minDate) continue;
|
||||
$existsQuery = $db->prepare(
|
||||
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid');
|
||||
$existsQuery->bindValue(':feed', $feedId);
|
||||
$existsQuery->bindValue(':guid', $item->guid);
|
||||
if ($exists = $existsQuery->execute()) {
|
||||
if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) {
|
||||
if ( $existing['published_on'] != $item->publishedOn
|
||||
|| ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
|
||||
if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
|
||||
public static function updateItems(int $feedId, Feed $feed, DateTimeInterface $lastChecked, SQLite3 $db): array {
|
||||
$results =
|
||||
array_map(function ($item) use ($db, $feedId) {
|
||||
$existsQuery = $db->prepare(
|
||||
'SELECT id, published_on, updated_on FROM item WHERE feed_id = :feed AND item_guid = :guid');
|
||||
$existsQuery->bindValue(':feed', $feedId);
|
||||
$existsQuery->bindValue(':guid', $item->guid);
|
||||
if ($exists = $existsQuery->execute()) {
|
||||
if ($existing = $exists->fetchArray(SQLITE3_ASSOC)) {
|
||||
if ( $existing['published_on'] != $item->publishedOn
|
||||
|| ($existing['updated_on'] ?? '') != ($item->updatedOn ?? '')) {
|
||||
if (!self::updateItem($existing['id'], $item, $db)) return Data::error($db);
|
||||
}
|
||||
} else {
|
||||
if (!self::addItem($feedId, $item, $db)) return Data::error($db);
|
||||
}
|
||||
} else {
|
||||
if (!self::addItem($feedId, $item, $db)) return Data::error($db);
|
||||
return Data::error($db);
|
||||
}
|
||||
} else {
|
||||
return Data::error($db);
|
||||
}
|
||||
}
|
||||
return ['ok', true];
|
||||
return ['ok' => true];
|
||||
}, array_filter($feed->items,
|
||||
fn($it) => date_create_immutable($it->updatedOn ?? $it->publishedOn) >= $lastChecked));
|
||||
$errors = array_map(fn($it) => $it['error'], array_filter($results, fn($it) => array_key_exists('error', $it)));
|
||||
return sizeof($errors) > 0 ? ['error' => implode("\n", $errors)] : ['ok' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -354,7 +352,6 @@ class Feed {
|
||||
* @return array|string[]|true[] ['ok' => true] if purging was successful, ['error' => message] if not
|
||||
*/
|
||||
private static function purgeItems(int $feedId, SQLite3 $db): array {
|
||||
|
||||
if (!array_search(PURGE_TYPE, [self::PURGE_READ, self::PURGE_BY_DAYS, self::PURGE_BY_COUNT])) {
|
||||
return ['error' => 'Unrecognized purge type ' . PURGE_TYPE];
|
||||
}
|
||||
@ -365,8 +362,7 @@ class Feed {
|
||||
self::PURGE_BY_DAYS => 'AND date(coalesce(updated_on, published_on)) < date(:oldest)',
|
||||
self::PURGE_BY_COUNT => 'AND id IN (SELECT id FROM item WHERE feed_id = :feed
|
||||
ORDER BY date(coalesce(updated_on, published_on)) DESC
|
||||
LIMIT -1 OFFSET :keep)',
|
||||
default => 'AND 1 = 0'
|
||||
LIMIT -1 OFFSET :keep)'
|
||||
};
|
||||
|
||||
$purge = $db->prepare("DELETE FROM item WHERE feed_id = :feed AND is_bookmarked = 0 $sql");
|
||||
@ -391,12 +387,18 @@ class Feed {
|
||||
* @return array|string[]|true[] ['ok' => true] if successful, ['error' => message] if not
|
||||
*/
|
||||
public static function refreshFeed(int $feedId, string $url, SQLite3 $db): array {
|
||||
$feedRetrieval = self::retrieveFeed($url);
|
||||
if (array_key_exists('error', $feedRetrieval)) return $feedRetrieval;
|
||||
$feed = $feedRetrieval['ok'];
|
||||
|
||||
$feedExtract = self::retrieveFeed($url);
|
||||
if (array_key_exists('error', $feedExtract)) return $feedExtract;
|
||||
$lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id');
|
||||
$lastCheckedQuery->bindValue(':id', $feedId);
|
||||
if (!($lastCheckedResult = $lastCheckedQuery->execute())) return Data::error($db);
|
||||
if (!($lastChecked = date_create_immutable($lastCheckedResult->fetchArray(SQLITE3_NUM)[0] ?? WWW_EPOCH))) {
|
||||
return ['error' => 'Could not derive date last checked for feed'];
|
||||
}
|
||||
|
||||
$feed = $feedExtract['ok'];
|
||||
$itemUpdate = self::updateItems($feedId, $feed, $db);
|
||||
$itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db);
|
||||
if (array_key_exists('error', $itemUpdate)) return $itemUpdate;
|
||||
|
||||
$urlUpdate = $url == $feed->url ? '' : ', url = :url';
|
||||
@ -415,7 +417,7 @@ class Feed {
|
||||
if ($urlUpdate != '') $feedUpdate->bindValue(':url', $feed->url);
|
||||
if (!$feedUpdate->execute()) return Data::error($db);
|
||||
|
||||
return self::purgeItems($feedId, $db);
|
||||
return PURGE_TYPE == self::PURGE_NONE ? ['ok' => true] : self::purgeItems($feedId, $db);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -451,7 +453,7 @@ class Feed {
|
||||
if (!$query->execute()) return Data::error($db);
|
||||
|
||||
$feedId = $db->lastInsertRowID();
|
||||
$result = self::updateItems($feedId, $feed, $db);
|
||||
$result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db);
|
||||
if (array_key_exists('error', $result)) return $result;
|
||||
|
||||
return ['ok' => $feedId];
|
||||
@ -497,7 +499,8 @@ class Feed {
|
||||
* Refresh all feeds
|
||||
*
|
||||
* @param SQLite3 $db The database connection to use for refreshing feeds
|
||||
* @return array|true[] ['ok => true] if successful, ['error' => message] if not (may have multiple error lines)
|
||||
* @return array|true[]|string[] ['ok' => true] if successful,
|
||||
* ['error' => message] if not (may have multiple error lines)
|
||||
*/
|
||||
public static function refreshAll(SQLite3 $db): array {
|
||||
$feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]);
|
||||
|
1
src/public/assets/htmx.min.js
vendored
Normal file
1
src/public/assets/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -43,6 +43,38 @@ header {
|
||||
main {
|
||||
padding: 0 .5rem;
|
||||
|
||||
.refresh, .loading {
|
||||
font-style: italic;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.htmx-request .refresh {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .loading {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.user_messages {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.user_message {
|
||||
margin: .25rem auto;
|
||||
border: solid 1px navy;
|
||||
border-radius: .5rem;
|
||||
background-color: rgba(255, 255, 255, .75);
|
||||
padding: .25rem;
|
||||
}
|
||||
|
||||
.user_messages + h1 {
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
.item_heading {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -78,7 +110,7 @@ input[type=url],
|
||||
input[type=text],
|
||||
input[type=email],
|
||||
input[type=password] {
|
||||
width: 50%;
|
||||
width: 40%;
|
||||
font-size: 1rem;
|
||||
padding: .25rem;
|
||||
border-radius: .25rem;
|
||||
|
@ -32,13 +32,17 @@ $query->bindValue(':userId', $_SESSION[Key::USER_ID]);
|
||||
$result = $query->execute();
|
||||
$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false;
|
||||
|
||||
page_head('Welcome'); ?>
|
||||
<h1>Your Unread Items <a href=/?refresh><small><small><em>(Refresh All Feeds)</em></small></small></a></h1>
|
||||
page_head('Your Unread Items'); ?>
|
||||
<h1>
|
||||
Your Unread Items
|
||||
<a class=refresh href=/?refresh hx-get=/?refresh hx-indicator="closest h1">(Refresh All Feeds)</a>
|
||||
<span class=loading>Refreshing…</span>
|
||||
</h1>
|
||||
<article><?php
|
||||
if ($item) {
|
||||
while ($item) { ?>
|
||||
<p><a href=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a><br>
|
||||
<?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
|
||||
<p><a href=/item?id=<?=$item['id']?> hx-get=/item?id=<?=$item['id']?>><?=strip_tags($item['item_title'])?></a>
|
||||
<br><?=htmlentities($item['feed_title'])?><br><small><em><?=date_time($item['as_of'])?></em></small><?php
|
||||
$item = $result->fetchArray(SQLITE3_ASSOC);
|
||||
}
|
||||
} else { ?>
|
||||
|
@ -30,6 +30,26 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
|
||||
frc_redirect('/');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] == 'DELETE') {
|
||||
$deleteQuery = $db->prepare(<<<'SQL'
|
||||
DELETE FROM item
|
||||
WHERE id IN (
|
||||
SELECT item.id
|
||||
FROM item INNER JOIN feed ON feed.id = item.feed_id
|
||||
WHERE item.id = :id
|
||||
AND feed.user_id = :user)
|
||||
SQL);
|
||||
$deleteQuery->bindValue(':id', $_GET['id']);
|
||||
$deleteQuery->bindValue(':user', $_SESSION[Key::USER_ID]);
|
||||
if ($deleteQuery->execute()) {
|
||||
add_info('Item deleted');
|
||||
} else {
|
||||
add_error(Data::error($db)['error']);
|
||||
}
|
||||
$db->close();
|
||||
frc_redirect('/');
|
||||
}
|
||||
|
||||
$query = $db->prepare(<<<'SQL'
|
||||
SELECT item.title AS item_title, item.item_link, item.published_on, item.updated_on, item.content,
|
||||
feed.title AS feed_title
|
||||
@ -61,10 +81,11 @@ page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>
|
||||
</div>
|
||||
<article>
|
||||
<div class=item_content><?=str_replace('<a ', '<a target=_blank rel=noopener ', $item['content'])?></div>
|
||||
<form class=action_buttons action=/item method=POST>
|
||||
<form class=action_buttons action=/item method=POST hx-post=/item>
|
||||
<input type=hidden name=id value=<?=$_GET['id']?>>
|
||||
<a href="/">Done</a>
|
||||
<a href=/ hx-get="/">Done</a>
|
||||
<button type=submit>Keep as New</button>
|
||||
<button type=button hx-delete=/item>Delete</button>
|
||||
</form>
|
||||
</article><?php
|
||||
page_foot();
|
||||
|
@ -18,7 +18,7 @@ $isSingle = SECURITY_MODEL == Security::SINGLE_USER_WITH_PASSWORD;
|
||||
page_head('Log On'); ?>
|
||||
<h1>Log On</h1>
|
||||
<article>
|
||||
<form method=POST action=/user/log-on hx-post=/user/log-on><?php
|
||||
<form method=POST action=/user/log-on><?php
|
||||
if (($_GET['returnTo'] ?? '') != '') { ?>
|
||||
<input type=hidden name=returnTo value="<?=$_GET['returnTo']?>"><?php
|
||||
}
|
||||
|
@ -16,8 +16,8 @@ session_start([
|
||||
* @param string $message The message itself
|
||||
*/
|
||||
function add_message(string $level, string $message): void {
|
||||
if (!array_key_exists(Key::USER_MSG, $_REQUEST)) $_REQUEST[Key::USER_MSG] = array();
|
||||
$_REQUEST[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
|
||||
if (!array_key_exists(Key::USER_MSG, $_SESSION)) $_SESSION[Key::USER_MSG] = array();
|
||||
$_SESSION[Key::USER_MSG][] = ['level' => $level, 'message' => $message];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,49 +38,75 @@ function add_info(string $message): void {
|
||||
add_message('INFO', $message);
|
||||
}
|
||||
|
||||
/** @var bool $is_htmx True if this request was initiated by htmx, false if not */
|
||||
$is_htmx = array_key_exists('HTTP_HX_REQUEST', $_SERVER)
|
||||
&& !array_key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER);
|
||||
|
||||
/**
|
||||
* Render the page title
|
||||
* @param string $title The title of the page being displayed
|
||||
*/
|
||||
function page_head(string $title): void {
|
||||
global $is_htmx;
|
||||
$version = match (true) {
|
||||
str_ends_with(FRC_VERSION, '.0.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 4),
|
||||
str_ends_with(FRC_VERSION, '.0') => substr(FRC_VERSION, 0, strlen(FRC_VERSION) - 2),
|
||||
default => FRC_VERSION
|
||||
} ?>
|
||||
};
|
||||
//if ($is_htmx) header('HX-Push-Url: true');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||
<title><?=$title?> | Feed Reader Central</title>
|
||||
<link href=/assets/style.css rel=stylesheet>
|
||||
<title><?=$title?> | Feed Reader Central</title><?php
|
||||
if (!$is_htmx) { ?>
|
||||
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||
<link href=/assets/style.css rel=stylesheet><?php
|
||||
} ?>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class=title href="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
|
||||
<div><?php
|
||||
if (array_key_exists(Key::USER_ID, $_SESSION)) {
|
||||
echo '<a href=/feed?id=new>Add Feed</a> | <a href=/docs/>Docs</a> | <a href=/user/log-off>Log Off</a>';
|
||||
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) echo " | {$_SESSION[Key::USER_EMAIL]}";
|
||||
} else {
|
||||
echo '<a href=/user/log-on>Log On</a> | <a href=/docs/>Docs</a>';
|
||||
} ?>
|
||||
</div>
|
||||
</header>
|
||||
<main hx-target=this><?php
|
||||
foreach ($_REQUEST[Key::USER_MSG] ?? [] as $msg) { ?>
|
||||
<div>
|
||||
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
|
||||
<?=$msg['message']?>
|
||||
</div><?php
|
||||
<body><?php
|
||||
if (!$is_htmx) { ?>
|
||||
<header hx-target=#main hx-push-url=true>
|
||||
<div><a class=title href=/ hx-get="/">Feed Reader Central</a><span class=version>v<?=$version?></span></div>
|
||||
<div><?php
|
||||
if (array_key_exists(Key::USER_ID, $_SESSION)) { ?>
|
||||
<a href=/feed?id=new hx-get=/feed?id=new>Add Feed</a> |
|
||||
<a href=/docs/ hx-get=/docs/>Docs</a> |
|
||||
<a href=/user/log-off hx-get=/user/log-off>Log Off</a><?php
|
||||
if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { ?>
|
||||
| <?=$_SESSION[Key::USER_EMAIL]?><?php
|
||||
}
|
||||
} else { ?>
|
||||
<a href=/user/log-on hx-get=/user/log-on>Log On</a> | <a href=/docs/ hx-get=/docs/>Docs</a><?php
|
||||
} ?>
|
||||
</div>
|
||||
</header>
|
||||
<main id=main hx-target=this hx-push-url=true hx-swap="innerHTML show:window:top"><?php
|
||||
}
|
||||
if (sizeof($messages = $_SESSION[Key::USER_MSG] ?? []) > 0) { ?>
|
||||
<div class=user_messages><?php
|
||||
array_walk($messages, function ($msg) { ?>
|
||||
<div class=user_message>
|
||||
<?=$msg['level'] == 'INFO' ? '' : "<strong>{$msg['level']}</strong><br>"?>
|
||||
<?=$msg['message']?>
|
||||
</div><?php
|
||||
}); ?>
|
||||
</div><?php
|
||||
$_SESSION[Key::USER_MSG] = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the end of the page
|
||||
*/
|
||||
function page_foot(): void {
|
||||
echo '</main></body></html>';
|
||||
global $is_htmx; ?>
|
||||
</main><?php
|
||||
if (!$is_htmx) { ?>
|
||||
<script src=/assets/htmx.min.js></script><?php
|
||||
} ?>
|
||||
</body>
|
||||
</html><?php
|
||||
session_commit();
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ const DATE_TIME_FORMAT = 'F j, Y \a\t g:ia';
|
||||
|
||||
/**
|
||||
* How should item purging be done? (Purging never applies to bookmarked items.) Options are:
|
||||
* - Feed::PURGE_NONE - Do not purge items
|
||||
* - Feed::PURGE_READ - Purge all read items whenever purging is run (will not purge unread items)
|
||||
* - Feed::PURGE_BY_DAYS - Purge read and unread items older than a number of days (PURGE_NUMBER below)
|
||||
* - Feed::PURGE_BY_COUNT - Purge read and unread items beyond the number to keep (PURGE_NUMBER below)
|
||||
|
Loading…
Reference in New Issue
Block a user