diff --git a/.htaccess b/.htaccess index 5b2d7467..9aba1425 100644 --- a/.htaccess +++ b/.htaccess @@ -21,6 +21,8 @@ RewriteEngine on RewriteRule ^zkrss\.php$ index.php?target=rss [QSA,L] RewriteRule ^zk-feed-reader\.xslt$ controllers/RSS.xslt [QSA,L] RewriteRule ^ssoLogin\.php$ index.php?target=sso [QSA,L] +RewriteCond %{REQUEST_URI} ^(.*)/tag/([0-9]+)$ +RewriteRule .* %1/?action=search&s=byAlbumKey&n=%2 [R=302] RewriteCond $0 !^\.mdhandler.php/.*$ RewriteRule ^(.+)\.md$ .mdhandler.php?asset=/$1.md [L] RewriteRule ^zk$ - [R=404,L] diff --git a/api/Playlists.php b/api/Playlists.php index fea0ed78..dd654d17 100644 --- a/api/Playlists.php +++ b/api/Playlists.php @@ -168,7 +168,8 @@ public static function fromRecord($rec, $flags) { })->onSpin(function($entry) use(&$events, $relations, $flags) { $spin = $entry->asArray(); $spin["type"] = "spin"; - $spin["artist"] = PlaylistEntry::swapNames($spin["artist"]); + if($spin["tag"]) + $spin["artist"] = PlaylistEntry::swapNames($spin["artist"]); $spin["created"] = $entry->getCreatedTime(); if($spin["tag"] && $flags & self::LINKS_ALBUMS) { $tag = $spin["tag"]; @@ -317,11 +318,11 @@ public function fetchRelationship(RequestInterface $request): ResponseInterface unset($attrs["tag"]); unset($attrs["id"]); $a->merge($attrs); - $a->set("artist", PlaylistEntry::swapNames($entry->getArtist())); $a->set("created", $entry->getCreatedTime()); $tag = $entry->getTag(); if($tag) { + $a->set("artist", PlaylistEntry::swapNames($entry->getArtist())); if($flags && sizeof($albums = Engine::api(ILibrary::class)->search(ILibrary::ALBUM_KEY, 0, 1, $tag))) $res = Albums::fromArray($albums, $flags)[0]; else @@ -665,7 +666,7 @@ public function addRelatedResources(RequestInterface $request): ResponseInterfac // don't allow modification of album info if tag is set $entry->setTag($album->id()); if(!$albumrec[0]["iscoll"]) - $entry->setArtist(PlaylistEntry::swapNames($albumrec[0]["artist"])); + $entry->setArtist($albumrec[0]["artist"]); $entry->setAlbum($albumrec[0]["album"]); $entry->setLabel($albumrec[0]["name"]); } @@ -717,7 +718,8 @@ public function addRelatedResources(RequestInterface $request): ResponseInterfac $list['id'] = $key; if($entry->isType(PlaylistEntry::TYPE_SPIN)) { $spin = $entry->asArray(); - $spin['artist'] = PlaylistEntry::swapNames($spin['artist']); + if($spin['tag']) + $spin['artist'] = PlaylistEntry::swapNames($spin['artist']); } else $spin = null; @@ -790,7 +792,7 @@ public function replaceRelatedResources(RequestInterface $request): ResponseInte // don't allow modification of album info if tag is set $entry->setTag($album->id()); if(!$albumrec[0]["iscoll"]) - $entry->setArtist(PlaylistEntry::swapNames($albumrec[0]["artist"])); + $entry->setArtist($albumrec[0]["artist"]); $entry->setAlbum($albumrec[0]["album"]); $entry->setLabel($albumrec[0]["name"]); } diff --git a/composer.json b/composer.json index 33d2c040..a4fd8cee 100644 --- a/composer.json +++ b/composer.json @@ -42,8 +42,11 @@ "react/datagram": "^1.5", "react/http": "^1.2", "ratchet/pawl": "^0.4.1", - "rocketman/pdf-label": "^1.6+rocketman.1", + "rocketman/pdf-label": "^1.6+rocketman.2", "twig/twig": "^3.5.1", "vstelmakh/url-highlight": "^3.0" + }, + "suggest": { + "ext-intl": "Allows transliteration of Cyrillic and Greek to Latin" } } diff --git a/composer.lock b/composer.lock index b4cdee4a..0ceb6994 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "148bc206dc5d0e2fbebd0ef40424a7a9", + "content-hash": "f1cedcd4d8c8d3b390d327e6ef53c23a", "packages": [ { "name": "axy/backtrace", @@ -2189,16 +2189,16 @@ }, { "name": "rocketman/pdf-label", - "version": "v1.6+rocketman.1", + "version": "v1.6+rocketman.2", "source": { "type": "git", "url": "https://github.com/RocketMan/pdf-label.git", - "reference": "46fc80242ff10e121fbe327d1741dc745e8c6ffd" + "reference": "b794822ca8ea7e9b665468a5f94766e3bdc8782c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/RocketMan/pdf-label/zipball/46fc80242ff10e121fbe327d1741dc745e8c6ffd", - "reference": "46fc80242ff10e121fbe327d1741dc745e8c6ffd", + "url": "https://api.github.com/repos/RocketMan/pdf-label/zipball/b794822ca8ea7e9b665468a5f94766e3bdc8782c", + "reference": "b794822ca8ea7e9b665468a5f94766e3bdc8782c", "shasum": "" }, "require": { @@ -2207,7 +2207,8 @@ "type": "library", "autoload": { "classmap": [ - "src/PDF_Label.php" + "src/PDF_Label.php", + "src/qrcode.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2224,14 +2225,18 @@ }, { "name": "Steve Dillon" + }, + { + "name": "Nicola Asuni", + "email": "info@tecnick.com" } ], "description": "This class is a modified version of PDF_Label that adds support for unicode and ttf.", "support": { "issues": "https://github.com/RocketMan/pdf-label/issues", - "source": "https://github.com/RocketMan/pdf-label/tree/v1.6+rocketman.1" + "source": "https://github.com/RocketMan/pdf-label/tree/v1.6+rocketman.2" }, - "time": "2021-08-24T14:45:11+00:00" + "time": "2023-09-10T17:40:30+00:00" }, { "name": "setasign/tfpdf", @@ -2992,5 +2997,5 @@ "ext-pdo_mysql": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.1.0" } diff --git a/config/controller_config.php b/config/controller_config.php index 456feabe..7e47ab63 100644 --- a/config/controller_config.php +++ b/config/controller_config.php @@ -10,6 +10,7 @@ 'export' => ZK\Controllers\ExportPlaylist::class, 'afile' => ZK\Controllers\ExportAfile::class, 'opensearch' => ZK\Controllers\OpenSearch::class, + 'artwork' => ZK\Controllers\ArtworkControl::class, 'cache' => ZK\Controllers\CacheControl::class, 'daily' => ZK\Controllers\RunDaily::class, 'print' => ZK\Controllers\PrintTags::class, diff --git a/controllers/ArtworkControl.php b/controllers/ArtworkControl.php new file mode 100644 index 00000000..1e717e35 --- /dev/null +++ b/controllers/ArtworkControl.php @@ -0,0 +1,89 @@ + + * @copyright Copyright (C) 1997-2023 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/ + * + */ + +namespace ZK\Controllers; + +use ZK\Engine\Engine; +use ZK\Engine\IArtwork; +use ZK\Engine\IPlaylist; +use ZK\Engine\PlaylistEntry; +use ZK\Engine\PlaylistObserver; + +class ArtworkControl implements IController { + protected $verbose = false; + + protected function refreshList($playlist) { + $count = 0; + $imageApi = Engine::api(IArtwork::class); + Engine::api(IPlaylist::class)->getTracksWithObserver($playlist, + (new PlaylistObserver())->on('spin', function($entry) use($imageApi, &$count) { + if(!$entry->getTag() && $entry->getCreated()) { + $artist = $entry->getArtist(); + if($this->verbose) + echo " deleting $artist\n"; + $imageApi->deleteArtistArt($artist); + $count++; + } + }) + ); + + if($count) { + echo "$count images queued for reload (please wait)\n"; + PushServer::lazyLoadImages($playlist); + } else + echo "No artist artwork found. No change.\n"; + } + + public function processRequest() { + if(php_sapi_name() != "cli") { + http_response_code(400); + return; + } + + // The heavy lifting is done by the push notification server. + // If it is not enabled, there is no point in proceeding. + if(!Engine::param('push_enabled', true)) { + echo "Push notification is disabled. No change.\n"; + return; + } + + $this->verbose = $_REQUEST["verbose"] ?? false; + + switch($_REQUEST["action"] ?? "") { + case "reload": + if($tag = $_REQUEST["tag"] ?? null) { + echo "Album queued for reload (please wait)\n"; + PushServer::lazyReloadAlbum($tag, $_REQUEST["master"] ?? 1, $_REQUEST["skip"] ?? 0); + break; + } else if($list = $_REQUEST["list"] ?? null) { + $this->refreshList($list); + break; + } + // fall through... + default: + echo "Usage: zk artwork:reload {tag|list}=id [master=0] [skip=0]\n"; + break; + } + } +} diff --git a/controllers/ExportPlaylist.php b/controllers/ExportPlaylist.php index fb54b0ae..badb5a87 100644 --- a/controllers/ExportPlaylist.php +++ b/controllers/ExportPlaylist.php @@ -177,7 +177,7 @@ public function emitCSV() { // emit the tracks echo "Artist\tTrack\tAlbum\tTag\tLabel\tTimestamp\n"; $observer = (new PlaylistObserver())->onSpin(function($entry) { - echo $entry->getArtist()."\t". + echo ($entry->getTag() ? PlaylistEntry::swapNames($entry->getArtist()) : $entry->getArtist())."\t". $entry->getTrack()."\t". $entry->getAlbum()."\t". $entry->getTag()."\t". @@ -249,7 +249,7 @@ public function emitHTML() { $break = true; } })->onSpin(function($entry) use(&$break) { - echo " ".htmlentities(PlaylistEntry::swapNames($entry->getArtist())) . "" . + echo " ".htmlentities($entry->getTag() ? PlaylistEntry::swapNames($entry->getArtist()) : $entry->getArtist()) . "" . htmlentities($entry->getTrack()). "" . htmlentities($entry->getAlbum()). "
" . htmlentities($entry->getLabel()). ""; diff --git a/controllers/PrintTags.php b/controllers/PrintTags.php index 89bde44c..5186cc9a 100644 --- a/controllers/PrintTags.php +++ b/controllers/PrintTags.php @@ -3,7 +3,7 @@ * Zookeeper Online * * @author Jim Mason - * @copyright Copyright (C) 1997-2021 Jim Mason + * @copyright Copyright (C) 1997-2023 Jim Mason * @link https://zookeeper.ibinx.com/ * @license GPL-3.0 * @@ -24,19 +24,30 @@ namespace ZK\Controllers; -use ZK\UI\Editor; -use ZK\UI\UICommon as UI; +use ZK\Engine\Engine; +use ZK\Engine\ILibrary; define("_SYSTEM_TTFONTS", dirname(__DIR__)."/fonts/"); class PrintTags implements IController { - const FONT_FACE="NimbusMono-Bold"; - const FONT_FILE="nimbusmono-bold.ttf"; + const FONT_FACE="Montserrat-Bold"; + const FONT_FILE="Montserrat-Bold.ttf"; + const FONT_FACE_Z="MontserratZ-Bold"; + const FONT_FILE_Z="MontserratZ-Bold.ttf"; const FONT_SIZE=13; + const FONT_SIZE_SUB=11; + const FONT_SIZE_DATE=7; const LINE_SIZE=9; + const FONT_FACE_TAG="Roboto-Bold"; + const FONT_FILE_TAG="Roboto-Bold.ttf"; + const FONT_SIZE_TAG=33; + const LINE_SIZE_TAG=14; + const LABEL_FORM="5161"; + const LINK = "tag/%d"; + const ADDITIONAL_FORMS = [ 'DK-1201' => [ 'orientation'=>'L', @@ -47,26 +58,109 @@ class PrintTags implements IController { 'width'=>90, 'height'=>29, 'font-size'=>8 ], ]; - + + protected $albums = []; + + protected function loadTags($tags) { + $special = false; + foreach($tags as $tag) { + if(!$tag) + continue; + + $result = Engine::api(ILibrary::class)->search(ILibrary::ALBUM_KEY, 0, 1, $tag); + if(count($result) == 0) + continue; + + $album = $result[0]; + $artist = $album["artist"]; + if(mb_strlen($artist) > 30) + $artist = mb_substr($artist, 0, 30); + + $title = $album["album"]; + $cat = explode(" - ", ILibrary::GENRES[$album["category"]]); + $category = "(" . $cat[0] . ")"; + $maxAlbumLen = 32 - mb_strlen($category); + if(mb_strlen($title) > $maxAlbumLen + 3) + $title = mb_substr($title, 0, $maxAlbumLen) . "..."; + + // Montserrat does not include Greek codepoints; + // for now, we will substitute MontserratZ in this case. + // + // MontserratZ has a wonky Latin and Cyrillic 'a', and as + // well, the TTF is twice the size of Montserrat. Thus, we + // include it in the PDF only if necessary, and use it only + // for labels that contain Greek text. + // + // U+0370 - U+03ff Greek and Coptic + // U+1f00 - U+1fff Greek Extended + $greek = preg_match("/[\u{0370}-\u{03ff}\u{1f00}-\u{1fff}]/u", $artist.$title); + $special |= $greek; + + $this->albums[$tag] = [ + 'artist' => "\n\n\n" . $artist, + 'title' => "\n\n\n\n " . $title, + 'category' => "\n\n\n\n" . $category, + 'subcat' => count($cat) > 1 ? str_repeat("\n", 8) . strtoupper($cat[1]) : false, + 'date' => date_format(date_create($album['created']), " m-Y"), + 'special' => $greek + ]; + } + + return $special; + } + public function processRequest() { header("Content-type: application/pdf"); $form = empty($_REQUEST["form"])?self::LABEL_FORM:$_REQUEST["form"]; if(array_key_exists($form, self::ADDITIONAL_FORMS)) $form = self::ADDITIONAL_FORMS[$form]; - + + $inst = $_REQUEST["inst"] ?? Engine::getBaseUrl(); + $url = $inst . self::LINK; + $pdf = new \PDF_Label($form); + $pdf->AddFont(self::FONT_FACE_TAG, '', self::FONT_FILE_TAG, true); $pdf->AddFont(self::FONT_FACE, '', self::FONT_FILE, true); - $pdf->SetFont(self::FONT_FACE, '', self::FONT_SIZE); - $pdf->Set_Font_Size(self::LINE_SIZE); - $pdf->SetFontSize(self::FONT_SIZE); + $tags = explode(",", $_REQUEST["tags"] ?? ''); + if($this->loadTags($tags)) + $pdf->AddFont(self::FONT_FACE_Z, '', self::FONT_FILE_Z, true); $pdf->SetCreator("Zookeeper Online"); $pdf->AddPage(); - $tags = explode(",", $_REQUEST["tags"]); + $empty = [ + 'artist' => '', + 'special' => false + ]; + foreach($tags as $tag) { - $output = $tag?Editor::makeLabel($tag, UI::CHARSET_UTF8):""; - $pdf->Add_Label($output); + $album = $this->albums[$tag] ?? $empty; + $face = $album['special'] ? self::FONT_FACE_Z : self::FONT_FACE; + $pdf->SetFont($face, '', self::FONT_SIZE); + $pdf->SetLineHeight(self::LINE_SIZE); + $artist = $album['artist']; + $pdf->Add_Label($artist); + + if($tag && $artist) { + $pdf->SetFontSize(self::FONT_SIZE_SUB); + $pdf->currentLabel($album['title']); + $pdf->currentLabel($album['category'], 'R'); + $pdf->Set_Font_Size(self::FONT_SIZE_DATE); + $pdf->verticalText($album['date'], $inst ? -15 : -1, 0); + if($album['subcat']) + $pdf->currentLabel($album['subcat'], 'R'); + + // insert half-space separator every three digits + $tagNum = strrev(implode(" ", str_split(strrev($tag), 3))); + $tagNum = str_replace(" ", "\u{2009}", $tagNum); + + $pdf->SetFont(self::FONT_FACE_TAG, '', self::FONT_SIZE_TAG); + $pdf->SetLineHeight(self::LINE_SIZE_TAG); + $pdf->currentLabel($tagNum); + + if($inst) + $pdf->writeQRCode(sprintf($url, $tag), 'R', 1.2); + } } $pdf->Output(); diff --git a/controllers/PushServer.php b/controllers/PushServer.php index 49e960ba..7dae30c4 100644 --- a/controllers/PushServer.php +++ b/controllers/PushServer.php @@ -27,6 +27,7 @@ use ZK\Engine\DBO; use ZK\Engine\Engine; use ZK\Engine\IArtwork; +use ZK\Engine\ILibrary; use ZK\Engine\IPlaylist; use ZK\Engine\OnNowFilter; use ZK\Engine\PlaylistEntry; @@ -96,6 +97,25 @@ public static function toJson($show, $spin) { return json_encode($val); } + /** + * perform artist name comparison, accounting for use of ampersand + * + * adapted from ZootopiaListener::testArtist + * + * @param $albumArtist haystack + * @param $trackArtist needle + * @returns true iff needle appears somewhere in haystack + */ + protected static function testArtist($albumArtist, $trackArtist) { + if(strpos($trackArtist, '&') !== false && + ($i = stripos($albumArtist, ' and ')) !== false) + $albumArtist = substr_replace($albumArtist, '&', $i + 1, 3); + else if(strpos($albumArtist, '&') !== false && + ($i = stripos($trackArtist, ' and ')) !== false) + $trackArtist = substr_replace($trackArtist, '&', $i + 1, 3); + return stripos($albumArtist, $trackArtist) !== false; + } + public function __construct($loop) { $this->clients = new \SplObjectStorage; $this->imageQ = new \SplQueue; @@ -134,7 +154,8 @@ protected function loadOnNow() { $filter = Engine::api(IPlaylist::class)->getTracksWithObserver($show['id'], (new PlaylistObserver())->onSpin(function($entry) use(&$event) { $spin = $entry->asArray(); - $spin['artist'] = PlaylistEntry::swapNames($spin['artist']); + if($spin['tag']) + $spin['artist'] = PlaylistEntry::swapNames($spin['artist']); $event = $spin; })->on('comment logEvent setSeparator', function($entry) use(&$event) { $event = null; @@ -194,18 +215,32 @@ public function onOpen(ConnectionInterface $conn) { // echo "New connection {$conn->resourceId}\n"; } + /** + * query discogs for album or artist + * + * To search for an album, supply both artist and album; + * to search for an artist, supply only the artist name. + * (To search for an artist by name and album, use the method + * `queryDiscogsArtistByAlbum`.) + * + * @param $artist artist name + * @param $album album name (optional; if supplied, does album search) + * @returns false iff communications error, result otherwise (can be empty) + */ protected function queryDiscogs($artist, $album = null) { $success = true; + $retval = new \stdClass(); + $retval->imageUrl = $retval->infoUrl = $retval->resourceUrl = null; try { $query = $album ? [ "artist" => $artist, - "release_title" => $album, + "release_title" => preg_replace('/\(.*$/', '', $album), "per_page" => 40 ] : [ - "query" => $artist, + "title" => $artist, "type" => "artist", - "per_page" => 1 + "per_page" => 40 ]; $response = $this->discogs->get('', [ @@ -242,19 +277,88 @@ function($carry, $item) { $result = $r; break; } + } else { + // advance to the first artist with artwork + foreach($json->results as $r) { + if($r->cover_image && + !preg_match('|/spacer.gif$|', $r->cover_image)) { + $result = $r; + break; + } + } } if($result->cover_image && !preg_match('|/spacer.gif$|', $result->cover_image)) - $imageUrl = $result->cover_image; - $infoUrl = self::DISCOGS_BASE . $result->uri; + $retval->imageUrl = $result->cover_image; + $retval->infoUrl = self::DISCOGS_BASE . $result->uri; + $retval->resourceUrl = $result->resource_url; } } catch(\Exception $e) { $success = false; - error_log("getImageData: ".$e->getMessage()); + error_log("queryDiscogs: ".$e->getMessage()); } - return [ $imageUrl ?? null, $infoUrl ?? null, $success ]; + return $success ? $retval : false; + } + + /** + * query discogs for artist by {album, artist} tuple + * + * @param $artist artist name + * @param $album album name + * @returns result if found, false if no match or error + */ + protected function queryDiscogsArtistByAlbum($artist, $album) { + $success = false; + $retval = new \stdClass(); + $retval->imageUrl = null; + + $albumRec = $this->queryDiscogs($artist, $album); + + if($albumRec && $albumRec->resourceUrl) { + try { + $response = $this->discogs->get($albumRec->resourceUrl); + + $page = $response->getBody()->getContents(); + $json = json_decode($page); + + if($json) { + $artists = $json->artists ?? null; + if($artists && count($artists)) { + foreach($artists as $candidate) { + if(self::testArtist($candidate->name, $artist)) { + $response = $this->discogs->get($candidate->resource_url); + + $page = $response->getBody()->getContents(); + $json = json_decode($page); + + if($json) { + $images = $json->images ?? null; + if($images && count($images)) { + $retval->imageUrl = $images[0]->uri; + foreach($images as $image) { + if($image->type == "primary") { + $retval->imageUrl = $image->uri; + break; + } + } + } + $retval->infoUrl = $json->uri; + $retval->resourceUrl = $json->resource_url; + $retval->album = $albumRec; + $success = true; + } + break; + } + } + } + } + } catch(\Exception $e) { + error_log("queryDiscogsArtistByAlbum: ".$e->getMessage()); + } + } + return $success ? $retval : false; } protected function injectImageData($msg) { @@ -272,10 +376,12 @@ protected function injectImageData($msg) { $infoUrl = $image['info_url']; } else { // otherwise, query Discogs - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($entry['track_artist'], $entry['track_album']); + $result = $this->queryDiscogs($entry['track_artist'], $entry['track_album']); - if($success) - $imageUuid = $imageApi->insertAlbumArt($entry['track_tag'], $imageUrl, $infoUrl); + if($result) { + $imageUuid = $imageApi->insertAlbumArt($entry['track_tag'], $result->imageUrl, $result->infoUrl); + $infoUrl = $result->infoUrl; + } } } @@ -289,10 +395,15 @@ protected function injectImageData($msg) { $infoUrl = $image['info_url']; } else { // otherwise, query Discogs - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($entry['track_artist']); - - if($success) - $imageUuid = $imageApi->insertArtistArt($entry['track_artist'], $imageUrl, $infoUrl); + $result = strlen(trim($entry['track_album'])) ? + $this->queryDiscogsArtistByAlbum($entry['track_artist'], $entry['track_album']) : null; + if(!$result) + $result = $this->queryDiscogs($entry['track_artist']); + + if($result) { + $imageUuid = $imageApi->insertArtistArt($entry['track_artist'], $result->imageUrl, $result->infoUrl); + $infoUrl = $result->infoUrl; + } } } @@ -313,17 +424,21 @@ protected function processImageQueue() { $artist = $entry->getArtist(); if($entry->getTag()) { - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($artist, $entry->getAlbum()); + $result = $this->queryDiscogs($artist, $entry->getAlbum()); - if($success) - $imageUuid = $imageApi->insertAlbumArt($entry->getTag(), $imageUrl, $infoUrl); + if($result) + $imageUuid = $imageApi->insertAlbumArt($entry->getTag(), $result->imageUrl, $result->infoUrl); } if(!isset($imageUuid)) { - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($artist); - - if($success) - $imageUuid = $imageApi->insertArtistArt($artist, $imageUrl, $infoUrl); + $album = $entry->getAlbum(); + $result = strlen(trim($album)) ? + $this->queryDiscogsArtistByAlbum($artist, $album) : null; + if(!$result) + $result = $this->queryDiscogs($artist); + + if($result) + $imageUuid = $imageApi->insertArtistArt($artist, $result->imageUrl, $result->infoUrl); } if(!$this->imageQ->isEmpty()) { @@ -340,13 +455,14 @@ protected function enqueueEntry($entry, $imageApi) { return; } - if(preg_match('/(\.gov|\.org|GED|Literacy|NIH|Ad\ Council)/', implode(' ', $entry->asArray())) || empty(trim($entry->getArtist()))) { + if(preg_match('/(\.gov|\.org|GED|Literacy|NIH|Ad\ Council|Lift\ Jesus)/', implode(' ', $entry->asArray())) || empty(trim($entry->getArtist()))) { // it's probably a PSA coded as a spin; let's skip it return; } // fixup artist name - $entry->setArtist(PlaylistEntry::swapNames($entry->getArtist())); + if($entry->getTag()) + $entry->setArtist(PlaylistEntry::swapNames($entry->getArtist())); if($entry->getTag() && $imageApi->getAlbumArt($entry->getTag(), true) || @@ -366,7 +482,8 @@ public function loadImages($playlist, $track) { if($track) { $entry = new PlaylistEntry($listApi->getTrack($track)); - $this->enqueueEntry($entry, $imageApi); + if($entry->isType(PlaylistEntry::TYPE_SPIN)) + $this->enqueueEntry($entry, $imageApi); } else { $visited = []; $listApi->getTracksWithObserver($playlist, @@ -392,6 +509,100 @@ public function loadImages($playlist, $track) { } } + public function reloadAlbum($tag, $param) { + $albums = Engine::api(ILibrary::class)->search(ILibrary::ALBUM_KEY, 0, 1, $tag); + if(!count($albums)) { + echo "reloadAlbum($tag): tag not found\n"; + return; + } + + $album = $albums[0]; + + try { + switch($album["medium"] ?? null) { + case 'S': + $format = "Vinyl, 7\""; + break; + case 'T': + case 'V': + $format = "Vinyl"; + break; + case 'M': + $format = "Cassette"; + break; + default: + $format = "CD"; + break; + } + + $params = [ + "artist" => $album["iscoll"] ? + "Various" : PlaylistEntry::swapNames($album["artist"]), + "release_title" => $album["album"], + "per_page" => 20 + ]; + + $master = $param & 0x1; + $skip = ($param & ~0xff) >> 8; + + if($master) + $params["type"] = "master"; + else + $params["format"] = $format; + + $response = $this->discogs->get('', [ + RequestOptions::QUERY => $params + ]); + + $page = $response->getBody()->getContents(); + $json = json_decode($page); + if($json->results && ($result2 = $json->results[0])) { + foreach($json->results as $r) { + if($skip-- > 0) + continue; + + // master releases are definitive + if($r->type == "master") { + $result2 = $r; + break; + } + + // ignore promos and limited/special editions + if(array_reduce($r->format, + function($carry, $item) { + return $carry || + $item == "Promo" || + strpos($item, "Edition") !== false; + })) + continue; + + // prefer CD or vinyl + switch($r->format[0]) { + case "CD": + case "Vinyl": + $result2 = $r; + break; + } + } + + $imageUrl = $result2->cover_image && + !preg_match('|/spacer.gif$|', $result2->cover_image) ? + $result2->cover_image : null; + $infoUrl = self::DISCOGS_BASE . $result2->uri; + } + + if($imageUrl) { + $imageApi = Engine::api(IArtwork::class); + $imageApi->deleteAlbumArt($tag); + $uuid = $imageApi->insertAlbumArt($tag, $imageUrl, $infoUrl); + echo "reloadAlbum($tag): ".($master?'master':$format)." loaded $uuid\n"; + } else + echo "reloadAlbum($tag): no image found\n"; + } catch(\Exception $e) { + echo $e->getMessage() . "\n"; + } + } + public function sendNotification($msg = null, $client = null) { if($msg) { if($this->current != $msg) @@ -465,6 +676,21 @@ public static function lazyLoadImages($playlistId, $trackId = 0) { socket_close($socket); } + public static function lazyReloadAlbum($tag, $master = 1, $skip = 0) { + if(!Engine::param('push_enabled', true) || + !($config = Engine::param('discogs')) || + !$config['apikey'] && !$config['client_id']) + return; + + $param = $master & 0xff | $skip << 8; + $data = "reloadAlbum($tag, $param)"; + + $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_sendto($socket, $data, strlen($data), 0, + PushServer::WSSERVER_HOST, PushServer::WSSERVER_PORT); + socket_close($socket); + } + public function processRequest() { if(php_sapi_name() != "cli") { http_response_code(400); @@ -503,6 +729,8 @@ function(\React\Datagram\Socket $client) use($nas) { if(preg_match("/^loadImages\((\d+)(,\s*(\d+))?\)$/", $message, $matches)) $nas->loadImages($matches[1], $matches[3] ?? 0); + else if(preg_match("/^reloadAlbum\((\d+)(,\s*(\d+))?\)$/", $message, $matches)) + $nas->reloadAlbum($matches[1], $matches[3] ?? 1); else if($message && $message[0] == '{') $nas->sendNotification($message); else // empty message means poll database diff --git a/controllers/RSS.php b/controllers/RSS.php index 68f87458..4bfbf1c6 100644 --- a/controllers/RSS.php +++ b/controllers/RSS.php @@ -48,7 +48,7 @@ public function processRequest() { $template = $templateFactory->load('rss.xml'); $this->params['feeds'] = []; - $feeds = explode(',', $_REQUEST['feed']); + $feeds = explode(',', $_REQUEST['feed'] ?? ''); foreach($feeds as $feed) $this->processLocal($feed, null); @@ -77,13 +77,8 @@ public function composeChartRSS($endDate, $limit="", $category="") { } public function recentCharts() { - $top = $_REQUEST["top"]; - $weeks = $_REQUEST["weeks"]; - - if(!$top) - $top = 30; - if(!$weeks) - $weeks = 10; + $top = $_REQUEST["top"] ?? 30; + $weeks = $_REQUEST["weeks"] ?? 10; $this->params['limit'] = $top; $this->params['dateSpec'] = UI::isUsLocale() ? 'l, F j, Y' : 'l, j F Y'; @@ -158,6 +153,6 @@ public function recentAdds() { } public function emitError() { - $this->params['feeds'][] = 'invalid'; + $this->params['feeds'][] = 'invalid'; } } diff --git a/controllers/templates/default/rss/adds.xml b/controllers/templates/default/rss/adds.xml index aabbe683..ce89d1f4 100644 --- a/controllers/templates/default/rss/adds.xml +++ b/controllers/templates/default/rss/adds.xml @@ -7,7 +7,7 @@ en-us {% for add in adds %} {%~ set title = app.station ~ " Adds for " ~ add.addDate | date(dateSpec) %} - {%~ set link = app.baseUrl ~ "?action=addmgr&date=" ~ add.addDate %} + {%~ set link = app.baseUrl ~ "?action=addmgr&subaction=adds&date=" ~ add.addDate %} {{ title }} {{ link | raw }} diff --git a/controllers/templates/default/rss/invalid.xml b/controllers/templates/default/rss/invalid.xml index 2fabee61..39b8d056 100644 --- a/controllers/templates/default/rss/invalid.xml +++ b/controllers/templates/default/rss/invalid.xml @@ -1,7 +1,7 @@ {% autoescape 'xml' %} Invalid feed: {{ app.request.feed }} - {{ baseUrl }} + {{ app.baseUrl }} Invalid feed: {{ app.request.feed }} {% endautoescape %} diff --git a/css/zoostyle.css b/css/zoostyle.css index eae2e83b..26c52e7f 100644 --- a/css/zoostyle.css +++ b/css/zoostyle.css @@ -185,7 +185,7 @@ div.content select[size='1'] { #add-manager td { text-align: left; } -#add-manager td:first-of-type { +#add-manager table:not(.cats) > tbody > tr > td:first-of-type { text-align: right; } @@ -1359,7 +1359,7 @@ a:hover.copy { top: 0; left: 0; transition: all 400ms ease 0s; - opacity: 1; + opacity: 0; border: 0; object-fit: cover; object-position: center 10%; @@ -1480,12 +1480,17 @@ a:hover.copy { /* artwork on album details page */ .album-info { - display: inline-block; + display: inline-flex; + flex-flow: column nowrap; + justify-content: space-between; + align-items: flex-start; vertical-align: top; padding-bottom: 10px; } .album-info.with-thumb { max-width: calc(100% - 140px); + min-height: 126px; + padding-bottom: 0; } .album-thumb { @@ -1501,6 +1506,33 @@ a:hover.copy { object-position: center 10%; } +.album-hashtag-area { + padding-top: 15px; +} + +.album-hashtag { + border-radius: 6px; + border: 1px solid #666; + padding: 2px 8px; + margin-right: 2px; + line-height: 2; +} +.album-hashtag.palette-0 { + background-color: #bdd0c4; +} +.album-hashtag.palette-1 { + background-color: #bad7f3; +} +.album-hashtag.palette-2 { + background-color: #ffd6a5; +} +.album-hashtag.palette-3 { + background-color: #f7e1d3; +} +.album-hashtag.palette-4 { + background-color: #dfccf1; +} + div.toggle-time-entry { position: relative; float: right; diff --git a/engine/Config.php b/engine/Config.php index 8dca4a28..7923512a 100644 --- a/engine/Config.php +++ b/engine/Config.php @@ -37,8 +37,12 @@ class Config { * @param variable variable name in config file (default 'config') */ public function __construct($file, $variable = 'config') { + $path = dirname(__DIR__) . "/config/${file}.php"; + if(!is_file($path)) + throw new \Exception("Config file not found: $file"); + // populate the configuration from the given file and variable - include __DIR__."/../config/${file}.php"; + include $path; if(isset($$variable) && is_array($$variable)) $this->config = $$variable; else diff --git a/engine/Engine.php b/engine/Engine.php index 3b4c1e8f..55a70665 100644 --- a/engine/Engine.php +++ b/engine/Engine.php @@ -146,10 +146,10 @@ public static function getBaseUrl() { * @return HTML-encoded URI of decorated asset */ public static function decorate($asset) { - $mtime = filemtime(__DIR__.'/../'.$asset); + $mtime = filemtime(dirname(__DIR__) . '/' . $asset); $ext = strrpos($asset, '.'); return htmlspecialchars($mtime && $ext !== FALSE? - substr($asset, 0, $ext).'-'.$mtime. + substr($asset, 0, $ext) . '-' . $mtime . substr($asset, $ext):$asset, ENT_QUOTES, 'UTF-8'); } } diff --git a/engine/IArtwork.php b/engine/IArtwork.php index e3e6e4ee..2ef49383 100644 --- a/engine/IArtwork.php +++ b/engine/IArtwork.php @@ -34,6 +34,7 @@ function insertAlbumArt($tag, $imageUrl, $infoUrl); function insertArtistArt($artist, $imageUrl, $infoUrl); function getCachePath($key); function deleteAlbumArt($tag); + function deleteArtistArt($artist); function expireCache($days=10, $expireAlbums=false); function expireEmpty($days=1); } diff --git a/engine/ILibrary.php b/engine/ILibrary.php index fd4319b5..4f4cf215 100644 --- a/engine/ILibrary.php +++ b/engine/ILibrary.php @@ -32,6 +32,8 @@ interface ILibrary { "B"=>"Blues", "C"=>"Country", "G"=>"General", + "1"=>"General - Electronic", + "2"=>"General - Loud", "H"=>"Hip-hop", "J"=>"Jazz", "K"=>"Childrens", diff --git a/engine/IReview.php b/engine/IReview.php index 1ee7db87..cb984ade 100644 --- a/engine/IReview.php +++ b/engine/IReview.php @@ -45,7 +45,7 @@ interface IReview { const MAX_REVIEW_LENGTH = 64000; function getRecentReviews($user = "", $weeks = 0, $limit = 0, $loggedIn = 0); - function getActiveReviewers($viewAll=0); + function getActiveReviewers($viewAll=0, $loggedIn=0); function getReviews($tag, $byName=1, $user = "", $loggedIn = 0, $byId = 0); function insertReview($tag, $private, $airname, $review, $user); function updateReview($tag, $private, $airname, $review, $user); diff --git a/engine/PlaylistEntry.php b/engine/PlaylistEntry.php index 9fac9021..dcee3593 100644 --- a/engine/PlaylistEntry.php +++ b/engine/PlaylistEntry.php @@ -185,7 +185,8 @@ public static function fromJSON($json) { if(sizeof($albumrec)) { // don't allow modification of album info if tag is set $entry->setTag($a); - $entry->setArtist(self::swapNames($albumrec[0]["artist"])); + if(!$albumrec[0]["iscoll"]) + $entry->setArtist($albumrec[0]["artist"]); $entry->setAlbum($albumrec[0]["album"]); $entry->setLabel($albumrec[0]["name"]); } @@ -220,7 +221,8 @@ public static function fromArray($array) { if(sizeof($albumrec)) { // don't allow modification of album info if tag is set $entry->setTag($album->id()); - $entry->setArtist(self::swapNames($albumrec[0]["artist"])); + if(!$albumrec[0]["iscoll"]) + $entry->setArtist($albumrec[0]["artist"]); $entry->setAlbum($albumrec[0]["album"]); $entry->setLabel($albumrec[0]["name"]); } diff --git a/engine/TemplateFactory.php b/engine/TemplateFactory.php index f0c6657e..c5293374 100644 --- a/engine/TemplateFactory.php +++ b/engine/TemplateFactory.php @@ -24,7 +24,6 @@ namespace ZK\Engine; - class SafeSession { public function getDN() { return Engine::session()->getDN(); } public function getUser() { return Engine::session()->getUser(); } @@ -89,11 +88,9 @@ public function __construct(string $templateRoot) { $cacheDir = Engine::param('template_cache_enabled') ? $templateRoot . '/.cache' : false; - if($cacheDir) { - if(!is_dir($cacheDir) && !mkdir($cacheDir)) { - error_log("TemplateFactory: cannot create $cacheDir"); - $cacheDir = false; // disable cache - } + if($cacheDir && !is_dir($cacheDir) && !mkdir($cacheDir)) { + error_log("TemplateFactory: cannot create $cacheDir"); + $cacheDir = false; // disable cache } $loader = new \Twig\Loader\FilesystemLoader($path); diff --git a/engine/impl/Artwork.php b/engine/impl/Artwork.php index adf1d3c9..14ee82e7 100644 --- a/engine/impl/Artwork.php +++ b/engine/impl/Artwork.php @@ -197,7 +197,23 @@ public function deleteAlbumArt($tag) { "WHERE image_id = ?"; $stmt = $this->prepare($query); $stmt->bindValue(1, $image['image_id']); - if($stmt->execute()) { + if($stmt->execute() && $image['image_uuid']) { + $cacheDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR; + $path = $cacheDir . $this->getCachePath($image['image_uuid']); + unlink(realpath($path)); + } + } + } + + public function deleteArtistArt($artist) { + $image = $this->getArtistArt($artist); + if($image) { + $query = "DELETE FROM artistmap, artwork USING artistmap " . + "LEFT JOIN artwork ON artistmap.image_id = artwork.id " . + "WHERE image_id = ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $image['image_id']); + if($stmt->execute() && $image['image_uuid']) { $cacheDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR; $path = $cacheDir . $this->getCachePath($image['image_uuid']); unlink(realpath($path)); diff --git a/engine/impl/Library.php b/engine/impl/Library.php index 77d15f32..5d67c6c4 100644 --- a/engine/impl/Library.php +++ b/engine/impl/Library.php @@ -87,11 +87,12 @@ class LibraryImpl extends DBO implements ILibrary { "ORDER BY showdate DESC, list DESC, t.id" ], [ "reviews", "reviewrec", "reviews", "review", "SELECT r.tag, av.artist, av.album, an.airname, " . - "DATE_FORMAT(r.created, GET_FORMAT(DATE, 'ISO')) reviewed, r.id " . + "DATE_FORMAT(r.created, GET_FORMAT(DATE, 'ISO')) reviewed, r.id, u.realname " . "FROM reviews r " . "LEFT JOIN albumvol av ON r.tag = av.tag " . "LEFT JOIN airnames an ON r.airname = an.id " . - "WHERE private = 0 AND r.airname IS NOT NULL AND " . + "LEFT JOIN users u ON r.user = u.name " . + "WHERE private = 0 AND " . "MATCH (review) AGAINST(? IN BOOLEAN MODE) " . "ORDER BY r.created DESC" ], [ "tracks", "albumrec", "tracknames", "track", diff --git a/engine/impl/Playlist.php b/engine/impl/Playlist.php index 50d20093..81ee4930 100644 --- a/engine/impl/Playlist.php +++ b/engine/impl/Playlist.php @@ -985,7 +985,8 @@ public function getPlaysBefore($timestamp, $limit) { continue; } - $track['track_artist'] = PlaylistEntry::swapNames($track['track_artist']); + if($track['track_tag']) + $track['track_artist'] = PlaylistEntry::swapNames($track['track_artist']); $this->injectImageData($track); $res[] = $track; } diff --git a/engine/impl/Review.php b/engine/impl/Review.php index 242b15fb..c2c0ac91 100644 --- a/engine/impl/Review.php +++ b/engine/impl/Review.php @@ -111,16 +111,20 @@ public function getRecentReviews($user = "", $weeks = 0, $limit = 0, $loggedIn = return $reviews; } - public function getActiveReviewers($viewAll = 0) { + public function getActiveReviewers($viewAll = 0, $loggedIn = 0) { $query = "SELECT a.id, a.airname FROM reviews r, airnames a "; $query .= "WHERE a.id = r.airname AND r.airname IS NOT NULL "; if(!$viewAll) $query .= "AND ADDDATE(r.created, 12*7) > NOW() "; + if(!$loggedIn) + $query .= "AND r.private = 0 "; $query .= "GROUP BY a.airname UNION "; $query .= "SELECT u.name, u.realname FROM reviews r, users u "; $query .= "WHERE u.name = r.user AND r.airname IS NULL "; if(!$viewAll) $query .= "AND ADDDATE(r.created, 12*7) > NOW() "; + if(!$loggedIn) + $query .= "AND r.private = 0 "; $query .= "GROUP BY u.name"; $stmt = $this->prepare($query); diff --git a/fonts/Montserrat-Bold.ttf b/fonts/Montserrat-Bold.ttf new file mode 100644 index 00000000..efddc834 Binary files /dev/null and b/fonts/Montserrat-Bold.ttf differ diff --git a/fonts/MontserratZ-Bold.ttf b/fonts/MontserratZ-Bold.ttf new file mode 100644 index 00000000..2cb3b1c7 Binary files /dev/null and b/fonts/MontserratZ-Bold.ttf differ diff --git a/fonts/Montserrat_LICENSE.txt b/fonts/Montserrat_LICENSE.txt new file mode 100644 index 00000000..7881887b --- /dev/null +++ b/fonts/Montserrat_LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/Roboto-Bold.ttf b/fonts/Roboto-Bold.ttf new file mode 100644 index 00000000..43da14d8 Binary files /dev/null and b/fonts/Roboto-Bold.ttf differ diff --git a/fonts/Roboto_LICENSE.txt b/fonts/Roboto_LICENSE.txt new file mode 100644 index 00000000..75b52484 --- /dev/null +++ b/fonts/Roboto_LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/js/editor.common.js b/js/editor.common.js index 37f1c56a..a14487cf 100644 --- a/js/editor.common.js +++ b/js/editor.common.js @@ -62,8 +62,8 @@ function changeList(list) { var html = '").append($("", { + var img = $("
").css('background-color', palette[Math.floor((Math.random() * palette.length))] + ).append($("", { href: spin.track_tag ? "?s=byAlbumKey&n=" + encodeURIComponent(spin.track_tag) + "&action=search" : spin.info_url, target: spin.track_tag ? "_self" : "_blank" - }).append($("", { + }).append($("", { class: "artwork", src: spin.image_url - }).css('background-color', palette[Math.floor((Math.random() * palette.length))]))); + }).on('load', function() { + this.style.opacity = 1; + }))); if(spin.track_tag || spin.info_url) img.find("A").attr('title', spin.track_tag ? - "View album in Zookeeper" : + "View album in " + station_title : "View artist in Discogs"); - var info = $("
", { + var info = $("
", { class: "info" - }).append($("

", { + }).append($("

", { class: "track details" }).html(spin.track_title)); - info.append($("

", { + info.append($("

", { class: "artist details" }).html(spin.track_artist)); @@ -81,13 +86,13 @@ $().ready(function(){ var spinTime = new Date(spin.track_time.replace(' ','T') + 'Z'); spinTime.setMinutes(spinTime.getMinutes() + spinTime.getTimezoneOffset()); - var card = $("

", { + var card = $("
", { class: "card" }).append(img); card.attr("data-id", spin.id); card.attr("data-time", spin.track_time); card.append(info); - card.append($("", { + card.append($("", { class: "time" }).html(localTime(spinTime))); @@ -96,7 +101,7 @@ $().ready(function(){ function populateCards(replace, before) { var target = replace ? - $("
", { + $("
", { class: "recently-played" }).css("display", "none") : $(".recently-played"); @@ -163,12 +168,12 @@ $().ready(function(){ } else { var start = serverDate(onnow.show_start); var end = serverDate(onnow.show_end); - $(".home-show").html("" + onnow.name + " with " + onnow.airname); + $(".home-show").html("" + onnow.name + " with " + onnow.airname); $(".home-title").html("On Now: " + localTime(start) + " - " + localTime(end) + " " + $("#tz").val() + ""); if(onnow.id == 0) { $(".home-currenttrack").html(" "); } else { - $(".home-currenttrack").html(onnow.track_artist + " – " + onnow.track_title + " (" + onnow.track_album + ")"); + $(".home-currenttrack").html(onnow.track_artist + " – " + onnow.track_title + " (" + onnow.track_album + ")"); var time = $("#time").val(); var nowPlaying = $(".recently-played"); diff --git a/js/search.findit.js b/js/search.findit.js index bbd5f508..6263814c 100644 --- a/js/search.findit.js +++ b/js/search.findit.js @@ -494,7 +494,7 @@ function search(type, url, size, offset) { sync.Timer = null; } sync.Timer = setTimeout(onSearchNow, 500); - }).keypress(function(e) { + }).on('keypress', function(e) { return e.keyCode != 13; }).on('cut paste', function() { // run on next tick, as pasted data is not yet in the field diff --git a/js/zklistbox.js b/js/zklistbox.js index ddb526a6..66fed690 100644 --- a/js/zklistbox.js +++ b/js/zklistbox.js @@ -60,7 +60,7 @@ var up = false; switch(e.originalEvent.keyCode) { case 13: // enter - $(this).closest("form").submit(); + $(this).closest('form').trigger('submit'); e.preventDefault(); return; case 38: // up @@ -104,7 +104,7 @@ if(name) $('input[name=' + name + ']').val(jqthis.data('value')); }).on('dblclick', function() { - $(this).closest('form').submit(); + $(this).closest('form').trigger('submit'); }).first().trigger('mousedown'); return this; diff --git a/ui/AddManager.php b/ui/AddManager.php index dc33ad71..ecb2d2c6 100644 --- a/ui/AddManager.php +++ b/ui/AddManager.php @@ -285,7 +285,7 @@ public function panelCats($validate) { if($validate) return true; ?> - +
getCreatedTimestamp(); $timeplayed = self::timestampToLocale($created); $reviewCell = $entry->getReviewed() ? "
" : ""; - $artistName = PlaylistEntry::swapNames($entry->getArtist()); + $artistName = $entry->getTag() ? PlaylistEntry::swapNames($entry->getArtist()) : $entry->getArtist(); $albumLink = $this->makeAlbumLink($entry, true); echo "" . $editCell . diff --git a/ui/Playlists.php b/ui/Playlists.php index 698a7a32..bc2b6cfd 100644 --- a/ui/Playlists.php +++ b/ui/Playlists.php @@ -804,7 +804,8 @@ public function emitImportList() { $line[3] = ""; } else { // update artist and album from tag - $line[0] = $albumrec[0]["artist"]; + if(!$albumrec[0]["iscoll"]) + $line[0] = $albumrec[0]["artist"]; $line[2] = $albumrec[0]["album"]; // update label name diff --git a/ui/Reviews.php b/ui/Reviews.php index cf94d6bf..18405866 100644 --- a/ui/Reviews.php +++ b/ui/Reviews.php @@ -72,9 +72,10 @@ public function viewRecentDispatch() { public function emitViewDJMain() { $viewAll = $this->subaction == "viewDJAll"; + $isAuthorized = $this->session->isAuth('u'); // Run the query - $records = Engine::api(IReview::class)->getActiveReviewers($viewAll); + $records = Engine::api(IReview::class)->getActiveReviewers($viewAll, $isAuthorized); $dj = []; while($records && ($row = $records->fetch())) { $row["sort"] = preg_match("/^the /i", $row[1])?substr($row[1], 4):$row[1]; diff --git a/ui/Search.php b/ui/Search.php index 44601116..da69d025 100644 --- a/ui/Search.php +++ b/ui/Search.php @@ -34,6 +34,8 @@ use ZK\UI\UICommon as UI; class Search extends MenuItem { + const HASHTAG_PALETTE_SIZE = 5; // css colours palette-0..palette-(n-1) + private static $legacySearchActions = [ [ "", "searchForm" ], [ "byAlbumKey", "searchByAlbumKey" ], @@ -127,6 +129,18 @@ public function searchByAlbumKey($key = null) { $reviews = Engine::api(IReview::class)->getReviews($tag, 1, "", $loggedIn); $this->addVar("reviews", $reviews); + // hashtags + $hashtags = array_reduce(array_reverse($reviews), function($carry, $review) { + return preg_match_all('/#\pL\w*/u', $review['review'], $matches) ? + array_merge($carry, $matches[0]) : $carry; + }, []); + $normalized = array_unique(array_map('strtolower', $hashtags)); + $hashtags = array_intersect_key($hashtags, $normalized); + $index = array_map(function($tag) { + return hexdec(hash('crc32', $tag)) % self::HASHTAG_PALETTE_SIZE; + }, $normalized); + $this->addVar("hashtags", array_combine($hashtags, $index)); + // tracks $tracks = $libraryApi->search($albums[0]['iscoll'] ? ILibrary::COLL_KEY : ILibrary::TRACK_KEY, 0, 200, $tag); diff --git a/ui/UICommon.php b/ui/UICommon.php index 63869314..15fb941e 100644 --- a/ui/UICommon.php +++ b/ui/UICommon.php @@ -107,6 +107,42 @@ class UICommon { /*"\u{201c}"*/ "\xe2\x80\x9c"=>'"', /*"\u{201d}"*/ "\xe2\x80\x9d"=>'"', ]; + private static $cyrillicToLatin = [ + "А" => "A", "а" => "a", "Б" => "B", "б" => "b", + "В" => "V", "в" => "v", "Г" => "G", "г" => "g", + "Д" => "D", "д" => "d", "Е" => "E", "е" => "e", + "Ё" => "Ë", "ё" => "ë", "Ж" => "Zh", "ж" => "zh", + "З" => "Z", "з" => "z", "И" => "I", "и" => "i", + "Й" => "J", "й" => "j", "К" => "K", "к" => "k", + "Л" => "L", "л" => "l", "М" => "M", "м" => "m", + "Н" => "N", "н" => "n", "О" => "O", "о" => "o", + "П" => "P", "п" => "p", "Р" => "R", "р" => "r", + "С" => "S", "с" => "s", "Т" => "T","т" => "t", + "У" => "U", "у" => "u", "Ф" => "F", "ф" => "f", + "Х" => "Kh", "х" => "kh", "Ц" => "Ts", "ц" => "ts", + "Ч" => "Ch", "ч" => "ch", "Ш" => "Sh","ш" => "sh", + "Щ" => "Shch", "щ" => "shch", "Ъ" => "\"\"", "ъ" => "\"", + "Ы" => "Y", "ы" => "y", "Ь" => "''", "ь" => "'", + "Э" => "È", "э" => "è", "Ю" => "Yu", "ю" => "yu", + "Я" => "Ya", "я" => "ya" + ]; + + private static $greekToLatin = [ + "Α" => "A", "α" => "a", "Β" => "V", "β" => "v", + "Γ" => "G", "γ" => "g", "γγ" => "ng", "γκ" => "ng", + "γξ" => "nx", "γχ" => "nch", "Δ" => "D", "δ" => "d", + "Ε" => "E", "ε" => "e", "Ζ" => "Z", "ζ" => "z", + "Η" => "H", "η" => "h", "Θ" => "Th", "θ" => "th", + "Ι" => "I", "ι" => "i", "Κ" => "K", "κ" => "k", + "Λ" => "L", "λ" => "l", "Μ" => "M", "μ" => "m", + "Ν" => "N", "ν" => "n", "Ξ" => "X", "ξ" => "x", + "Ο" => "O", "ο" => "o", "Π" => "P", "π" => "p", + "Ρ" => "R", "ρ" => "r", "Σ" => "S", "σ" => "s", "ς" => "s", + "Τ" => "T", "τ" => "t", "Υ" => "Y", "υ" => "y", + "Φ" => "F", "φ" => "f", "Χ" => "Ch", "χ" => "ch", + "Ψ" => "Ps", "ψ" => "ps", "Ω" => "w", "ω" => "w" + ]; + private static $singletons = []; protected static function getSingleton(string $name, \Closure $factory) { @@ -191,12 +227,39 @@ public static function HTMLify($arg, $size, $noTables=0) { return htmlentities($arg, ENT_QUOTES, 'UTF-8'); } + private static function transliterate($pair, $string) { + if(extension_loaded('intl')) + return transliterator_transliterate($pair, $string); + + switch($pair) { + case "Greek-Latin/BGN": + $matrix = self::$greekToLatin; + break; + case "Russian-Latin/BGN": + $matrix = self::$cyrillicToLatin; + break; + default: + error_log("unknown transliteration: $pair"); + $matrix = null; + break; + } + + return $matrix ? strtr($string, $matrix) : $string; + } + public static function deLatin1ify($string, $charset=UICommon::CHARSET_ASCII) { // input is already UTF-8 if($charset == UICommon::CHARSET_UTF8) return $string; + // cyrillic and greek to latin1 + if(preg_match("/[\u{0370}-\u{03ff}]/u", $string)) + $string = self::transliterate('Greek-Latin/BGN', $string); + + if(preg_match("/[\u{0400}-\u{045f}]/u", $string)) + $string = self::transliterate('Russian-Latin/BGN', $string); + // flatten latin extended to latin1 $string = strtr($string, self::$latinExtendedA); @@ -295,7 +358,7 @@ public static function setFocus($control = "") { if($control) { echo "\n"; } } diff --git a/ui/templates/default/album/info.html b/ui/templates/default/album/info.html index ab8ad5cc..d810f3f4 100644 --- a/ui/templates/default/album/info.html +++ b/ui/templates/default/album/info.html @@ -15,57 +15,70 @@ {% endif %} -
- - - - - -
Album:{{ album.album }} Collection: +
+ + + + + + - - - - + + + + + + + + +
Album:{{ album.album }} Collection: {%- set showMissing = 'missing' -%} {%- if album.location == 'G' -%} - Deep Storage {{ album.bin }} + Deep Storage {{ album.bin }} {%- elseif album.location == 'M' -%} - Missing + Missing {%- set showMissing = 'found' -%} {%- elseif album.location == 'C' -%} - A-File + A-File {%- elseif album.location == 'E' -%} - Review Shelf + Review Shelf {%- elseif album.location == 'F' -%} - Out for Review + Out for Review {%- elseif album.location == 'U' -%} - Deaccessioned + Deaccessioned {%- set showMissing = false -%} {%- elseif album.medium == 'D' -%} - Digital + Digital {%- set showMissing = false -%} {%- else -%} - {{ GENRES[album.category] }} {{ album.medium != 'C' ? MEDIA[album.medium] }} + {{ GENRES[album.category] }} {{ album.medium != 'C' ? MEDIA[album.medium] }} {%- endif -%} - -{% if app.session.isAuth('u') and showMissing %} - [report {{ showMissing }}...] -{% endif -%} -
Artist: -{% if album.iscoll %} -{{ artist }} -{% else %} -{{ artist }} -{% endif %} - Added:{{ album.created | date('M Y') }}
Label: -{% if album.pubkey %} -{{ album.name }} -{% else %} -(Unknown) + +{%- if app.session.isAuth('u') and showMissing -%} + [report {{ showMissing }}...] +{%- endif -%} +
Artist: +{%- if album.iscoll -%} + {{ artist }} +{%- else -%} + {{ artist }} +{%- endif -%} +  Added:{{ album.created | date('M Y') }}
Label: +{%- if album.pubkey -%} + {{ album.name }} +{%- else -%} + (Unknown) +{%- endif -%} +   +{%- if app.session.isAuth('u') -%} + Write a review of this album +{%- endif -%} +
+{% if hashtags | length %} +
+{% for tag, index in hashtags %} + {{ tag }} +{% endfor %} +
{% endif %} -
  -{% if app.session.isAuth('u') %} -Write a review of this album -{% endif ~%} -
+

diff --git a/ui/templates/default/currents/adds.html b/ui/templates/default/currents/adds.html index 03d8f1d3..d72fb0eb 100644 --- a/ui/templates/default/currents/adds.html +++ b/ui/templates/default/currents/adds.html @@ -40,7 +40,7 @@ form.find("input[name=subaction]").val("addsemail"); } else form.find("input[name=date]").val($("#add-manager select[name=date]").val()); - form.submit(); + form.trigger('submit'); }); }); // --> diff --git a/ui/templates/default/currents/albums.html b/ui/templates/default/currents/albums.html index a1103f1d..747f81dc 100644 --- a/ui/templates/default/currents/albums.html +++ b/ui/templates/default/currents/albums.html @@ -1,8 +1,10 @@ {%- macro catcodes(catmap, categories) %} - {%~ set cats = categories | split(',') %} - {%~ for cat in cats -%} - {{ catmap[cat - 1].code }} - {%- endfor %} + {%~ if categories | length %} + {%~ set cats = categories | split(',') %} + {%~ for cat in cats -%} + {{ catmap[cat - 1].code }} + {%- endfor %} + {%~ endif %} {% endmacro -%} {%- macro medium(type) %} @@ -78,7 +80,7 @@ $(".nav.del").on('click', function() { if(confirm("Delete this album from the add?")) { $('#add-delete input[name=id]').val($(this).data('id')); - $('#add-delete').submit(); + $('#add-delete').trigger('submit'); } return false; }); diff --git a/ui/templates/default/onnow.html b/ui/templates/default/onnow.html index 8e3c8f6d..f3dbb6a4 100644 --- a/ui/templates/default/onnow.html +++ b/ui/templates/default/onnow.html @@ -20,6 +20,7 @@
+
{% else %} diff --git a/ui/templates/default/review.edit.html b/ui/templates/default/review.edit.html index d3dd3a61..98302c3d 100644 --- a/ui/templates/default/review.edit.html +++ b/ui/templates/default/review.edit.html @@ -23,13 +23,13 @@

Review Album:  {{ album.artist }} / {{ album

{% if id %}
- - + +
{% else %}
- +
{% endif %} @@ -67,30 +67,28 @@

Review Album:  {{ album.artist }} / {{ album return String(s).replace(/\'/g, '\\\''); } - $("form.review").on('submit', function(e) { - var id = document.activeElement.id; - $("input[name=button]").val(id); - + $(".edit-submit").on('click', function(e) { var airname = $("#airname").val().trim(); if(airname.length == 0 || $("#airnames option[value='" + escQuote(airname) + "' i]").length == 0 && !confirm('Create new airname "' + airname + '"?')) { $("#airname").val('').trigger('focus'); - e.preventDefault(); + return; } + + var id = e.target.id; + $("input[name=button]").val(id).closest('form').trigger('submit'); }); $("#edit-delete").on('click', function(e) { - if(!confirm("Delete the review?")) - e.preventDefault(); - else if(!$("#airname").val().trim().length) - $("#airname").val('{{ airname ?: self | e("js") }}'); + if(confirm("Delete the review?")) + $("input[name=button]").val('edit-delete').closest('form').trigger('submit'); }); $("#edit-cancel").on('click', function() { location.href = "?action=search&s=byAlbumKey&n=" + $("input[name=tag]").val(); }); - $("*[name=review]").focus(); + $("*[name=review]").trigger('focus'); }); // --> diff --git a/ui/templates/kzsu/contact.html b/ui/templates/kzsu/contact.html index cee375fa..59b25556 100644 --- a/ui/templates/kzsu/contact.html +++ b/ui/templates/kzsu/contact.html @@ -44,7 +44,7 @@

Contact KZSU Music

Office Hours: tba

Country/Bluegrass Director: Joseph Hnilo
Office Hours: Sunday noon-4

- RPM/Electronica Director: Johnathan Martin
+ Electronic Director: Johnathan Martin
Office Hours: tba

Reggae Director: Margy Kahn
Office Hours: tba

diff --git a/zk b/zk index ae7df505..bc138a00 100755 --- a/zk +++ b/zk @@ -37,7 +37,7 @@ $_REQUEST = []; foreach($argv as $arg) { $tuple = explode('=', $arg); if(count($tuple) == 2) - $_REQUEST[$tuple[0]] = $tuple[1]; + $_REQUEST[$tuple[0]] = urldecode($tuple[1]); } /**