diff --git a/src/app-config.php b/src/app-config.php index bc29c5a..76994d9 100644 --- a/src/app-config.php +++ b/src/app-config.php @@ -1,7 +1,7 @@ "", + 1 => strtoupper($value), + default => strtoupper(substr($value, 0, 1)) . substr($value, 1), + }; +} diff --git a/src/cli-start.php b/src/cli-start.php index 5a61045..b6dda7e 100644 --- a/src/cli-start.php +++ b/src/cli-start.php @@ -28,17 +28,3 @@ function cli_title(string $title): void { printfn(' | %s | %s |', $title, $appTitle); printfn($dashes . PHP_EOL); } - -/** - * Capitalize the first letter of the given string - * - * @param string $value The string to be capitalized - * @return string The given string with the first letter capitalized - */ -function init_cap(string $value): string { - return match (strlen($value)) { - 0 => "", - 1 => strtoupper($value), - default => strtoupper(substr($value, 0, 1)) . substr($value, 1), - }; -} diff --git a/src/lib/Data.php b/src/lib/Data.php index 0d5328f..4bc8ac8 100644 --- a/src/lib/Data.php +++ b/src/lib/Data.php @@ -14,6 +14,31 @@ class Data { return $db; } + /** + * Create the search index and synchronization triggers for the item table + * + * @param SQLite3 $db The database connection on which these will be created + */ + public static function createSearchIndex(SQLite3 $db): void { + $db->exec("CREATE VIRTUAL TABLE item_search USING fts5(content, content='item', content_rowid='id')"); + $db->exec(<<<'SQL' + CREATE TRIGGER item_ai AFTER INSERT ON item BEGIN + INSERT INTO item_search (rowid, content) VALUES (new.id, new.content); + END; + SQL); + $db->exec(<<<'SQL' + CREATE TRIGGER item_au AFTER UPDATE ON item BEGIN + INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content); + INSERT INTO item_search (rowid, content) VALUES (new.id, new.content); + END; + SQL); + $db->exec(<<<'SQL' + CREATE TRIGGER item_ad AFTER DELETE ON item BEGIN + INSERT INTO item_search (item_search, rowid, content) VALUES ('delete', old.id, old.content); + END; + SQL); + } + /** * Make sure the expected tables exist */ @@ -23,17 +48,16 @@ class Data { $tableQuery = $db->query("SELECT name FROM sqlite_master WHERE type = 'table'"); while ($table = $tableQuery->fetchArray(SQLITE3_NUM)) $tables[] = $table[0]; if (!in_array('frc_user', $tables)) { - $query = <<<'SQL' + $db->exec(<<<'SQL' CREATE TABLE frc_user ( id INTEGER NOT NULL PRIMARY KEY, email TEXT NOT NULL, password TEXT NOT NULL) - SQL; - $db->exec($query); + SQL); $db->exec('CREATE INDEX idx_user_email ON frc_user (email)'); } if (!in_array('feed', $tables)) { - $query = <<<'SQL' + $db->exec(<<<'SQL' CREATE TABLE feed ( id INTEGER NOT NULL PRIMARY KEY, user_id INTEGER NOT NULL, @@ -42,11 +66,10 @@ class Data { updated_on TEXT, checked_on TEXT, FOREIGN KEY (user_id) REFERENCES frc_user (id)) - SQL; - $db->exec($query); + SQL); } if (!in_array('item', $tables)) { - $query = <<<'SQL' + $db->exec(<<<'SQL' CREATE TABLE item ( id INTEGER NOT NULL PRIMARY KEY, feed_id INTEGER NOT NULL, @@ -59,8 +82,8 @@ class Data { is_read BOOLEAN NOT NULL DEFAULT 0, is_bookmarked BOOLEAN NOT NULL DEFAULT 0, FOREIGN KEY (feed_id) REFERENCES feed (id)) - SQL; - $db->exec($query); + SQL); + self::createSearchIndex($db); } $db->close(); } diff --git a/src/lib/Feed.php b/src/lib/Feed.php index 7dd5fca..8e37465 100644 --- a/src/lib/Feed.php +++ b/src/lib/Feed.php @@ -239,19 +239,19 @@ class Feed { $start = strtolower(strlen($doc['content']) >= 9 ? substr($doc['content'], 0, 9) : $doc['content']); if ($start == ' $derivedURL['error']]; + if (key_exists('error', $derivedURL)) return ['error' => $derivedURL['error']]; $feedURL = $derivedURL['ok']; if (!str_starts_with($feedURL, 'http')) { // Relative URL; feed should be retrieved in the context of the original URL $original = parse_url($url); - $port = array_key_exists('port', $original) ? ":{$original['port']}" : ''; + $port = key_exists('port', $original) ? ":{$original['port']}" : ''; $feedURL = $original['scheme'] . '://' . $original['host'] . $port . $feedURL; } $doc = self::retrieveDocument($feedURL); } $parsed = self::parseFeed($doc['content']); - if (array_key_exists('error', $parsed)) return ['error' => $parsed['error']]; + if (key_exists('error', $parsed)) return ['error' => $parsed['error']]; $extract = $parsed['ok']->getElementsByTagNameNS(self::ATOM_NS, 'feed')->length > 0 ? self::fromAtom(...) : self::fromRSS(...); @@ -388,7 +388,7 @@ class Feed { */ public static function refreshFeed(int $feedId, string $url, SQLite3 $db): array { $feedRetrieval = self::retrieveFeed($url); - if (array_key_exists('error', $feedRetrieval)) return $feedRetrieval; + if (key_exists('error', $feedRetrieval)) return $feedRetrieval; $feed = $feedRetrieval['ok']; $lastCheckedQuery = $db->prepare('SELECT checked_on FROM feed where id = :id'); @@ -399,7 +399,7 @@ class Feed { } $itemUpdate = self::updateItems($feedId, $feed, $lastChecked, $db); - if (array_key_exists('error', $itemUpdate)) return $itemUpdate; + if (key_exists('error', $itemUpdate)) return $itemUpdate; $urlUpdate = $url == $feed->url ? '' : ', url = :url'; $feedUpdate = $db->prepare(<<lastInsertRowID(); $result = self::updateItems($feedId, $feed, date_create_immutable(WWW_EPOCH), $db); - if (array_key_exists('error', $result)) return $result; + if (key_exists('error', $result)) return $result; return ['ok' => $feedId]; } @@ -504,12 +504,12 @@ class Feed { */ public static function refreshAll(SQLite3 $db): array { $feeds = self::retrieveAll($db, $_SESSION[Key::USER_ID]); - if (array_key_exists('error', $feeds)) return $feeds; + if (key_exists('error', $feeds)) return $feeds; $errors = []; array_walk($feeds, function ($feed) use ($db, &$errors) { $result = self::refreshFeed($feed['id'], $feed['url'], $db); - if (array_key_exists('error', $result)) $errors[] = $result['error']; + if (key_exists('error', $result)) $errors[] = $result['error']; }); return sizeof($errors) == 0 ? ['ok' => true] : ['error' => implode("\n", $errors)]; diff --git a/src/lib/ItemList.php b/src/lib/ItemList.php new file mode 100644 index 0000000..2f9866b --- /dev/null +++ b/src/lib/ItemList.php @@ -0,0 +1,191 @@ +error != ''; + } + + /** @var bool Whether to render a link to the feed to which the item belongs */ + public bool $linkFeed = false; + + /** @var bool Whether to display the feed to which the item belongs */ + public bool $displayFeed = false; + + /** @var bool Whether to show read / bookmarked indicators on posts */ + public bool $showIndicators = false; + + /** + * Constructor + * + * @param SQLite3 $db The database connection (used to retrieve error information if the query fails) + * @param SQLite3Stmt $query The query to retrieve the items for this list + * @param string $itemType The type of item being displayed (unread, bookmark, etc.) + * @param string $returnURL The URL to which the item page should return once the item has been viewed + */ + private function __construct(SQLite3 $db, SQLite3Stmt $query, public string $itemType, public string $returnURL = '') { + $result = $query->execute(); + if (!$result) { + $this->error = Data::error($db)['error']; + } else { + $this->items = $result; + } + } + + /** + * Create an item list query + * + * @param SQLite3 $db The database connection to use to obtain items + * @param array $criteria One or more SQL WHERE conditions (will be combined with AND) + * @param array $parameters Parameters to be added to the query (key index 0, value index 1; optional) + * @return SQLite3Stmt The query, ready to be executed + */ + private static function makeQuery(SQLite3 $db, array $criteria, array $parameters = []): SQLite3Stmt { + $where = empty($criteria) ? '' : 'AND ' . implode(' AND ', $criteria); + $sql = <<prepare($sql); + $query->bindValue(':userId', $_SESSION[Key::USER_ID]); + foreach ($parameters as $param) $query->bindValue($param[0], $param[1]); + return $query; + } + + /** + * Create an item list with all the current user's bookmarked items + * + * @param SQLite3 $db The database connection to use to obtain items + * @return static An item list with all bookmarked items + */ + public static function allBookmarked(SQLite3 $db): static { + $list = new static($db, self::makeQuery($db, ['item.is_bookmarked = 1']), 'Bookmarked', '/?bookmarked'); + $list->linkFeed = true; + return $list; + } + + /** + * Create an item list with all the current user's unread items + * + * @param SQLite3 $db The database connection to use to obtain items + * @return static An item list with all unread items + */ + public static function allUnread(SQLite3 $db): static { + $list = new static($db, self::makeQuery($db, ['item.is_read = 0']), 'Unread'); + $list->linkFeed = true; + return $list; + } + + /** + * Create an item list with all items for the given feed + * + * @param int $feedId The ID of the feed for which items should be retrieved + * @param SQLite3 $db The database connection to use to obtain items + * @return static An item list with all items for the given feed + */ + public static function allForFeed(int $feedId, SQLite3 $db): static { + $list = new static($db, self::makeQuery($db, ['feed.id = :feed'], [[':feed', $feedId]]), '', + "/feed/items?id=$feedId"); + $list->showIndicators = true; + return $list; + } + + /** + * Create an item list with unread items for the given feed + * + * @param int $feedId The ID of the feed for which items should be retrieved + * @param SQLite3 $db The database connection to use to obtain items + * @return static An item list with unread items for the given feed + */ + public static function unreadForFeed(int $feedId, SQLite3 $db): static { + return new static($db, self::makeQuery($db, ['feed.id = :feed', 'item.is_read = 0'], [[':feed', $feedId]]), + 'Unread', "/feed/items?id=$feedId&unread"); + } + + /** + * Create an item list with bookmarked items for the given feed + * + * @param int $feedId The ID of the feed for which items should be retrieved + * @param SQLite3 $db The database connection to use to obtain items + * @return static An item list with bookmarked items for the given feed + */ + public static function bookmarkedForFeed(int $feedId, SQLite3 $db): static { + return new static($db, + self::makeQuery($db, ['feed.id = :feed', 'item.is_bookmarked = 1'], [[':feed', $feedId]]), 'Bookmarked', + "/feed/items?id=$feedId&bookmarked"); + } + + /** + * Create an item list with items matching given search terms + * + * @param string $search The item search terms / query + * @param bool $isBookmarked Whether to restrict the search to bookmarked items + * @param SQLite3 $db The database connection to use to obtain items + * @return static An item list match the given search terms + */ + public static function matchingSearch(string $search, bool $isBookmarked, SQLite3 $db): static { + $where = $isBookmarked ? ['item.is_bookmarked = 1'] : []; + $where[] = 'item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)'; + $list = new static($db, self::makeQuery($db, $where, [[':search', $search]]), + 'Matching' . ($isBookmarked ? ' Bookmarked' : ''), + "/search?search=$search&items=" . ($isBookmarked ? 'bookmarked' : 'all')); + $list->showIndicators = true; + $list->displayFeed = true; + return $list; + } + + /** + * Render this item list + */ + public function render(): void { + if ($this->isError()) { ?> +

Error retrieving list:
error?>items->fetchArray(SQLITE3_ASSOC); + $return = $this->returnURL == '' ? '' : '&from=' . urlencode($this->returnURL); + echo '

'; + if ($item) { + while ($item) { ?> +


+ showIndicators) { + if (!$item['is_read']) echo 'Unread   '; + if ($item['is_bookmarked']) echo 'Bookmarked   '; + } + echo '' . date_time($item['as_of']) . ''; + if ($this->linkFeed) { + echo ' • ' . + hx_get("/feed/items?id={$item['feed_id']}&" . strtolower($this->itemType), + htmlentities($item['feed_title'])); + } elseif ($this->displayFeed) { + echo ' • ' . htmlentities($item['feed_title']); + } ?> + items->fetchArray(SQLITE3_ASSOC); + } + } else { ?> +

There are no itemType)?> items'; + } +} diff --git a/src/lib/Security.php b/src/lib/Security.php index d8e865b..68e2bbb 100644 --- a/src/lib/Security.php +++ b/src/lib/Security.php @@ -131,7 +131,7 @@ class Security { * @param bool $redirectIfAnonymous Whether to redirect the request if there is no user logged on */ public static function verifyUser(SQLite3 $db, bool $redirectIfAnonymous = true): void { - if (array_key_exists(Key::USER_ID, $_SESSION)) return; + if (key_exists(Key::USER_ID, $_SESSION)) return; if (SECURITY_MODEL == self::SINGLE_USER) self::logOnSingleUser($db); diff --git a/src/public/assets/bookmark-add.png b/src/public/assets/bookmark-add.png new file mode 100644 index 0000000..c50719a Binary files /dev/null and b/src/public/assets/bookmark-add.png differ diff --git a/src/public/assets/bookmark-added.png b/src/public/assets/bookmark-added.png new file mode 100644 index 0000000..b7d1e05 Binary files /dev/null and b/src/public/assets/bookmark-added.png differ diff --git a/src/public/assets/style.css b/src/public/assets/style.css index d63410c..22e8d88 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -25,28 +25,28 @@ header { flex-flow: row wrap; justify-content: space-between; align-items: baseline; - - div { + div, nav { margin-bottom: .25rem; } - .title { font-size: 1.5rem; } - .version { font-size: .85rem; padding-left: .5rem; color: rgba(255, 255, 255, .75); } - a:link, a:visited { color: white; } + nav { + display: flex; + flex-flow: row wrap; + gap: 0 .4rem; + } } main { padding: 0 .5rem; - .refresh, .loading { font-style: italic; font-size: .9rem; @@ -54,14 +54,12 @@ main { .htmx-request .refresh { display: none; } - .loading { display: none; } .htmx-request .loading { display: inline; } - .user_messages { display: flex; flex-flow: column; @@ -74,15 +72,9 @@ main { background-color: rgba(255, 255, 255, .75); padding: .25rem; } - .user_messages + h1 { margin-top: .25rem; } - - .item_heading { - margin-bottom: 0; - } - .item_published { margin-bottom: 1rem; line-height: 1.2; @@ -91,37 +83,73 @@ main { article { max-width: 60rem; margin: auto; - .item_content { border: solid 1px navy; border-radius: .5rem; background-color: white; padding: .5rem; - img { max-width: 100%; object-fit: contain; - height: unset; - width: unset; } } - .meta { font-size: .9rem; } + &.docs { + line-height: 1.4rem; + } } -article.docs { - line-height: 1.4rem; +form { + display: flex; + flex-flow: row wrap; + justify-content: center; + gap: 0 2rem; + label { + font-size: .9rem; + font-weight: bold; + input, select { + display: block; + } + } + .break { + flex-basis: 100%; + height: 1rem; + width: 0; + } + input[type=url], + input[type=text], + input[type=email], + input[type=password], + select { + min-width: 12rem; + max-width: 100%; + font-size: 1rem; + padding: .25rem; + border-radius: .25rem; + background-color: white; + border: solid 2px navy; + } + select { + min-width: unset; + max-width: unset; + } } - -input[type=url], -input[type=text], -input[type=email], -input[type=password] { - width: 40%; - font-size: 1rem; - padding: .25rem; - border-radius: .25rem; +@media all and (min-width: 60rem) { + form { + input[type=url], + input[type=text], + input[type=email], + input[type=password] { + min-width: 25rem; + } + } +} +.action_buttons { + margin: 1rem 0; + display: flex; + flex-flow: row nowrap; + justify-content: space-evenly; } button, .action_buttons a:link, @@ -134,18 +162,11 @@ button, border-radius: .25rem; cursor: pointer; border: none; -} -button:hover, -.action_buttons a:hover { - text-decoration: none; - cursor: pointer; - background: linear-gradient(navy, #000032); -} -.action_buttons { - margin: 1rem 0; - display: flex; - flex-flow: row nowrap; - justify-content: space-evenly; + &:hover { + text-decoration: none; + cursor: pointer; + background: linear-gradient(navy, #000032); + } } code { font-size: .9rem; @@ -153,3 +174,28 @@ code { p.back-link { margin-top: -1rem; } +.item_heading { + margin-bottom: 0; + .bookmark { + padding: 0; + border: solid 1px black; + border-radius: .5rem; + &.add { + background-color: lightgray; + &:hover { + background: linear-gradient(lightgreen, gray); + } + } + &.remove { + background: linear-gradient(lightgreen, green); + &:hover { + background: linear-gradient(gray, lightgreen); + } + } + img { + max-width: 1.5rem; + max-height: 1.5rem; + padding: .5rem; + } + } +} diff --git a/src/public/bookmark.php b/src/public/bookmark.php new file mode 100644 index 0000000..4c16617 --- /dev/null +++ b/src/public/bookmark.php @@ -0,0 +1,49 @@ +prepare( + 'SELECT item.id FROM item INNER JOIN feed ON feed.id = item.feed_id WHERE item.id = :id AND feed.user_id = :user'); +$existsQuery->bindValue(':id', $id); +$existsQuery->bindValue(':user', $_SESSION[Key::USER_ID]); +$existsResult = $existsQuery->execute(); +$exists = $existsResult ? $existsResult->fetchArray(SQLITE3_ASSOC) : false; + +if (!$exists) not_found(); + +if (key_exists('action', $_GET)) { + if ($_GET['action'] == 'add') { + $flag = 1; + } elseif ($_GET['action'] == 'remove') { + $flag = 0; + } + if (isset($flag)) { + $update = $db->prepare('UPDATE item SET is_bookmarked = :flag WHERE id = :id'); + $update->bindValue(':id', $id); + $update->bindValue(':flag', $flag); + if (!$update->execute()) die(Data::error($db)['error']); + } +} + +$bookQuery = $db->prepare('SELECT id, is_bookmarked FROM item WHERE id = :id'); +$bookQuery->bindValue(':id', $id); +$bookResult = $bookQuery->execute(); +$bookmark = $bookResult ? $bookResult->fetchArray(SQLITE3_ASSOC) : ['id' => $id, 'is_bookmarked' => 0]; + +$action = $bookmark['is_bookmarked'] ? 'remove' : 'add'; +$icon = $bookmark['is_bookmarked'] ? 'added' : 'add'; ?> + "Feed {$_POST['id']} not found"]; } - if (array_key_exists('ok', $result)) { + if (key_exists('ok', $result)) { add_info('Feed saved successfully'); frc_redirect('/feeds'); } @@ -61,7 +61,8 @@ page_head($title); ?>
+ +

TYPE_UNREAD, - array_key_exists('bookmarked', $_GET) => TYPE_BOOKMARKED, - default => TYPE_ALL +$list = match (true) { + key_exists('unread', $_GET) => ItemList::unreadForFeed($feed['id'], $db), + key_exists('bookmarked', $_GET) => ItemList::bookmarkedForFeed($feed['id'], $db), + default => ItemList::allForFeed($feed['id'], $db) }; -$extraSQL = match ($type) { - TYPE_UNREAD => ' AND is_read = 0', - TYPE_BOOKMARKED => ' AND is_bookmarked = 1', - default => '' -}; -$itemQuery = $db->prepare(<<bindValue(':feed', $feed['id']); -if (!($itemResult = $itemQuery->execute())) add_error(Data::error($db)['error']); -$item = $itemResult ? $itemResult->fetchArray(SQLITE3_ASSOC) : false; - -$queryParam = match ($type) { - TYPE_UNREAD => '&unread', - TYPE_BOOKMARKED => '&bookmarked', - default => '' -}; -$thisURL = urlencode("/feed/items?id={$feed['id']}$queryParam"); - -$listType = match ($type) { - TYPE_UNREAD => 'Unread', - TYPE_BOOKMARKED => 'Bookmarked', - default => '' -}; - -page_head(($type != TYPE_ALL ? "$listType Items | " : '') . strip_tags($feed['title'])); -if ($type == TYPE_ALL) { ?> -

-

-
Items
-
-


- New   ' : ''?> - fetchArray(SQLITE3_ASSOC); - } - } else { ?> -

There are no items -

-itemType != '' ? "$list->itemType Items | " : '') . strip_tags($feed['title'])); +if ($list->itemType == '') { + echo '

' . htmlentities($feed['title']) . '

'; +} else { + echo '

' . htmlentities($feed['title']) . '

'; + echo "
$list->itemType Items
"; +} +$list->render(); page_foot(); $db->close(); diff --git a/src/public/index.php b/src/public/index.php index 3482e65..73307e7 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -2,7 +2,7 @@ /** * Home Page * - * Displays a list of unread feed items for the current user + * Displays a list of unread or bookmarked items for the current user */ include '../start.php'; @@ -10,46 +10,28 @@ include '../start.php'; $db = Data::getConnection(); Security::verifyUser($db); -if (array_key_exists('refresh', $_GET)) { +if (key_exists('refresh', $_GET)) { $refreshResult = Feed::refreshAll($db); - if (array_key_exists('ok', $refreshResult)) { + if (key_exists('ok', $refreshResult)) { add_info('All feeds refreshed successfully'); } else { add_error(nl2br($refreshResult['error'])); } } -$query = $db->prepare(<<<'SQL' - SELECT item.id, item.feed_id, item.title AS item_title, coalesce(item.updated_on, item.published_on) AS as_of, - feed.title AS feed_title - FROM item - INNER JOIN feed ON feed.id = item.feed_id - WHERE feed.user_id = :userId - AND item.is_read = 0 - ORDER BY coalesce(item.updated_on, item.published_on) DESC - SQL); -$query->bindValue(':userId', $_SESSION[Key::USER_ID]); -$result = $query->execute(); -$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; - -page_head('Your Unread Items'); ?> -

- Your Unread Items   - (Refresh All Feeds) - Refreshing… -

-
-


- • - fetchArray(SQLITE3_ASSOC); - } -} else { ?> -

There are no unread items

-
ItemList::allBookmarked($db), + default => ItemList::allUnread($db) +}; +$title = "Your $list->itemType Items"; +page_head($title); +echo "

