From f4273935cb6f30133dc6baa3cafe428ea263d408 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 23 May 2024 22:06:16 -0400 Subject: [PATCH 1/6] Add item bookmark buttons (#14) Implemented as a toggle button - Move init_cap func where web can see it - Bump version to alpha7 --- src/app-config.php | 16 ++++++++- src/cli-start.php | 14 -------- src/public/assets/bookmark-add.png | Bin 0 -> 1776 bytes src/public/assets/bookmark-added.png | Bin 0 -> 1984 bytes src/public/assets/style.css | 32 ++++++++++++++--- src/public/bookmark.php | 49 +++++++++++++++++++++++++++ src/public/feed/items.php | 1 + src/public/item.php | 2 ++ 8 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 src/public/assets/bookmark-add.png create mode 100644 src/public/assets/bookmark-added.png create mode 100644 src/public/bookmark.php 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/public/assets/bookmark-add.png b/src/public/assets/bookmark-add.png new file mode 100644 index 0000000000000000000000000000000000000000..c50719a12cb3eb0784492661f703dd4b09824c3e GIT binary patch literal 1776 zcma(}dpMhU7XE$7mxxP9gUTQTsqIp?luE0K4^d(sChoVYSc7O)(?@GXwEdV>6cvV+ zXRJgV7@Ta8O;U3PW;+W&UXdCvPj=e+NE^LTzE9tJCBVxaH2!M*8vyv*T#j2%+OhelFY~FMdfN;3l0sOW z-bTU=W#=c+Nd&fK+i)Qvi$gk7&?HsZQMRzQ8C{`4Bt19Sxow!sgC95CmAk9MN2Qr) zi7&sLS%2!)jg8mmgMve=|33Mz6BJ7P%*V>6_LP~RSChJn4u!I=mAQi=3FzS>m>t|@ z&pu}b)cMBH+u{2Ec^LGBlJP=2RE^QnYn0bJJ^VTmEbU*vHwiQG3K+E*t>|lRB(g<= z+5xe2cwD90km4{ISjF!~$k%Zzch7l)!7_5dzH9I19Ry?<_J+j#R7}>`X`4xXS62fv zt*eb1HwInz8Lv7LNJrdD2ipX$wNIh~waBzzB7B3lam~&#Vng3zkjHDM%DOox6AyS` z=1RZS;CQ}CqjNd6Z%b8|c10YF$4SY^gtGXmxuJZ zfJ`gJrXEH1GPe4??520zv6hfIvfM!a?5)CD8(94JY5`54R;ilY`&vV(1&tI>RQ-TU za=L>K37Yf6JAF>H=s6f~K*Ff)&$JkTkC1Epw?(^2S*ntz@#mj7Q>`^+%Rl};n^JQ`ZLSsl*$KgwW85)+V~*~z?vFxO zqtH>E90p#H3mxALN6AW_q|1fpl37Jd8<3%14H+9rz<9_RY2hY#qW0<6R0$AGCM*uc zxuGSxHJX%{)1%sVX-J|s9jM!X8pK;&YklNmg@h=dnQE!m6p2DuCE$~iZ=Ra~tw!sG zPY@mS8#?Sq)29<@`Z8r`ciTPmqF6ZW8Y#!}ASt+|Uo$!u0Nq*))52()BAD?#h(ih) zg(+L%;2Q*=sfD@5L4_IlRk`9a zbwDzSBx8RjCWV@`fpc*ov&FXPA1!Os6to(2ah`?gy8R%V2$?MkZWV%|(!F8it0v%;QRX~W5eUpi2 zVHGS8t@eD@$`7-bqyQlNH}mD6&V=81`|k@bo}{?-`h>`US1KcqkbhjXNlFFd&iGax zSw5)p;Y-^r+rc7)R*PG{!ch>Y3b#l`Jy=@00jobz<3y&idb6^l;p7@nyM36+ffn%1 z3!59hyDobBzq^#L%YNZs`G#dghtjQNb)X8nSpFwPM0Yz*s3H3+mAV^NrNuUP-$S3j zR~w%V**Fc>UIOOh{HwZ-4hf6PfQ_f!UW_JwAq9|zkPsHGc0XFRoHaeW6eN& z(4fSGDvDIK zQbwtGZ&40RXud4pk`sx?QZp zFBshYutadoTvYrqKlXn5)0Ydv1-E4Trw?+5bP>N^j`H2+^}&@*kD!>;y2KV(va$Y` zbyLA=1ZS}aBTQu literal 0 HcmV?d00001 diff --git a/src/public/assets/bookmark-added.png b/src/public/assets/bookmark-added.png new file mode 100644 index 0000000000000000000000000000000000000000..b7d1e05b99aee23b5be8e0c003418bc9c27ea219 GIT binary patch literal 1984 zcmb_dX;71C5`MoVJUb;PrYpP|A z*&=Pj;V*4E>GsNKU{X{#%l8=ZEWgG6s*G-TzAsv7rM(LKS@QCb^l&{c=T<|};|t)3 z3IrK~>oMGr1sw=)H>F=8jy(WSZp}E8-NkA!I5L~~^w}BA|D>$CZ$+Gd2*-J{huQ#^ zBgCrtF}7+k2z|mAExpiN6N=`bVJc`yHWv%xSOx*knsbr}aQyb`-nu6N;#9y_@d@TN z*u>qmXjW?tFg~^IZCap9_y}aj-5PKvYNnHW#U7$mnbmKYEbh} z#YheD=u!FK^Q?%?Cc60|jFu2Ot z?`0M6UR1inerH7ZoGVcKmA>i@ZGSjM*rQAF|Hm8wVY+nhHP$Q!HwWg(@Km;MbMusu zmRzEx(&&dR?aBsqyWV~dyCm@#ir(ghO)Axk3? zLcVVPKG2Y?S-iuLzuW^NyDa?;FtTwiJdzXlmKf%2p+F%>4^I^et?{2b*gvjb2w9_W zouTt3a4?g)k=*|s#zz!82%x|n`7{No+Nom!#;RRelqaMRy3GQ?E={<2t{D44@F0pK ztQgN91VZF#-Yf&r2!egSDOM2d_CA`BIz66`0^FQ9F!weT=zk2l#kxX|MR7l^b5nMr zn%HhV8HHEV*+fMBCn_=Whh5Y~4YH}kbhc*chMtk8L?7m1r$>7kR^U-*HTp%>7N*GT zNkLz@v{cOy^cW(lC=DrrZ3)HqV!NIA#Zd)106piUL^38leK6CQ zOf_Wkc(|!%ofUi|0%aIe(jnz7L`qa7Ye2uV=$xdnaeaayb3(( za-OB&81^7`(<^(?B^Y+tRmfHDKGomx2A5bfwk|y+e0N z@I`QZlF<|5hq*DFlFeR;M4ydGhiy+}b<;iw)+&^NV2!r`Cybu9F^%@rlI?6qg$7D_{u9ib21f;2#n zIX`(Fu+W_Ae~$h1jE3R>q_c={e+RrTF`wErP&m^93WCa}k*Ij^VIxRaOsK#7d;|Q8 z(q$kzf3@$MZVALhyoMLCM|Vs!!WYD~`fYw%ne#QdyEo(AEv;~n+i``iFJbg$A+{*l zcVvxI(u%Q*FQ&RO)?eNS80-pFHupPv?89gIMN*42Umc9M-NsvjVka)<)-pm5TB)j< z_gkr)U8qGTp3u;d)>*jZlXn6o8w{-R`e9>EU=S$wvs!1j-3y*rQ}OB z(F^sqI?4Dnm7rtHtYeh7d?8x&p`qz)$F_R~GxKXB>RI_C?Z$r9`go+^uDa}$$COar zlkL;j!CDAB8*KW%Eq}nSm)lt+WlQ1l~)njlNZrc!F3{f zq72!68^>HmuU}K+YvS4#sX-N3@Q1AIYA!j!sC2sxk0vZj9Wi@-atz9D@e64Xeht;{ zoK5K3+#^fpB`erjyV93`{3H4{&OE}ftBmhtqQ}XK6NJrgQCb05Vk@pY_7nk@1UHbE q07Q<6@*%0icRrF&DF0u@v-O+&k?f>46&?9Y22T53>3j!f(!T%=ZFDRE literal 0 HcmV?d00001 diff --git a/src/public/assets/style.css b/src/public/assets/style.css index d63410c..da1fefa 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -79,10 +79,6 @@ main { margin-top: .25rem; } - .item_heading { - margin-bottom: 0; - } - .item_published { margin-bottom: 1rem; line-height: 1.2; @@ -153,3 +149,31 @@ 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'; ?> + while ($item) { ?>


New   ' : ''?> + Bookmarked   ' : ''?> fetchArray(SQLITE3_ASSOC); } 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']}")); ?>

