diff --git a/.github/workflows/phpTestLinux.yml b/.github/workflows/phpTestLinux.yml new file mode 100644 index 0000000..c0c6bdc --- /dev/null +++ b/.github/workflows/phpTestLinux.yml @@ -0,0 +1,52 @@ +name: PHP Tests on Linux + +on: [push, pull_request] + +jobs: + testLinux: + name: PHP ${{ matrix.php-versions }} DokuWiki ${{ matrix.dokuwiki-branch }} + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository + + strategy: + matrix: + php-versions: ['7.2', '7.3', '7.4', '8.0'] + dokuwiki-branch: [ 'master', 'stable'] + exclude: + - dokuwiki-branch: 'stable' + php-versions: '8.0' + fail-fast: false + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: mbstring, intl, PDO, pdo_sqlite, bz2 + + - name: Setup problem matchers + run: | + echo ::add-matcher::${{ runner.tool_cache }}/php.json + echo ::add-matcher::${{ runner.tool_cache }}/phpunit.json + + - name: Download DokuWiki Test-setup + run: wget https://raw.github.com/splitbrain/dokuwiki-travis/master/travis.sh + + - name: Run DokuWiki Test-setup + env: + CI_SERVER: 1 + DOKUWIKI: ${{ matrix.dokuwiki-branch }} + run: sh travis.sh + + - name: Setup PHPUnit + run: | + php _test/fetchphpunit.php + cd _test + + - name: Run PHPUnit + run: | + cd _test + php phpunit.phar --verbose --stderr --group plugin_gallery diff --git a/_test/FeedGalleryTest.php b/_test/FeedGalleryTest.php new file mode 100644 index 0000000..ccf265a --- /dev/null +++ b/_test/FeedGalleryTest.php @@ -0,0 +1,28 @@ +getImages(); + $this->assertIsArray($images); + $this->assertCount(3, $images); + } +} diff --git a/_test/GeneralTest.php b/_test/GeneralTest.php new file mode 100644 index 0000000..638dd10 --- /dev/null +++ b/_test/GeneralTest.php @@ -0,0 +1,86 @@ +assertFileExists($file); + + $info = confToHash($file); + + $this->assertArrayHasKey('base', $info); + $this->assertArrayHasKey('author', $info); + $this->assertArrayHasKey('email', $info); + $this->assertArrayHasKey('date', $info); + $this->assertArrayHasKey('name', $info); + $this->assertArrayHasKey('desc', $info); + $this->assertArrayHasKey('url', $info); + + $this->assertEquals('gallery', $info['base']); + $this->assertRegExp('/^https?:\/\//', $info['url']); + $this->assertTrue(mail_isvalid($info['email'])); + $this->assertRegExp('/^\d\d\d\d-\d\d-\d\d$/', $info['date']); + $this->assertTrue(false !== strtotime($info['date'])); + } + + /** + * Test to ensure that every conf['...'] entry in conf/default.php has a corresponding meta['...'] entry in + * conf/metadata.php. + */ + public function testPluginConf(): void + { + $conf_file = __DIR__ . '/../conf/default.php'; + $meta_file = __DIR__ . '/../conf/metadata.php'; + + if (!file_exists($conf_file) && !file_exists($meta_file)) { + self::markTestSkipped('No config files exist -> skipping test'); + } + + if (file_exists($conf_file)) { + include($conf_file); + } + if (file_exists($meta_file)) { + include($meta_file); + } + + $this->assertEquals( + gettype($conf), + gettype($meta), + 'Both ' . DOKU_PLUGIN . 'gallery/conf/default.php and ' . DOKU_PLUGIN . 'gallery/conf/metadata.php have to exist and contain the same keys.' + ); + + if ($conf !== null && $meta !== null) { + foreach ($conf as $key => $value) { + $this->assertArrayHasKey( + $key, + $meta, + 'Key $meta[\'' . $key . '\'] missing in ' . DOKU_PLUGIN . 'gallery/conf/metadata.php' + ); + } + + foreach ($meta as $key => $value) { + $this->assertArrayHasKey( + $key, + $conf, + 'Key $conf[\'' . $key . '\'] missing in ' . DOKU_PLUGIN . 'gallery/conf/default.php' + ); + } + } + + } +} diff --git a/_test/NamespaceGalleryTest.php b/_test/NamespaceGalleryTest.php new file mode 100644 index 0000000..e844e71 --- /dev/null +++ b/_test/NamespaceGalleryTest.php @@ -0,0 +1,43 @@ +getImages(); + $this->assertIsArray($images); + $this->assertCount(3, $images); + } +} diff --git a/_test/data/media/gallery/img_0153.jpg b/_test/data/media/gallery/img_0153.jpg new file mode 100644 index 0000000..3ce67b9 Binary files /dev/null and b/_test/data/media/gallery/img_0153.jpg differ diff --git a/_test/data/media/gallery/img_0373.jpg b/_test/data/media/gallery/img_0373.jpg new file mode 100644 index 0000000..3f2b707 Binary files /dev/null and b/_test/data/media/gallery/img_0373.jpg differ diff --git a/_test/data/media/gallery/img_1378.jpg b/_test/data/media/gallery/img_1378.jpg new file mode 100644 index 0000000..30734e7 Binary files /dev/null and b/_test/data/media/gallery/img_1378.jpg differ diff --git a/classes/AbstractGallery.php b/classes/AbstractGallery.php new file mode 100644 index 0000000..dfd8354 --- /dev/null +++ b/classes/AbstractGallery.php @@ -0,0 +1,84 @@ +options = $options; + } + + /** + * Simple heuristic if something is an image + * + * @param string $src + * @return bool + */ + public function hasImageExtension($src) + { + return (bool)preg_match(Image::IMG_REGEX, $src); + } + + /** + * Get the images of this gallery + * + * The result will be sorted, reversed and limited according to the options + * + * @return Image[] + */ + public function getImages() + { + $images = $this->images; // create a copy of the array + + switch ($this->options->sort) { + case Options::SORT_FILE: + usort($images, function ($a, $b) { + return strcmp($a->getFilename(), $b->getFilename()); + }); + break; + case Options::SORT_CTIME: + usort($images, function ($a, $b) { + return $a->getCreated() - $b->getCreated(); + }); + break; + case Options::SORT_MTIME: + usort($images, function ($a, $b) { + return $a->getModified() - $b->getModified(); + }); + break; + case Options::SORT_TITLE: + usort($images, function ($a, $b) { + return strcmp($a->getTitle(), $b->getTitle()); + }); + break; + case Options::SORT_RANDOM: + shuffle($images); + break; + } + if ($this->options->reverse) { + $images = array_reverse($images); + } + if ($this->options->offset) { + $images = array_slice($images, $this->options->offset); + } + if ($this->options->limit) { + $images = array_slice($images, 0, $this->options->limit); + } + + return $images; + } +} diff --git a/classes/FeedGallery.php b/classes/FeedGallery.php new file mode 100644 index 0000000..361eb73 --- /dev/null +++ b/classes/FeedGallery.php @@ -0,0 +1,90 @@ +initBaseUrl($url); + $this->parseFeed($url); + } + + /** + * Parses the given feed and adds all images to the gallery + * + * @param string $url + * @return void + * @throws \Exception + */ + protected function parseFeed($url) { + $feed = new FeedParser(); + $feed->set_feed_url($url); + $ok = $feed->init(); + if (!$ok) throw new \Exception($feed->error()); + + foreach ($feed->get_items() as $item) { + $enclosure = $item->get_enclosure(); + if (!$enclosure) continue; + + // skip non-image enclosures + if ($enclosure->get_type() && substr($enclosure->get_type(), 0, 5) != 'image') { + continue; + } elseif (!$this->hasImageExtension($enclosure->get_link())) { + continue; + } + + $enclosureLink = $this->makeAbsoluteUrl($enclosure->get_link()); + $detailLink = $this->makeAbsoluteUrl($item->get_link()); + + $image = new Image($enclosureLink); + $image->setDetaillink($detailLink); + $image->setTitle(htmlspecialchars_decode($enclosure->get_title() ?? '', ENT_COMPAT)); + $image->setDescription(strip_tags(htmlspecialchars_decode($enclosure->get_description() ?? '', ENT_COMPAT))); + $image->setCreated($item->get_date('U')); + $image->setModified($item->get_date('U')); + $image->setWidth($enclosure->get_width()); + $image->setHeight($enclosure->get_height()); + + $this->images[] = $image; + } + } + + /** + * Make the given URL absolute using feed's URL as base + * + * @param string $url + * @return string + */ + protected function makeAbsoluteUrl($url) + { + + if (!preg_match('/^https?:\/\//i', $url)) { + if ($url[0] == '/') { + $url = $this->feedHost . $url; + } else { + $url = $this->feedHost . $this->feedPath . $url; + } + } + return $url; + } + + /** + * Initialize base url to use for broken feeds with non-absolute links + * @param string $url The feed URL + * @return void + */ + protected function initBaseUrl($url) + { + $main = parse_url($url); + $this->feedHost = $main['scheme'] . '://' . $main['host'] . (!empty($main['port']) ? ':' . $main['port'] : ''); + $this->feedPath = dirname($main['path']) . '/'; + } +} diff --git a/classes/Formatter.php b/classes/Formatter.php new file mode 100644 index 0000000..e4fe2ed --- /dev/null +++ b/classes/Formatter.php @@ -0,0 +1,225 @@ +options = $options; + } + + // region Main Formatters + + /** + * @param AbstractGallery $gallery + * @return string + */ + public function format(AbstractGallery $gallery) + { + $html = ''; + return $html; + } + + /** + * Create an array of pages for the given images + * + * @param Image[] $images + * @return Image[][] + */ + protected function paginate($images) + { + if ($this->options->paginate) { + $pages = array_chunk($images, $this->options->paginate); + } else { + $pages = [$images]; + } + + return $pages; + } + + /** + * Format the given images into a gallery page + * + * @param Image[] $images + * @param int $page The page number + * @return string + */ + protected function formatPage($images, int $page) + { + $html = ''; + return $html; + } + + protected function formatImage(Image $image) + { + global $ID; + + // thumbnail image properties + list($w, $h) = $this->getThumbnailSize($image); + $img = []; + $img['width'] = $w; + $img['height'] = $h; + $img['src'] = ml($image->getSrc(), ['w' => $w, 'h' => $h], true, '&'); + $img['alt'] = $image->getFilename(); + $img['loading'] = 'lazy'; + + // link properties + $a = []; + $a['href'] = $this->getDetailLink($image); + $a['title'] = $image->getTitle(); + $a['data-caption'] = $image->getDescription(); + if ($this->options->lightbox) { + $a['class'] = "lightbox JSnocheck"; + $a['rel'] = 'lightbox[gal-' . substr(md5($ID), 4) . ']'; //unique ID for the gallery + $a['data-url'] = $this->getLightboxLink($image); + } + + // figure properties + $fig = []; + $fig['class'] = 'gallery-image'; + $fig['style'] = 'width: ' . $w . 'px;'; + + # differentiate between the URL for the lightbox and the URL for details + # using a data-attribute + # needs slight adjustment in the swipebox script + # fall back to href when no data-attribute is set + # lightbox url should have width/height limit -> adjust from old defaults to 1600x1200? + # use detail URLs for thumbnail, title and filename + # when direct is set it should link to full size image + + + $html = '
'; + $html .= ''; + $html .= ''; + $html .= ''; + + if ($this->options->showtitle || $this->options->showname) { + $html .= '
'; + if ($this->options->showname) { + $a = [ + 'href' => $this->getDetailLink($image), + 'class' => 'gallery-filename', + 'title' => $image->getFilename(), + ]; + $html .= '' . hsc($image->getFilename()) . ''; + } + if ($this->options->showtitle) { + $a = [ + 'href' => $this->getDetailLink($image), + 'class' => 'gallery-title', + 'title' => $image->getTitle(), + ]; + $html .= '' . hsc($image->getTitle()) . ''; + } + $html .= '
'; + } + + $html .= '
'; + return $html; + } + + // endregion + + // region Utilities + + protected function getDetailLink(Image $image) + { + if ($image->getDetaillink()) { + // external image + return $image->getDetaillink(); + } else { + return ml($image->getSrc(), '', $this->options->direct, '&'); + } + } + + /** + * Get the direct link to the image but limit it to a certain size + * + * @param Image $image + * @return string + */ + protected function getLightboxLink(Image $image) + { + // use original image if no size is available + if (!$image->getWidth() || !$image->getHeight()) { + return ml($image->getSrc(), '', true, '&'); + } + + // fit into bounding box + list($width, $height) = $this->fitBoundingBox( + $image->getWidth(), + $image->getHeight(), + $this->options->lightboxWidth, + $this->options->lightboxHeight + ); + + // no upscaling + if ($width > $image->getWidth() || $height > $image->getHeight()) { + return ml($image->getSrc(), '', true, '&'); + } + + return ml($image->getSrc(), ['w' => $width, 'h' => $height], true, '&'); + } + + /** Calculate the thumbnail size */ + protected function getThumbnailSize(Image $image) + { + $crop = $this->options->crop; + if (!$image->getWidth() || !$image->getHeight()) { + $crop = true; + } + if (!$crop) { + list($thumbWidth, $thumbHeight) = $this->fitBoundingBox( + $image->getWidth(), + $image->getHeight(), + $this->options->thumbnailWidth, + $this->options->thumbnailHeight + ); + } else { + $thumbWidth = $this->options->thumbnailWidth; + $thumbHeight = $this->options->thumbnailHeight; + } + return [$thumbWidth, $thumbHeight]; + } + + + /** + * Calculate the size of a thumbnail to fit into a bounding box + * + * @param int $imgWidth + * @param int $imgHeight + * @param int $bBoxWidth + * @param int $bBoxHeight + * @return int[] + */ + protected function fitBoundingBox($imgWidth, $imgHeight, $bBoxWidth, $bBoxHeight) + { + $scale = min($bBoxWidth / $imgWidth, $bBoxHeight / $imgHeight); + + $width = round($imgWidth * $scale); + $height = round($imgHeight * $scale); + + return [$width, $height]; + } + + // endregion +} diff --git a/classes/Image.php b/classes/Image.php new file mode 100644 index 0000000..dd29916 --- /dev/null +++ b/classes/Image.php @@ -0,0 +1,191 @@ +src = $src; + if (preg_match('/^https:\/\//i', $src)) { + $this->isExternal = true; + $path = parse_url($src, PHP_URL_PATH); + $this->filename = basename($path); + } else { + $this->localfile = mediaFN($src); + if (!file_exists($this->localfile)) throw new \Exception('File not found: ' . $this->localfile); + $this->filename = basename($this->localfile); + $this->modified = filemtime($this->localfile); + + $jpegMeta = new \JpegMeta($this->localfile); + $this->title = $jpegMeta->getField('Simple.Title'); + $this->description = $jpegMeta->getField('Iptc.Caption'); + $this->created = $jpegMeta->getField('Date.EarliestTime'); + $this->width = $jpegMeta->getField('File.Width'); + $this->height = $jpegMeta->getField('File.Height'); + } + } + + public function isExternal() + { + return $this->isExternal; + } + + public function getSrc() + { + return $this->src; + } + + public function getFilename() + { + return $this->filename; + } + + public function setFilename(string $filename) + { + $this->filename = $filename; + } + + public function getLocalfile() + { + return $this->localfile; + } + + public function setLocalfile(string $localfile) + { + $this->localfile = $localfile; + } + + /** + * @return string + */ + public function getTitle() + { + if (empty($this->title) || $this->title == $this->filename) { + $title = str_replace('_', ' ', $this->filename); + $title = preg_replace(self::IMG_REGEX, '', $title); + $title = PhpString::ucwords($title); + return $title; + } + return $this->title; + } + + /** + * @param string $title + */ + public function setTitle($title) + { + $this->title = $title; + } + + /** + * @return string + */ + public function getDescription() + { + return trim(str_replace("\n", ' ', $this->description)); + } + + /** + * @param string $description + */ + public function setDescription($description) + { + $this->description = $description; + } + + /** + * @return int + */ + public function getWidth() + { + return $this->width; + } + + /** + * @param int $width + */ + public function setWidth($width) + { + $this->width = $width; + } + + /** + * @return int + */ + public function getHeight() + { + return $this->height; + } + + /** + * @param int $height + */ + public function setHeight($height) + { + $this->height = $height; + } + + /** + * @return int + */ + public function getCreated() + { + return $this->created; + } + + /** + * @param int $created + */ + public function setCreated($created) + { + $this->created = $created; + } + + /** + * @return int + */ + public function getModified() + { + return $this->modified; + } + + /** + * @param int $modified + */ + public function setModified($modified) + { + $this->modified = $modified; + } + + public function getDetaillink() + { + return $this->detaillink; + } + + public function setDetaillink($detaillink) + { + $this->detaillink = $detaillink; + } +} diff --git a/classes/NamespaceGallery.php b/classes/NamespaceGallery.php new file mode 100644 index 0000000..de13606 --- /dev/null +++ b/classes/NamespaceGallery.php @@ -0,0 +1,73 @@ +searchNamespace($ns, $options->recursive, $options->filter); + } + + /** + * Find the images + * + * @param string $ns + * @param bool $recursive search recursively? + * @param string $filter regular expresion to filter image IDs against (without namespace) + * @throws \Exception + */ + protected function searchNamespace($ns, $recursive, $filter) + { + global $conf; + + if (media_exists($ns) && !is_dir(mediaFN($ns))) { + // this is a single file, not a namespace + if ($this->hasImageExtension($ns)) { + $this->images[] = new Image($ns); + } + } else { + search( + $this->images, + $conf['mediadir'], + [$this, 'searchCallback'], + [ + 'depth' => $recursive ? 0 : 1, + 'filter' => $filter + ], + utf8_encodeFN(str_replace(':', '/', $ns)) + ); + } + } + + /** + * Callback for search() to find images + */ + public function searchCallback(&$data, $base, $file, $type, $lvl, $opts) + { + if ($type == 'd') { + if (empty($opts['depth'])) return true; // recurse forever + $depth = substr_count($file, '/'); // we can't use level because we start deeper + if ($depth >= $opts['depth']) return false; // depth reached + return true; + } + + $id = pathID($file, true); + + // skip non-valid files + if ($id != cleanID($id)) return false; + //check ACL for namespace (we have no ACL for mediafiles) + if (auth_quickaclcheck(getNS($id) . ':*') < AUTH_READ) return false; + // skip non-images + if (!$this->hasImageExtension($file)) return false; + // skip filtered images + if ($opts['filter'] && !preg_match($opts['filter'], noNS($id))) return false; + + // still here, add to result + $data[] = new Image($id); + return false; + } +} diff --git a/classes/Options.php b/classes/Options.php new file mode 100644 index 0000000..7e5d117 --- /dev/null +++ b/classes/Options.php @@ -0,0 +1,109 @@ +thumbnailWidth = $plugin->getConf('thumbnail_width'); + $this->thumbnailHeight = $plugin->getConf('thumbnail_height'); + $this->lightboxWidth = $plugin->getConf('image_width'); + $this->lightboxHeight = $plugin->getConf('image_height'); + $this->columns = $plugin->getConf('cols'); + $this->sort = $plugin->getConf('sort'); + $this->parseParameters($plugin->getConf('options')); + } + + /** + * Simple option strings parser + * + * @param string $params + * @return void + */ + public function parseParameters($params) + { + $params = preg_replace('/[,&?]+/', ' ', $params); + $params = explode(' ', $params); + foreach ($params as $param) { + if ($param === '') continue; + if ($param == 'titlesort') { + $this->sort = self::SORT_TITLE; + } elseif ($param == 'datesort') { + $this->sort = self::SORT_CTIME; + } elseif ($param == 'modsort') { + $this->sort = self::SORT_MTIME; + } elseif (preg_match('/^=(\d+)$/', $param, $match)) { + $this->limit = (int)$match[1]; + } elseif (preg_match('/^\+(\d+)$/', $param, $match)) { + $this->offset = (int)$match[1]; + } elseif (is_numeric($param)) { + $this->columns = (int)$param; + } elseif (preg_match('/^~(\d+)$/', $param, $match)) { + $this->paginate = (int)$match[1]; + } elseif (preg_match('/^(\d+)([xX])(\d+)$/', $param, $match)) { + if ($match[2] == 'X') { + $this->lightboxWidth = (int)$match[1]; + $this->lightboxHeight = (int)$match[3]; + } else { + $this->thumbnailWidth = (int)$match[1]; + $this->thumbnailHeight = (int)$match[3]; + } + } elseif (strpos($param, '*') !== false) { + $param = preg_quote($param, '/'); + $param = '/^' . str_replace('\\*', '.*?', $param) . '$/'; + $this->filter = $param; + } else { + if (substr($param, 0, 2) == 'no') { + $opt = substr($param, 2); + $set = false; + } else { + $opt = $param; + $set = true; + } + if (!property_exists($this, $opt) || !is_bool($this->$opt)) continue; + $this->$opt = $set; + } + } + } + +} diff --git a/conf/default.php b/conf/default.php index 1ea08ff..01a0d0f 100644 --- a/conf/default.php +++ b/conf/default.php @@ -7,8 +7,8 @@ $conf['thumbnail_width'] = 120; $conf['thumbnail_height'] = 120; -$conf['image_width'] = 800; -$conf['image_height'] = 600; +$conf['image_width'] = 1600; +$conf['image_height'] = 1200; $conf['cols'] = 5; $conf['sort'] = 'file'; diff --git a/conf/metadata.php b/conf/metadata.php index f43fe28..d544037 100644 --- a/conf/metadata.php +++ b/conf/metadata.php @@ -5,12 +5,23 @@ * @author Dmitry Baikov */ +use dokuwiki\plugin\gallery\classes\Options; + $meta['thumbnail_width'] = array('numeric'); $meta['thumbnail_height'] = array('numeric'); $meta['image_width'] = array('numeric'); $meta['image_height'] = array('numeric'); $meta['cols'] = array('numeric'); -$meta['sort'] = array('multichoice', '_choices' => array('file', 'mod', 'date', 'title')); -$meta['options'] = array('multicheckbox', '_choices' => array('cache', 'crop', 'direct', 'lightbox', 'random', 'reverse', 'showname', 'showtitle')); +$meta['sort'] = array( + 'multichoice', + '_choices' => array( + Options::SORT_FILE, + Options::SORT_CTIME, + Options::SORT_MTIME, + Options::SORT_TITLE, + Options::SORT_RANDOM, + ) +); +$meta['options'] = array('multicheckbox', '_choices' => array('cache', 'crop', 'direct', 'lightbox', 'reverse', 'showname', 'showtitle')); diff --git a/syntax.php b/syntax.php index 86677bf..f87678b 100644 --- a/syntax.php +++ b/syntax.php @@ -1,6 +1,10 @@ galleryID = substr(md5($match), 0, 4); // alignment - $data['align'] = 0; - if (substr($match, 0, 1) == ' ') $data['align'] += 1; - if (substr($match, -1, 1) == ' ') $data['align'] += 2; + if (substr($match, 0, 1) == ' ') $options->align += Options::ALIGN_LEFT; + if (substr($match, -1, 1) == ' ') $options->align += Options::ALIGN_RIGHT; - // extract params - list($ns, $params) = explode('?', $match, 2); - $ns = trim($ns); + // extract src and params + list($src, $params) = sexplode('?', $match, 2); + $src = trim($src); - // namespace (including resolving relatives) - if (!preg_match('/^https?:\/\//i', $ns)) { + // resolve relative namespace + if (!preg_match('/^https?:\/\//i', $src)) { $pageResolver = new PageResolver($ID); - $data['ns'] = $pageResolver->resolveId($ns); - } else { - $data['ns'] = $ns; + $src = $pageResolver->resolveId($src); } - $data = array_merge( - $data, - $this->getDataFromParams($this->getConf('options') . ',' . $params) - ); + // parse parameters + $options->parseParameters($params); - return $data; - } - - /** - * Extract options from the provided parameter string - * - * @param string $params - * - * @return array associative array of the options defined in the string - */ - public function getDataFromParams($params) - { - // set the defaults - $data = []; - $data['tw'] = $this->getConf('thumbnail_width'); - $data['th'] = $this->getConf('thumbnail_height'); - $data['iw'] = $this->getConf('image_width'); - $data['ih'] = $this->getConf('image_height'); - $data['cols'] = $this->getConf('cols'); - $data['filter'] = ''; - $data['lightbox'] = false; - $data['direct'] = false; - $data['showname'] = false; - $data['showtitle'] = false; - $data['reverse'] = false; - $data['random'] = false; - $data['cache'] = true; - $data['crop'] = false; - $data['recursive'] = true; - $data['sort'] = $this->getConf('sort'); - $data['limit'] = 0; - $data['offset'] = 0; - $data['paginate'] = 0; - - // parse additional options - $params = preg_replace('/[,&?]+/', ' ', $params); - $params = explode(' ', $params); - foreach ($params as $param) { - if ($param === '') continue; - if ($param == 'titlesort') { - $data['sort'] = 'title'; - } elseif ($param == 'datesort') { - $data['sort'] = 'date'; - } elseif ($param == 'modsort') { - $data['sort'] = 'mod'; - } elseif (preg_match('/^=(\d+)$/', $param, $match)) { - $data['limit'] = $match[1]; - } elseif (preg_match('/^\+(\d+)$/', $param, $match)) { - $data['offset'] = $match[1]; - } elseif (is_numeric($param)) { - $data['cols'] = (int)$param; - } elseif (preg_match('/^~(\d+)$/', $param, $match)) { - $data['paginate'] = $match[1]; - } elseif (preg_match('/^(\d+)([xX])(\d+)$/', $param, $match)) { - if ($match[2] == 'X') { - $data['iw'] = $match[1]; - $data['ih'] = $match[3]; - } else { - $data['tw'] = $match[1]; - $data['th'] = $match[3]; - } - } elseif (strpos($param, '*') !== false) { - $param = preg_quote($param, '/'); - $param = '/^' . str_replace('\\*', '.*?', $param) . '$/'; - $data['filter'] = $param; - } else { - if (substr($param, 0, 2) == 'no') { - $data[substr($param, 2)] = false; - } else { - $data[$param] = true; - } - } - } - - // implicit direct linking? - if ($data['lightbox']) $data['direct'] = true; - - return $data; + return [ + $src, $options + ]; } /** @inheritdoc */ public function render($mode, Doku_Renderer $R, $data) { global $ID; + + [$src, $options] = $data; + + if (preg_match('/^https?:\/\//i', $src)) { + $gallery = new FeedGallery($src, $options); + } else { + $gallery = new NamespaceGallery($src, $options); + } + + if ($mode == 'xhtml') { - $R->info['cache'] &= $data['cache']; - $R->doc .= $this->formatGallery($data); + $R->info['cache'] = $options->cache; + $formatter = new Formatter($options); + $R->doc .= $formatter->format($gallery); + + // FIXME next steps: + // * implement minimal standard renderer for all renderers (just inline thumbnails with links) + // * maybe implement PDF renderer separately from XHTML + // * adjust lightbox script + // * redo CSS + // * add more unit tests + + return true; } elseif ($mode == 'metadata') { - $rel = p_get_metadata($ID, 'relation', METADATA_RENDER_USING_CACHE); - $img = $rel['firstimage']; - if (empty($img)) { - $files = $this->findimages($data); - if (count($files)) $R->internalmedia($files[0]['id']); + // render the first image of the gallery, to ensure it will be used as first image if needed + $images = $gallery->getImages(); + if (count($images)) { + if ($images[0]->isExternal()) { + $R->externalmedia($images[0]->getSrc()); + } else { + $R->internalmedia($images[0]->getSrc()); + } } return true; } return false; } - - /** - * Loads images from a MediaRSS or ATOM feed - */ - protected function loadRSS($url) - { - $feed = new FeedParser(); - $feed->set_feed_url($url); - $feed->init(); - $files = array(); - - // base url to use for broken feeds with non-absolute links - $main = parse_url($url); - $host = $main['scheme'] . '://' . - $main['host'] . - (($main['port']) ? ':' . $main['port'] : ''); - $path = dirname($main['path']) . '/'; - - foreach ($feed->get_items() as $item) { - if ($enclosure = $item->get_enclosure()) { - // skip non-image enclosures - if ($enclosure->get_type()) { - if (substr($enclosure->get_type(), 0, 5) != 'image') continue; - } else { - if (!preg_match('/\.(jpe?g|png|gif)(\?|$)/i', - $enclosure->get_link())) continue; - } - - // non absolute links - $ilink = $enclosure->get_link(); - if (!preg_match('/^https?:\/\//i', $ilink)) { - if ($ilink[0] == '/') { - $ilink = $host . $ilink; - } else { - $ilink = $host . $path . $ilink; - } - } - $link = $item->link; - if (!preg_match('/^https?:\/\//i', $link)) { - if ($link[0] == '/') { - $link = $host . $link; - } else { - $link = $host . $path . $link; - } - } - - $files[] = array( - 'id' => $ilink, - 'isimg' => true, - 'file' => basename($ilink), - // decode to avoid later double encoding - 'title' => htmlspecialchars_decode($enclosure->get_title(), ENT_COMPAT), - 'desc' => strip_tags(htmlspecialchars_decode($enclosure->get_description(), ENT_COMPAT)), - 'width' => $enclosure->get_width(), - 'height' => $enclosure->get_height(), - 'mtime' => $item->get_date('U'), - 'ctime' => $item->get_date('U'), - 'detail' => $link, - ); - } - } - return $files; - } - - /** - * Gather all photos matching the given criteria - */ - protected function findimages(&$data) - { - global $conf; - $files = array(); - - // http URLs are supposed to be media RSS feeds - if (preg_match('/^https?:\/\//i', $data['ns'])) { - $files = $this->loadRSS($data['ns']); - $data['_single'] = false; - } else { - $dir = utf8_encodeFN(str_replace(':', '/', $data['ns'])); - // all possible images for the given namespace (or a single image) - if (is_file($conf['mediadir'] . '/' . $dir)) { - $files[] = array( - 'id' => $data['ns'], - 'isimg' => preg_match('/\.(jpe?g|gif|png)$/', $dir), - 'file' => basename($dir), - 'mtime' => filemtime($conf['mediadir'] . '/' . $dir), - 'meta' => new JpegMeta($conf['mediadir'] . '/' . $dir) - ); - $data['_single'] = true; - } else { - $depth = $data['recursive'] ? 0 : 1; - search($files, - $conf['mediadir'], - 'search_media', - array('depth' => $depth), - $dir); - $data['_single'] = false; - } - } - - // done, yet? - $len = count($files); - if (!$len) return $files; - if (isset($data['single']) && $data['single']) return $files; - - // filter images - for ($i = 0; $i < $len; $i++) { - if (!$files[$i]['isimg']) { - unset($files[$i]); // this is faster, because RE was done before - } elseif ($data['filter']) { - if (!preg_match($data['filter'], noNS($files[$i]['id']))) unset($files[$i]); - } - } - if ($len < 1) return $files; - - // random? - if ($data['random']) { - shuffle($files); - } else { - // sort? - if ($data['sort'] == 'date') { - usort($files, array($this, 'datesort')); - } elseif ($data['sort'] == 'mod') { - usort($files, array($this, 'modsort')); - } elseif ($data['sort'] == 'title') { - usort($files, array($this, 'titlesort')); - } - - // reverse? - if ($data['reverse']) $files = array_reverse($files); - } - - // limits and offsets? - if ($data['offset']) $files = array_slice($files, $data['offset']); - if ($data['limit']) $files = array_slice($files, 0, $data['limit']); - - return $files; - } - - /** - * usort callback to sort by file lastmodified time - */ - protected function modsort($a, $b) - { - if ($a['mtime'] < $b['mtime']) return -1; - if ($a['mtime'] > $b['mtime']) return 1; - return strcmp($a['file'], $b['file']); - } - - /** - * usort callback to sort by EXIF date - */ - protected function datesort($a, $b) - { - $da = $this->readMeta($a, 'cdate'); - $db = $this->readMeta($b, 'cdate'); - if ($da < $db) return -1; - if ($da > $db) return 1; - return strcmp($a['file'], $b['file']); - } - - /** - * usort callback to sort by EXIF title - */ - protected function titlesort($a, $b) - { - $ta = $this->readMeta($a, 'title'); - $tb = $this->readMeta($b, 'title'); - return strcmp($ta, $tb); - } - - - /** - * Does the gallery formatting - */ - protected function formatGallery($data) - { - $ret = ''; - - $files = $this->findimages($data); - - //anything found? - if (!count($files)) { - $ret .= '
' . $this->getLang('nothingfound') . '
'; - return $ret; - } - - // prepare alignment - $align = ''; - $xalign = ''; - if ($data['align'] == 1) { - $align = ' gallery_right'; - $xalign = ' align="right"'; - } - if ($data['align'] == 2) { - $align = ' gallery_left'; - $xalign = ' align="left"'; - } - if ($data['align'] == 3) { - $align = ' gallery_center'; - $xalign = ' align="center"'; - } - if (!$data['_single']) { - if (!$align) $align = ' gallery_center'; // center galleries on default - if (!$xalign) $xalign = ' align="center"'; - } - - $page = 0; - - // build gallery - if ($data['_single']) { - $ret .= $this->formatThumbnail($files[0], $data); - $ret .= $this->formatName($files[0], $data); - $ret .= $this->formatTitle($files[0], $data); - } elseif ($data['cols'] > 0) { // format as table - $close_pg = false; - - $i = 0; - foreach ($files as $img) { - - // new page? - if ($data['paginate'] && ($i % $data['paginate'] == 0)) { - $ret .= ''; - $close_pg = false; - $i = 0; // reset counter to ensure next page is properly started - } - - } // foreach - - if ($close_tr) { - // add remaining empty cells - while ($i % $data['cols']) { - $ret .= ''; - $i++; - } - $ret .= ''; - } - - if (!$data['paginate']) { - $ret .= ''; - } elseif ($close_pg) { - $ret .= ''; - $ret .= ''; - } - } else { // format as div sequence - $i = 0; - $close_pg = false; - foreach ($files as $img) { - - if ($data['paginate'] && ($i % $data['paginate'] == 0)) { - $ret .= ''; - $close_pg = false; - } - } - - if ($close_pg) $ret .= ''; - - $ret .= '
'; - } - - // pagination links - $pgret = ''; - if ($page) { - $pgret .= ''; - } - - return '
' . $pgret . $ret . '
'; - } - - /** - * Defines how a thumbnail should look like - */ - protected function formatThumbnail(&$img, $data) - { - global $ID; - - // calculate thumbnail size - if (!$data['crop']) { - $w = (int)$this->readMeta($img, 'width'); - $h = (int)$this->readMeta($img, 'height'); - if ($w && $h) { - $dim = array(); - if ($w > $data['tw'] || $h > $data['th']) { - $ratio = $this->calculateRatio($img, $data['tw'], $data['th']); - $w = floor($w * $ratio); - $h = floor($h * $ratio); - $dim = array('w' => $w, 'h' => $h); - } - } else { - $data['crop'] = true; // no size info -> always crop - } - } - if ($data['crop']) { - $w = $data['tw']; - $h = $data['th']; - $dim = array('w' => $w, 'h' => $h); - } - - //prepare img attributes - $i = array(); - $i['width'] = $w; - $i['height'] = $h; - $i['border'] = 0; - $i['alt'] = $this->readMeta($img, 'title'); - $i['class'] = 'tn'; - $iatt = buildAttributes($i); - $src = ml($img['id'], $dim); - - // prepare lightbox dimensions - $w_lightbox = (int)$this->readMeta($img, 'width'); - $h_lightbox = (int)$this->readMeta($img, 'height'); - $dim_lightbox = array(); - if ($w_lightbox > $data['iw'] || $h_lightbox > $data['ih']) { - $ratio = $this->calculateRatio($img, $data['iw'], $data['ih']); - $w_lightbox = floor($w_lightbox * $ratio); - $h_lightbox = floor($h_lightbox * $ratio); - $dim_lightbox = array('w' => $w_lightbox, 'h' => $h_lightbox); - } - - //prepare link attributes - $a = array(); - $a['title'] = $this->readMeta($img, 'title'); - $a['data-caption'] = trim(str_replace("\n", ' ', $this->readMeta($img, 'desc'))); - if (!$a['data-caption']) unset($a['data-caption']); - if ($data['lightbox']) { - $href = ml($img['id'], $dim_lightbox); - $a['class'] = "lightbox JSnocheck"; - $a['rel'] = 'lightbox[gal-' . substr(md5($ID), 4) . ']'; //unique ID for the gallery - } elseif ($img['detail'] && !$data['direct']) { - $href = $img['detail']; - } else { - $href = ml($img['id'], array('id' => $ID), $data['direct']); - } - $aatt = buildAttributes($a); - - // prepare output - $ret = ''; - $ret .= ''; - $ret .= ''; - $ret .= ''; - return $ret; - } - - - /** - * Defines how a filename + link should look - */ - protected function formatName($img, $data) - { - global $ID; - - if (!$data['showname']) { - return ''; - } - - //prepare link - $lnk = ml($img['id'], array('id' => $ID), false); - - // prepare output - $ret = ''; - $ret .= '
'; - $ret .= hsc($img['file']); - $ret .= ''; - return $ret; - } - - /** - * Defines how title + link should look - */ - protected function formatTitle($img, $data) - { - global $ID; - - if (!$data['showtitle']) { - return ''; - } - - //prepare link - $lnk = ml($img['id'], array('id' => $ID), false); - - // prepare output - $ret = ''; - $ret .= '
'; - $ret .= hsc($this->readMeta($img, 'title')); - $ret .= ''; - return $ret; - } - - /** - * Return the metadata of an item - * - * Automatically checks if a JPEGMeta object is available or if all data is - * supplied in array - */ - protected function readMeta(&$img, $opt) - { - if ($img['meta']) { - // map JPEGMeta calls to opt names - - switch ($opt) { - case 'title': - return $img['meta']->getField('Simple.Title'); - case 'desc': - return $img['meta']->getField('Iptc.Caption'); - case 'cdate': - return $img['meta']->getField('Date.EarliestTime'); - case 'width': - return $img['meta']->getField('File.Width'); - case 'height': - return $img['meta']->getField('File.Height'); - - - default: - return ''; - } - - } else { - // just return the array field - return $img[$opt]; - } - } - - /** - * Calculates the multiplier needed to resize the image to the given - * dimensions - * - * @author Andreas Gohr - */ - protected function calculateRatio(&$img, $maxwidth, $maxheight = 0) - { - if (!$maxheight) $maxheight = $maxwidth; - - $w = $this->readMeta($img, 'width'); - $h = $this->readMeta($img, 'height'); - - $ratio = 1; - if ($w >= $h) { - if ($w >= $maxwidth) { - $ratio = $maxwidth / $w; - } elseif ($h > $maxheight) { - $ratio = $maxheight / $h; - } - } else { - if ($h >= $maxheight) { - $ratio = $maxheight / $h; - } elseif ($w > $maxwidth) { - $ratio = $maxwidth / $w; - } - } - return $ratio; - } - }