$title"; +if ($list->itemType == 'Unread') { + echo '   ' . hx_get('/?refresh', '(Refresh All Feeds)', 'class=refresh hx-indicator="closest h1"') + . 'Refreshing…'; +} +echo '

'; +$list->render(); page_foot(); $db->close(); diff --git a/src/public/item.php b/src/public/item.php index 21659c9..3633f87 100644 --- a/src/public/item.php +++ b/src/public/item.php @@ -75,6 +75,8 @@ $updated = isset($item['updated_on']) ? date_time($item['updated_on']) : null; page_head(htmlentities("{$item['item_title']} | {$item['feed_title']}")); ?>

+

diff --git a/src/public/search.php b/src/public/search.php new file mode 100644 index 0000000..af5e6ab --- /dev/null +++ b/src/public/search.php @@ -0,0 +1,45 @@ + +

Item Search

+
+
+ + + + +
+
render(); + } ?> +
close(); diff --git a/src/public/user/log-off.php b/src/public/user/log-off.php index 5ba1f32..73fd811 100644 --- a/src/public/user/log-off.php +++ b/src/public/user/log-off.php @@ -5,6 +5,6 @@ include '../../start.php'; -if (array_key_exists(Key::USER_ID, $_SESSION)) session_destroy(); +if (key_exists(Key::USER_ID, $_SESSION)) session_destroy(); frc_redirect('/'); diff --git a/src/public/user/log-on.php b/src/public/user/log-on.php index 6632a7a..3133f0f 100644 --- a/src/public/user/log-on.php +++ b/src/public/user/log-on.php @@ -5,7 +5,7 @@ $db = Data::getConnection(); Security::verifyUser($db, redirectIfAnonymous: false); // Users already logged on have no need of this page -if (array_key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/'); +if (key_exists(Key::USER_ID, $_SESSION)) frc_redirect('/'); if ($_SERVER['REQUEST_METHOD'] == 'POST') { Security::logOnUser($_POST['email'] ?? '', $_POST['password'], $_POST['returnTo'] ?? null, $db); @@ -26,12 +26,13 @@ page_head('Log On'); ?>

+ + $level, 'message' => $message]; } @@ -39,8 +39,12 @@ function add_info(string $message): void { } /** @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); +$is_htmx = key_exists('HTTP_HX_REQUEST', $_SERVER) && !key_exists('HTTP_HX_HISTORY_RESTORE_REQUEST', $_SERVER); + +function nav_link(string $link, bool $isFirst = false) { + $sep = $isFirst ? '' : ' | '; + echo "$sep$link"; +} /** * Render the title bar for the page @@ -52,18 +56,36 @@ function title_bar(): void { default => FRC_VERSION }; ?>
-
v
- +
+
- + if (!$is_htmx) echo ''; ?> query("SELECT COUNT(*) FROM sqlite_master WHERE name = 'item_ai'"); + if ($hasIndex->fetchArray(SQLITE3_NUM)[0] == 0) { + printfn('Creating search index....'); + Data::createSearchIndex($db); + } + printfn('Rebuilding search index...'); + $db->exec("INSERT INTO item_search (item_search) VALUES ('rebuild')"); + printfn(PHP_EOL . 'Search index rebuilt'); + } finally { + $db->close(); + } +}