+

From 2495136fc993f65e7809c89b648f172ff37bc521 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 23 May 2024 23:02:07 -0400 Subject: [PATCH 2/6] First cut of read bookmarked item page (#14) - Added Bookmarked link to header if items exist --- src/public/index.php | 29 +++++++++++++++++++++-------- src/start.php | 28 ++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/public/index.php b/src/public/index.php index 3482e65..ea5cca3 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -19,35 +19,48 @@ if (array_key_exists('refresh', $_GET)) { } } -$query = $db->prepare(<<<'SQL' +if (key_exists('bookmarked', $_GET)) { + $itemCriteria = 'item.is_bookmarked = 1'; + $returnURL = '&from=' . urlencode('/?bookmarked'); + $type = 'Bookmarked'; +} else { + $itemCriteria = 'item.is_read = 0'; + $returnURL = ''; + $type = 'Unread'; +} +$title = "Your $type Items"; + +$query = $db->prepare(<<bindValue(':userId', $_SESSION[Key::USER_ID]); $result = $query->execute(); $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; -page_head('Your Unread Items'); ?> +page_head($title); ?>

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

-


+


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

There are no unread items

There are no items
FRC_VERSION }; ?>
-
v
+
- | | - Log Off - | prepare(<<<'SQL' + SELECT EXISTS( + SELECT 1 + FROM item INNER JOIN feed ON item.feed_id = feed.id + WHERE feed.user_id = :id AND item.is_bookmarked = 1) + SQL); + $bookQuery->bindValue(':id', $_SESSION[Key::USER_ID]); + $bookResult = $bookQuery->execute(); + $hasBookmarks = $bookResult ? $bookResult->fetchArray(SQLITE3_NUM)[0] : false; ?> + | + + | + Log Off + | close(); } } else { ?> | Date: Sat, 25 May 2024 12:31:48 -0400 Subject: [PATCH 3/6] Link name to bookmarked page (#14) - Tweak CSS --- src/public/assets/style.css | 6 ++---- src/public/index.php | 11 ++++++----- src/start.php | 23 ++++++++++------------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/public/assets/style.css b/src/public/assets/style.css index da1fefa..f12b9bd 100644 --- a/src/public/assets/style.css +++ b/src/public/assets/style.css @@ -97,8 +97,6 @@ article { img { max-width: 100%; object-fit: contain; - height: unset; - width: unset; } } @@ -159,13 +157,13 @@ p.back-link { &.add { background-color: lightgray; - :hover { + &:hover { background: linear-gradient(lightgreen, gray); } } &.remove { background: linear-gradient(lightgreen, green); - :hover { + &:hover { background: linear-gradient(gray, lightgreen); } } diff --git a/src/public/index.php b/src/public/index.php index ea5cca3..a1df915 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'; @@ -46,17 +46,18 @@ $item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; page_head($title); ?>

  - (Refresh All Feeds) + if ($type == 'Unread'): ?>   + Refreshing… + endif; ?>


• - + fetchArray(SQLITE3_ASSOC); } } else { ?> diff --git a/src/start.php b/src/start.php index 9010241..f0fa0f0 100644 --- a/src/start.php +++ b/src/start.php @@ -64,20 +64,19 @@ function title_bar(): void { WHERE feed.user_id = :id AND item.is_bookmarked = 1) SQL); $bookQuery->bindValue(':id', $_SESSION[Key::USER_ID]); - $bookResult = $bookQuery->execute(); - $hasBookmarks = $bookResult ? $bookResult->fetchArray(SQLITE3_NUM)[0] : false; ?> - | - - | - Log Off - | execute(); + $hasBookmarks = $bookResult && $bookResult->fetchArray(SQLITE3_NUM)[0]; + echo hx_get('/feeds', 'Feeds') . ' | '; + if ($hasBookmarks) echo hx_get('/?bookmarked', 'Bookmarked') . ' | '; + echo hx_get('/docs/', 'Docs') . ' | Log Off'; + if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { + echo " | {$_SESSION[Key::USER_EMAIL]}"; } } finally { $db->close(); } - } else { ?> - |

@@ -122,9 +121,7 @@ function page_head(string $title): void { function page_foot(): void { global $is_htmx; ?> - + if (!$is_htmx) echo ''; ?> Date: Sat, 25 May 2024 23:03:39 -0400 Subject: [PATCH 4/6] Move list retrieve/render to class (#15) - array_key_exists -> key_exists --- src/lib/Feed.php | 18 ++-- src/lib/ItemList.php | 172 ++++++++++++++++++++++++++++++++++++ src/lib/Security.php | 2 +- src/public/feed/index.php | 2 +- src/public/feed/items.php | 74 +++------------- src/public/index.php | 62 ++++--------- src/public/user/log-off.php | 2 +- src/public/user/log-on.php | 2 +- src/start.php | 7 +- 9 files changed, 215 insertions(+), 126 deletions(-) create mode 100644 src/lib/ItemList.php 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..18f71b0 --- /dev/null +++ b/src/lib/ItemList.php @@ -0,0 +1,172 @@ +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"); + } + + /** + * 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/feed/index.php b/src/public/feed/index.php index 59a73e5..dd84544 100644 --- a/src/public/feed/index.php +++ b/src/public/feed/index.php @@ -35,7 +35,7 @@ if ($_SERVER['REQUEST_METHOD'] == 'POST') { $toEdit = Feed::retrieveById($_POST['id'], $db); $result = $toEdit ? Feed::update($toEdit, $_POST['url'], $db) : ['error' => "Feed {$_POST['id']} not found"]; } - if (array_key_exists('ok', $result)) { + if (key_exists('ok', $result)) { add_info('Feed saved successfully'); frc_redirect('/feeds'); } diff --git a/src/public/feed/items.php b/src/public/feed/items.php index 30ba98f..a92269f 100644 --- a/src/public/feed/items.php +++ b/src/public/feed/items.php @@ -12,69 +12,19 @@ Security::verifyUser($db); if (!($feed = Feed::retrieveById($_GET['id'], $db))) not_found(); -/** Display a list of unread items for this feed */ -const TYPE_UNREAD = 0; - -/** Display a list of bookmarked items for this feed */ -const TYPE_BOOKMARKED = 1; - -/** Display all items for this feed */ -const TYPE_ALL = 2; - -$type = match (true) { - array_key_exists('unread', $_GET) => 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   ' : ''?> - Bookmarked   ' : ''?> - 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 a1df915..73307e7 100644 --- a/src/public/index.php +++ b/src/public/index.php @@ -10,60 +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'])); } } -if (key_exists('bookmarked', $_GET)) { - $itemCriteria = 'item.is_bookmarked = 1'; - $returnURL = '&from=' . urlencode('/?bookmarked'); - $type = 'Bookmarked'; -} else { - $itemCriteria = 'item.is_read = 0'; - $returnURL = ''; - $type = 'Unread'; +$list = match (true) { + key_exists('bookmarked', $_GET) => 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…'; } -$title = "Your $type Items"; - -$query = $db->prepare(<<bindValue(':userId', $_SESSION[Key::USER_ID]); -$result = $query->execute(); -$item = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; - -page_head($title); ?> -

-   - - Refreshing… -

-
-


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

There are no items -

'; +$list->render(); page_foot(); $db->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..4a9c60d 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); diff --git a/src/start.php b/src/start.php index f0fa0f0..3b5a686 100644 --- a/src/start.php +++ b/src/start.php @@ -16,7 +16,7 @@ session_start([ * @param string $message The message itself */ function add_message(string $level, string $message): void { - if (!array_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]; } @@ -39,8 +39,7 @@ 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); /** * Render the title bar for the page @@ -54,7 +53,7 @@ function title_bar(): void {
prepare(<<<'SQL' From 9d59bfb1c6c53094cb7168169817eb9db51d8ec1 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 26 May 2024 14:18:20 -0400 Subject: [PATCH 5/6] Add search index (#15) - Add utility rebuild script - Add Search to header - Add shell of search page - Add search query support to ItemList --- src/lib/Data.php | 41 ++++++++++++++++++++++++++++-------- src/lib/ItemList.php | 16 ++++++++++++++ src/public/search.php | 23 ++++++++++++++++++++ src/start.php | 3 ++- src/util/search.php | 49 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 src/public/search.php create mode 100644 src/util/search.php 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/ItemList.php b/src/lib/ItemList.php index 18f71b0..4bb4c29 100644 --- a/src/lib/ItemList.php +++ b/src/lib/ItemList.php @@ -134,6 +134,22 @@ class ItemList { "/feed/items?id=$feedId&bookmarked"); } + /** + * Create an item list with items matching given search terms + * + * @param string $search The item search terms / query + * @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, SQLite3 $db): static { + $list = new static($db, + self::makeQuery($db, ['item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)'], + [[':search', $search]]), 'Matching', "/search?search=$search"); + $list->showIndicators = true; + $list->displayFeed = true; + return $list; + } + /** * Render this item list */ diff --git a/src/public/search.php b/src/public/search.php new file mode 100644 index 0000000..04170c6 --- /dev/null +++ b/src/public/search.php @@ -0,0 +1,23 @@ + +

Item Search

+// TODO: search form render(); +page_foot(); +$db->close(); diff --git a/src/start.php b/src/start.php index 3b5a686..0d136fb 100644 --- a/src/start.php +++ b/src/start.php @@ -67,7 +67,8 @@ function title_bar(): void { $hasBookmarks = $bookResult && $bookResult->fetchArray(SQLITE3_NUM)[0]; echo hx_get('/feeds', 'Feeds') . ' | '; if ($hasBookmarks) echo hx_get('/?bookmarked', 'Bookmarked') . ' | '; - echo hx_get('/docs/', 'Docs') . ' | Log Off'; + echo hx_get('/search', 'Search') . ' | ' . hx_get('/docs/', 'Docs') + . ' | Log Off'; if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { echo " | {$_SESSION[Key::USER_EMAIL]}"; } diff --git a/src/util/search.php b/src/util/search.php new file mode 100644 index 0000000..b7430d8 --- /dev/null +++ b/src/util/search.php @@ -0,0 +1,49 @@ +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(); + } +} From 58dd7a4ffb316c0f73c5d323a0e324161d60fe3b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 26 May 2024 16:56:30 -0400 Subject: [PATCH 6/6] Add bookmark item search (#15) - Implement form styling throughout - Modify header links for narrower views - Clean up CSS --- src/lib/ItemList.php | 11 ++-- src/public/assets/style.css | 102 ++++++++++++++++++++++-------------- src/public/feed/index.php | 3 +- src/public/search.php | 30 +++++++++-- src/public/user/log-on.php | 5 +- src/start.php | 23 +++++--- 6 files changed, 116 insertions(+), 58 deletions(-) diff --git a/src/lib/ItemList.php b/src/lib/ItemList.php index 4bb4c29..2f9866b 100644 --- a/src/lib/ItemList.php +++ b/src/lib/ItemList.php @@ -138,13 +138,16 @@ class ItemList { * 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, SQLite3 $db): static { - $list = new static($db, - self::makeQuery($db, ['item.id IN (SELECT ROWID FROM item_search WHERE content MATCH :search)'], - [[':search', $search]]), 'Matching', "/search?search=$search"); + 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; diff --git a/src/public/assets/style.css b/src/public/assets/style.css index f12b9bd..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,11 +72,9 @@ main { background-color: rgba(255, 255, 255, .75); padding: .25rem; } - .user_messages + h1 { margin-top: .25rem; } - .item_published { margin-bottom: 1rem; line-height: 1.2; @@ -87,35 +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; } } - .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, @@ -128,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; @@ -149,12 +176,10 @@ p.back-link { } .item_heading { margin-bottom: 0; - .bookmark { padding: 0; border: solid 1px black; border-radius: .5rem; - &.add { background-color: lightgray; &:hover { @@ -167,7 +192,6 @@ p.back-link { background: linear-gradient(gray, lightgreen); } } - img { max-width: 1.5rem; max-height: 1.5rem; diff --git a/src/public/feed/index.php b/src/public/feed/index.php index dd84544..f9dba42 100644 --- a/src/public/feed/index.php +++ b/src/public/feed/index.php @@ -61,7 +61,8 @@ page_head($title); ?>
+ +

Item Search

-// TODO: search form render(); +
+
+ + + + +
+
render(); + } ?> +
close(); diff --git a/src/public/user/log-on.php b/src/public/user/log-on.php index 4a9c60d..3133f0f 100644 --- a/src/public/user/log-on.php +++ b/src/public/user/log-on.php @@ -26,12 +26,13 @@ page_head('Log On'); ?>

+ + $sep$link"; +} + /** * Render the title bar for the page */ @@ -52,7 +57,7 @@ function title_bar(): void { }; ?>
-
bindValue(':id', $_SESSION[Key::USER_ID]); $bookResult = $bookQuery->execute(); $hasBookmarks = $bookResult && $bookResult->fetchArray(SQLITE3_NUM)[0]; - echo hx_get('/feeds', 'Feeds') . ' | '; - if ($hasBookmarks) echo hx_get('/?bookmarked', 'Bookmarked') . ' | '; - echo hx_get('/search', 'Search') . ' | ' . hx_get('/docs/', 'Docs') - . ' | Log Off'; + nav_link(hx_get('/feeds', 'Feeds'), true); + if ($hasBookmarks) nav_link(hx_get('/?bookmarked', 'Bookmarked')); + nav_link(hx_get('/search', 'Search')); + nav_link(hx_get('/docs/', 'Docs')); + nav_link('Log Off'); if ($_SESSION[Key::USER_EMAIL] != Security::SINGLE_USER_EMAIL) { - echo " | {$_SESSION[Key::USER_EMAIL]}"; + nav_link($_SESSION[Key::USER_EMAIL]); } } finally { $db->close(); } } else { - echo hx_get('/user/log-on', 'Log On') . ' | ' . hx_get('/docs/', 'Docs'); + nav_link(hx_get('/user/log-on', 'Log On'), true); + nav_link(hx_get('/docs/', 'Docs')); } ?> -
+