From d4a8a8e016ac6db73718077bb2081a54937ceecd Mon Sep 17 00:00:00 2001 From: Jim Mason Date: Wed, 11 Dec 2024 15:30:21 +0000 Subject: [PATCH] Add hashtag hyperlinks and trending cloud (#492) --- api/Albums.php | 1 + controllers/Validate.php | 4 +- css/trending.css | 53 +++++++++++++++ css/zoostyle.css | 15 +++++ db/convert_v2_11_7_to_v3_0_0.sql | 10 +++ db/zkdbSchema.sql | 16 +++++ engine/ILibrary.php | 1 + engine/IReview.php | 3 +- engine/impl/LibraryImpl.php | 19 +++++- engine/impl/ReviewImpl.php | 61 +++++++++++++++-- js/search.library.js | 14 +++- js/trending.js | 80 +++++++++++++++++++++++ ui/Reviews.php | 21 ++++++ ui/Search.php | 5 +- ui/templates/default/album/info.html | 2 +- ui/templates/default/review.trending.html | 5 ++ ui/templates/default/search.library.html | 3 +- 17 files changed, 296 insertions(+), 17 deletions(-) create mode 100644 css/trending.css create mode 100644 js/trending.js create mode 100644 ui/templates/default/review.trending.html diff --git a/api/Albums.php b/api/Albums.php index 9dae9d5a..9104765c 100644 --- a/api/Albums.php +++ b/api/Albums.php @@ -66,6 +66,7 @@ class Albums implements RequestHandlerInterface { "track" => [ ILibrary::TRACK_NAME, null ], "label.id" => [ ILibrary::ALBUM_PUBKEY, null ], "reviews.airname.id" => [ ILibrary::ALBUM_AIRNAME, null ], + "reviews.hashtag" => [ ILibrary::ALBUM_HASHTAG, null ], "match(artist)" => [ -1, "artists" ], "match(artist,album)" => [ -1, "albums" ], "match(album,artist)" => [ -1, "albums" ], diff --git a/controllers/Validate.php b/controllers/Validate.php index 5bc6565a..3220e2ee 100644 --- a/controllers/Validate.php +++ b/controllers/Validate.php @@ -554,7 +554,7 @@ public function validateLibrary() { 'attributes' => [ 'airname' => $airname, 'date' => '2022-02-09', - 'review' => 'This is a review' + 'review' => 'This is a #review #test' ], 'relationships' => [ 'album' => [ @@ -575,7 +575,7 @@ public function validateLibrary() { } if($this->doTest("validate review", $success9)) { - $success10 = $this->searchAlbum($albumname2, "review", "review", "This is a review"); + $success10 = $this->searchAlbum($albumname2, "review", "review", "This is a #review #test"); $this->showSuccess($success10); } diff --git a/css/trending.css b/css/trending.css new file mode 100644 index 00000000..f75295bc --- /dev/null +++ b/css/trending.css @@ -0,0 +1,53 @@ +/* fonts */ + +div.jqcloud { + font-size: 120%; +} + +@media (max-width: 800px) { + div.jqcloud { + font-size: 100%; + } +} + +div.jqcloud a { + font-size: inherit; + text-decoration: none; +} + +div.jqcloud span.w10 { font-size: 300%; } +div.jqcloud span.w9 { font-size: 280%; } +div.jqcloud span.w8 { font-size: 260%; } +div.jqcloud span.w7 { font-size: 240%; } +div.jqcloud span.w6 { font-size: 220%; } +div.jqcloud span.w5 { font-size: 200%; } +div.jqcloud span.w4 { font-size: 180%; } +div.jqcloud span.w3 { font-size: 140%; } +div.jqcloud span.w2 { font-size: 120%; } +div.jqcloud span.w1 { font-size: 100%; } + +/* colors */ + +div.jqcloud { color: var(--theme-link-colour); } +div.jqcloud a { color: inherit; } + +div.jqcloud a:hover { filter: saturate(2); } +div.jqcloud span.w10 { filter: saturate(1.3); } +div.jqcloud span.w9 { filter: saturate(1.25); } +div.jqcloud span.w8 { filter: saturate(1.2); } +div.jqcloud span.w7 { filter: saturate(1.1); } +div.jqcloud span.w6 { filter: saturate(1.0); } +div.jqcloud span.w5 { filter: saturate(0.9); } +div.jqcloud span.w4 { filter: saturate(0.85); } +div.jqcloud span.w3 { filter: saturate(0.80); } +div.jqcloud span.w2 { filter: saturate(0.75); } +div.jqcloud span.w1 { filter: saturate(0.70); } + +/* layout */ + +div.jqcloud { + overflow: hidden; + position: relative; +} + +div.jqcloud span { padding: 4px; } diff --git a/css/zoostyle.css b/css/zoostyle.css index 0a26ccec..3ac6db17 100644 --- a/css/zoostyle.css +++ b/css/zoostyle.css @@ -784,6 +784,11 @@ th.sec { font-weight: bold; color: #cc0000; } +.quiet-notice { + font-size: 20px; + color: #ccc; +} + .input { } .sel:link { color: #ff0000; } .sel:visited { color: #ff0000; } @@ -1576,6 +1581,16 @@ a:hover.copy { padding-top: 15px; } +.album-hashtag-area a { + color: inherit; +} +.album-hashtag-area a:hover { + text-decoration: none; +} +.album-hashtag-area a:hover .album-hashtag { + filter: brightness(115%); +} + .album-hashtag { border-radius: 6px; border: 1px solid #666; diff --git a/db/convert_v2_11_7_to_v3_0_0.sql b/db/convert_v2_11_7_to_v3_0_0.sql index 99ff711d..c65fba3a 100644 --- a/db/convert_v2_11_7_to_v3_0_0.sql +++ b/db/convert_v2_11_7_to_v3_0_0.sql @@ -58,6 +58,16 @@ ALTER TABLE `reviews` ADD COLUMN `exportid` varchar(80) DEFAULT NULL; ALTER TABLE `tracknames` ADD COLUMN `duration` time DEFAULT NULL; ALTER TABLE `colltracknames` ADD COLUMN `duration` time DEFAULT NULL; +CREATE TABLE IF NOT EXISTS `reviews_hashtags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `tag` int(11) NOT NULL, + `user` varchar(8) NOT NULL, + `hashtag` varchar(190) NOT NULL, + PRIMARY KEY (`id`), + KEY `tu` (`tag`,`user`), + KEY `hashtag` (`hashtag`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 ; + SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT; SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS; SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION; diff --git a/db/zkdbSchema.sql b/db/zkdbSchema.sql index eebdb4e0..5d4382f3 100644 --- a/db/zkdbSchema.sql +++ b/db/zkdbSchema.sql @@ -335,6 +335,22 @@ CREATE TABLE IF NOT EXISTS `reviews` ( -- -------------------------------------------------------- +-- +-- Table structure for table `reviews_hashtags` +-- + +CREATE TABLE IF NOT EXISTS `reviews_hashtags` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `tag` int(11) NOT NULL, + `user` varchar(8) NOT NULL, + `hashtag` varchar(190) NOT NULL, + PRIMARY KEY (`id`), + KEY `tu` (`tag`,`user`), + KEY `hashtag` (`hashtag`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 ; + +-- -------------------------------------------------------- + -- -- Table structure for table `sessions` -- diff --git a/engine/ILibrary.php b/engine/ILibrary.php index 63097fca..2bc9d380 100644 --- a/engine/ILibrary.php +++ b/engine/ILibrary.php @@ -87,6 +87,7 @@ interface ILibrary { const TRACK_KEY = 9; const TRACK_NAME = 10; const ALBUM_LOCATION = 11; + const ALBUM_HASHTAG = 12; const OP_PREV_LINE = 0; const OP_NEXT_LINE = 1; diff --git a/engine/IReview.php b/engine/IReview.php index cb984ade..5ba86c0c 100644 --- a/engine/IReview.php +++ b/engine/IReview.php @@ -3,7 +3,7 @@ * Zookeeper Online * * @author Jim Mason - * @copyright Copyright (C) 1997-2023 Jim Mason + * @copyright Copyright (C) 1997-2024 Jim Mason * @link https://zookeeper.ibinx.com/ * @license GPL-3.0 * @@ -47,6 +47,7 @@ interface IReview { function getRecentReviews($user = "", $weeks = 0, $limit = 0, $loggedIn = 0); function getActiveReviewers($viewAll=0, $loggedIn=0); function getReviews($tag, $byName=1, $user = "", $loggedIn = 0, $byId = 0); + function getTrending(int $limit = 50); function insertReview($tag, $private, $airname, $review, $user); function updateReview($tag, $private, $airname, $review, $user); function deleteReview($tag, $user); diff --git a/engine/impl/LibraryImpl.php b/engine/impl/LibraryImpl.php index ebcbd608..22679dc5 100644 --- a/engine/impl/LibraryImpl.php +++ b/engine/impl/LibraryImpl.php @@ -137,6 +137,7 @@ private static function orderBy($sortBy) { $query = "ORDER BY name$desc, album$desc, artist$desc "; break; case "created": + case "added": $query = "ORDER BY a.created$desc, artist$desc "; break; case "date": @@ -306,11 +307,21 @@ public function searchPos($tableIndex, &$pos, $count, $search, $sortBy = 0) { $query .= self::orderBy($sortBy); $query .= "LIMIT ?, ?"; break; + case ILibrary::ALBUM_HASHTAG: + $query = "SELECT artist, album, category, medium, size, ". + "a.created, a.updated, a.pubkey, location, bin, a.tag, iscoll, p.name ". + "FROM reviews_hashtags r LEFT JOIN albumvol a ON a.tag = r.tag ". + "LEFT JOIN publist p ON p.pubkey = a.pubkey ". + "WHERE hashtag = ? GROUP BY r.tag "; + $query .= self::orderBy($sortBy); + $query .= "LIMIT ?, ?"; + $bindType = 3; + break; default: error_log("searchPos: unknown key '$tableIndex'"); return; } - + // Collation for utf8mb4 coalesces related characters, such as // 'a', 'a-umlaut', 'a-acute', and so on, for searching. However, // it does not coalese various punctuation, such as apostrophe @@ -386,11 +397,13 @@ public function searchPos($tableIndex, &$pos, $count, $search, $sortBy = 0) { else if($lim = strpos($query, " LIMIT")) $query = substr($query, 0, $lim); - // For UNION queries, we must count number of aggregate rows. + // For UNION queries and for queries which contain GROUP BY, + // we must count the number of aggregate rows. // // This will work also for simple queries, but in that // case, we just do a count, as it's a tad more efficient. - if(strpos($query, " UNION SELECT ")) { + if(strpos($query, " UNION SELECT ") || + strpos($query, " GROUP BY ")) { $query = "SELECT COUNT(*) FROM (" . $query . ") x"; } else { $from = strpos($query, "FROM"); diff --git a/engine/impl/ReviewImpl.php b/engine/impl/ReviewImpl.php index c2c0ac91..7bad1682 100644 --- a/engine/impl/ReviewImpl.php +++ b/engine/impl/ReviewImpl.php @@ -3,7 +3,7 @@ * Zookeeper Online * * @author Jim Mason - * @copyright Copyright (C) 1997-2023 Jim Mason + * @copyright Copyright (C) 1997-2024 Jim Mason * @link https://zookeeper.ibinx.com/ * @license GPL-3.0 * @@ -156,8 +156,44 @@ public function getReviews($tag, $byName=1, $user = "", $loggedIn = 0, $byId = 0 $stmt->bindValue(2, $user); return $stmt->executeAndFetchAll(\PDO::FETCH_BOTH); } + + public function getTrending(int $limit = 50) { + $query = "SELECT hashtag, count(*) freq FROM reviews_hashtags " . + "GROUP BY hashtag ORDER BY id DESC LIMIT ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $limit, \PDO::PARAM_INT); + return $stmt->executeAndFetchAll(); + } + + protected function syncHashtags(int $tag, string $user, ?string $review = null) { + $this->adviseLock($tag); + + $query = "DELETE FROM reviews_hashtags WHERE tag = ? AND user = ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $tag); + $stmt->bindValue(2, $user); + $stmt->execute(); + + if($review && preg_match_all('/#(\pL\w*)/u', $review, $matches)) { + $query = "INSERT INTO reviews_hashtags (tag, user, hashtag) VALUES (?, ?, ?)"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $tag); + $stmt->bindValue(2, $user); + $normalized = array_unique(array_map('strtolower', $matches[1])); + $hashtags = array_intersect_key($matches[1], $normalized); + foreach($hashtags as $hashtag) { + $stmt->bindValue(3, $hashtag); + $stmt->execute(); + } + } + + $this->adviseUnlock($tag); + } public function insertReview($tag, $private, $airname, $review, $user) { + // we must do this first, as caller depends on lastInsertId from INSERT + $this->syncHashtags($tag, $user, $private ? null : $review); + $query = "INSERT INTO reviews " . "(tag, user, created, private, review, airname) VALUES (" . "?, ?, " . @@ -171,7 +207,13 @@ public function insertReview($tag, $private, $airname, $review, $user) { $stmt->bindValue(4, $review); if($airname) $stmt->bindValue(5, $airname); - return $stmt->execute()?$stmt->rowCount():0; + $count = $stmt->execute() ? $stmt->rowCount() : 0; + + // back out hashtags on failure + if(!$count) + $this->syncHashtags($tag, $user); + + return $count; } public function updateReview($tag, $private, $airname, $review, $user) { @@ -188,7 +230,12 @@ public function updateReview($tag, $private, $airname, $review, $user) { $stmt->bindValue($p++, $review); $stmt->bindValue($p++, $tag); $stmt->bindValue($p++, $user); - return $stmt->execute()?$stmt->rowCount():0; + $count = $stmt->execute() ? $stmt->rowCount() : 0; + + if($count) + $this->syncHashtags($tag, $user, $private ? null : $review); + + return $count; } public function deleteReview($tag, $user) { @@ -197,7 +244,13 @@ public function deleteReview($tag, $user) { $stmt = $this->prepare($query); $stmt->bindValue(1, $tag); $stmt->bindValue(2, $user); - return $stmt->execute()?$stmt->rowCount():0; + $count = $stmt->execute() ? $stmt->rowCount() : 0; + + // delete any associated hashtags + if($count) + $this->syncHashtags($tag, $user); + + return $count; } public function setExportId($tag, $user, $exportId) { diff --git a/js/search.library.js b/js/search.library.js index 1a1ce11f..a9b2b7ef 100644 --- a/js/search.library.js +++ b/js/search.library.js @@ -144,7 +144,7 @@ function emitAlbumsEx(table, data) { tr.append(header("Album", true)); tr.append(header("Collection", false)); tr.append(header("Media", false).attr('colSpan', 2)); - tr.append(header("Added", false)); + tr.append(header("Added", $("#type").val() == 'hashtags')); tr.append(header("Label", true)); table.append($("").append(tr)); @@ -215,7 +215,8 @@ var requestMap = { albumsByPubkey: "album?filter[label.id]=", tracks: "album?filter[track]=", labels: "label?filter[name]=", - reviews: "album?filter[reviews.airname.id]=" + reviews: "album?filter[reviews.airname.id]=", + hashtags: "album?filter[reviews.hashtag]=" }; var lists = { @@ -370,6 +371,9 @@ var lists = { }, }; +// hashtags and albums share the same marshaller +lists.hashtags = lists.albums; + function search(size, offset) { var suffix, type = $("#type").val(); if(!type || !requestMap[type]) @@ -377,6 +381,7 @@ function search(size, offset) { switch(type) { case "albumsByPubkey": case "reviews": + case "hashtags": suffix = ""; break; default: @@ -428,6 +433,9 @@ function search(size, offset) { case "albumsByPubkey": ttype = "albums"; break; + case "hashtags": + ttype = "albums tagged #" + $("#fkey").val(); + break; default: ttype = type; break; @@ -457,7 +465,7 @@ function search(size, offset) { if($("#sortBy").val() == "") $("#sortBy").val("Artist"); lists[type](results, response); - } else { + } else if (type != 'hashtags' ) { if($("#m").is(":checked")) results.append('Hint: Uncheck "Exact match" box to broaden search.'); else diff --git a/js/trending.js b/js/trending.js new file mode 100644 index 00000000..dc3edf1c --- /dev/null +++ b/js/trending.js @@ -0,0 +1,80 @@ +// +// Zookeeper Online +// +// @author Jim Mason +// @copyright Copyright (C) 1997-2024 Jim Mason +// @link https://zookeeper.ibinx.com/ +// @license GPL-3.0 +// +// This code is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License, version 3, +// as published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License, +// version 3, along with this program. If not, see +// http://www.gnu.org/licenses/ +// + +/*! Zookeeper Online (C) 1997-2024 Jim Mason | @source: https://zookeeper.ibinx.com/ | @license: magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3.0 */ + +var trending = null; +var loading = false; +var timeout; + +function reposition() { + var pos = $("#cloud span").toArray().reduce(function(carry, span) { + if(span.offsetLeft < carry.left) + carry.left = span.offsetLeft; + if(span.offsetTop < carry.top) + carry.top = span.offsetTop; + return carry; + }, { left: 1000, top: 1000 } ); + + $("#cloud").css('margin-left', -pos.left + 'px') + .css('margin-top', -pos.top+20 + 'px') + .css('opacity', 1); + + loading = false; +} + +function loadCloud() { + if(trending && !loading) { + loading = true; + var width = $(".content").outerWidth(); + $("#cloud").css('opacity', 0).empty().jQCloud(trending, { + width: width, + height: 350, + removeOverflowing: false, + afterCloudRender: reposition + }); + } +} + +$().ready(function() { + if(!$.fn.jQCloud) { + $("#cloud").append($("

", { + class: 'quiet-notice' + }).html('The trending cloud is unavailable.')); + return; + } + + $.ajax({ + dataType: 'json', + type: 'GET', + accept: 'application/json; charset=utf-8', + url: '?action=viewRecent&subaction=trendingData' + }).done(function(response) { + trending = response; + loadCloud(); + }); + + window.addEventListener('resize', function(event) { + clearTimeout(timeout); + timeout = setTimeout(loadCloud, 100); + }); +}); diff --git a/ui/Reviews.php b/ui/Reviews.php index cf440b9d..9ba0b6ad 100644 --- a/ui/Reviews.php +++ b/ui/Reviews.php @@ -53,6 +53,8 @@ class Reviews extends MenuItem { private static $subactions = [ [ "a", "", "Recent Reviews", "viewRecentReviews" ], [ "a", "viewDJ", "By DJ", "reviewsByDJ" ], + [ "a", "viewHashtag", "Trending", "viewTrending" ], + [ "a", "trendingData", 0, "getTrendingData" ], ]; private $subaction; @@ -127,6 +129,25 @@ public function reviewsByDJ() { $this->emitViewDJMain(); } + public function viewTrending() { + $this->setTemplate("review.trending.html"); + } + + public function getTrendingData() { + $limit = 50; + $scale = $limit / 5; + $trending = Engine::api(IReview::class)->getTrending($limit); + $data = array_map(function($entry) use(&$limit, $scale) { + return [ + 'text' => $entry['hashtag'], + 'weight' => $entry['freq'] * $scale + floor($limit-- / 10), + 'link' => '?action=search&s=byHashtag&n=' . urlencode($entry['hashtag']) + ]; + }, $trending); + + echo json_encode($data); + } + public function viewRecentReviews() { $isAuthorized = $this->session->isAuth('u'); $author = $isAuthorized && ($_GET['dj'] ?? '') == 'Me' ? $this->session->getUser() : ''; diff --git a/ui/Search.php b/ui/Search.php index 8d709f15..d94d9bf1 100644 --- a/ui/Search.php +++ b/ui/Search.php @@ -46,7 +46,8 @@ class Search extends MenuItem { "byArtist" => "artists", "byTrack" => "tracks", "byLabel" => "labels", - "byLabelKey" => "albumsByPubkey" + "byLabelKey" => "albumsByPubkey", + "byHashtag" => "hashtags" ]; public $searchText; @@ -131,7 +132,7 @@ public function searchByAlbumKey($key = null) { // hashtags $hashtags = array_reduce(array_reverse($reviews), function($carry, $review) { - return preg_match_all('/#\pL\w*/u', $review['review'], $matches) ? + return !$review['private'] && preg_match_all('/#\pL\w*/u', $review['review'], $matches) ? array_merge($carry, $matches[0]) : $carry; }, []); $normalized = array_unique(array_map('strtolower', $hashtags)); diff --git a/ui/templates/default/album/info.html b/ui/templates/default/album/info.html index d810f3f4..3b344002 100644 --- a/ui/templates/default/album/info.html +++ b/ui/templates/default/album/info.html @@ -76,7 +76,7 @@ {% if hashtags | length %}

{% for tag, index in hashtags %} - {{ tag }} + {{ tag }} {% endfor %}
{% endif %} diff --git a/ui/templates/default/review.trending.html b/ui/templates/default/review.trending.html new file mode 100644 index 00000000..9127d737 --- /dev/null +++ b/ui/templates/default/review.trending.html @@ -0,0 +1,5 @@ + + + +

Music review tags trending on {{ app.station_title }}:

+
diff --git a/ui/templates/default/search.library.html b/ui/templates/default/search.library.html index 0fe4d166..284d4661 100644 --- a/ui/templates/default/search.library.html +++ b/ui/templates/default/search.library.html @@ -9,7 +9,7 @@

Classic search is now available in the Search bar at the top of the page.Classic search is now available in the Search bar at the top of the page.