WIP refactored backend

This splits up all the code in classes and cleans everything up. A very
few tests have been added. HTML has been simplified.

Next up: frontend refactoring
This commit is contained in:
Andreas Gohr
2023-08-22 16:41:19 +02:00
parent bbfb182ebe
commit fa496f88a8
16 changed files with 1046 additions and 615 deletions

52
.github/workflows/phpTestLinux.yml vendored Normal file
View File

@ -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

28
_test/FeedGalleryTest.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace dokuwiki\plugin\gallery\test;
use dokuwiki\plugin\gallery\classes\FeedGallery;
use dokuwiki\plugin\gallery\classes\Options;
use DokuWikiTest;
/**
* Media Feed tests for the gallery plugin
*
* @group plugin_gallery
* @group plugins
* @group internet
*/
class FeedGalleryTest extends DokuWikiTest
{
protected $pluginsEnabled = ['gallery'];
public function testGetImages()
{
$url = 'https://www.flickr.com/services/feeds/photoset.gne?nsid=22019303@N00&set=72177720310667219&lang=en-us&format=atom';
$gallery = new FeedGallery($url, new Options());
$images = $gallery->getImages();
$this->assertIsArray($images);
$this->assertCount(3, $images);
}
}

86
_test/GeneralTest.php Normal file
View File

@ -0,0 +1,86 @@
<?php
namespace dokuwiki\plugin\gallery\test;
use DokuWikiTest;
/**
* General tests for the gallery plugin
*
* @group plugin_gallery
* @group plugins
*/
class GeneralTest extends DokuWikiTest
{
/**
* Simple test to make sure the plugin.info.txt is in correct format
*/
public function testPluginInfo(): void
{
$file = __DIR__ . '/../plugin.info.txt';
$this->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'
);
}
}
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace dokuwiki\plugin\gallery\test;
use dokuwiki\plugin\gallery\classes\NamespaceGallery;
use dokuwiki\plugin\gallery\classes\Options;
use DokuWikiTest;
/**
* Namespace Gallery tests for the gallery plugin
*
* @group plugin_gallery
* @group plugins
*/
class NamespaceGalleryTest extends DokuWikiTest
{
protected $pluginsEnabled = ['gallery'];
/**
* Copy demo images to the media directory
*
* @inheritdoc
*/
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
global $conf;
\TestUtils::rcopy($conf['mediadir'], __DIR__ . '/data/media/gallery');
}
/**
* Check that the images are returned correctly
*/
public function testGetImages()
{
$gallery = new NamespaceGallery('gallery', new Options());
$images = $gallery->getImages();
$this->assertIsArray($images);
$this->assertCount(3, $images);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@ -0,0 +1,84 @@
<?php
namespace dokuwiki\plugin\gallery\classes;
abstract class AbstractGallery
{
/** @var Image[] */
protected $images = [];
/** @var Options */
protected Options $options;
/**
* Initialize the Gallery
*
* @param string $src The source from where to get the images
* @param Options $options Gallery configuration
*/
public function __construct($src, Options $options)
{
$this->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;
}
}

90
classes/FeedGallery.php Normal file
View File

@ -0,0 +1,90 @@
<?php
namespace dokuwiki\plugin\gallery\classes;
use FeedParser;
class FeedGallery extends AbstractGallery
{
protected $feedHost;
protected $feedPath;
/** @inheritdoc */
public function __construct($url, Options $options)
{
parent::__construct($url, $options);
$this->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']) . '/';
}
}

225
classes/Formatter.php Normal file
View File

@ -0,0 +1,225 @@
<?php
namespace dokuwiki\plugin\gallery\classes;
class Formatter
{
protected Options $options;
/**
* @param Options $options
*/
public function __construct(Options $options)
{
$this->options = $options;
}
// region Main Formatters
/**
* @param AbstractGallery $gallery
* @return string
*/
public function format(AbstractGallery $gallery)
{
$html = '<div class="plugin-gallery" id="gallery__' . $this->options->galleryID . '">';
$images = $gallery->getImages();
$pages = $this->paginate($images);
foreach ($pages as $page => $images) {
$html .= $this->formatPage($images, $page);
}
$html .= '</div>';
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 = '<div class="gallery_page" id="gallery__' . $this->options->galleryID . '_' . $page . '">';
foreach ($images as $image) {
$html .= $this->formatImage($image);
}
$html .= '</div>';
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 = '<figure ' . buildAttributes($fig, true) . '>';
$html .= '<a ' . buildAttributes($a, true) . '>';
$html .= '<img ' . buildAttributes($img, true) . ' />';
$html .= '</a>';
if ($this->options->showtitle || $this->options->showname) {
$html .= '<figcaption>';
if ($this->options->showname) {
$a = [
'href' => $this->getDetailLink($image),
'class' => 'gallery-filename',
'title' => $image->getFilename(),
];
$html .= '<a ' . buildAttributes($a) . '>' . hsc($image->getFilename()) . '</a>';
}
if ($this->options->showtitle) {
$a = [
'href' => $this->getDetailLink($image),
'class' => 'gallery-title',
'title' => $image->getTitle(),
];
$html .= '<a ' . buildAttributes($a) . '>' . hsc($image->getTitle()) . '</a>';
}
$html .= '</figcaption>';
}
$html .= '</figure>';
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
}

191
classes/Image.php Normal file
View File

@ -0,0 +1,191 @@
<?php
namespace dokuwiki\plugin\gallery\classes;
use dokuwiki\Utf8\PhpString;
class Image
{
const IMG_REGEX = '/\.(jpe?g|gif|png)$/i'; // FIXME add more formats
protected $isExternal = false;
protected $src;
protected $filename;
protected $localfile;
protected $title;
protected $description;
protected $width;
protected $height;
protected $created = 0;
protected $modified = 0;
protected $detaillink;
/**
* @param string $src local ID or external URL to image
* @throws \Exception
*/
public function __construct($src)
{
$this->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;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace dokuwiki\plugin\gallery\classes;
class NamespaceGallery extends AbstractGallery
{
/** @inheritdoc */
public function __construct($ns, $options)
{
parent::__construct($ns, $options);
$this->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;
}
}

109
classes/Options.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace dokuwiki\plugin\gallery\classes;
class Options
{
const SORT_FILE = 'file';
const SORT_CTIME = 'date';
const SORT_MTIME = 'mod';
const SORT_TITLE = 'title';
const SORT_RANDOM = 'random';
const ALIGN_NONE = 0;
const ALIGN_LEFT = 1;
const ALIGN_RIGHT = 2;
const ALIGN_CENTER = 3;
// defaults
public string $galleryID = '';
public int $thumbnailWidth = 120;
public int $thumbnailHeight = 120;
public int $lightboxWidth = 1600;
public int $lightboxHeight = 1200;
public int $columns = 0;
public string $filter = '';
public bool $lightbox = false;
public bool $direct = false;
public bool $showname = false;
public bool $showtitle = false;
public bool $reverse = false;
public bool $cache = true;
public bool $crop = false;
public bool $recursive = true;
public $sort = self::SORT_FILE;
public int $limit = 0;
public int $offset = 0;
public int $paginate = 0;
public int $align = self::ALIGN_NONE;
/**
* Options constructor.
*/
public function __construct()
{
// load options from config
$plugin = plugin_load('syntax', 'gallery');
$this->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;
}
}
}
}

View File

@ -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';

View File

@ -5,12 +5,23 @@
* @author Dmitry Baikov <dsbaikov@gmail.com>
*/
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'));

View File

@ -1,6 +1,10 @@
<?php
use dokuwiki\File\PageResolver;
use dokuwiki\plugin\gallery\classes\FeedGallery;
use dokuwiki\plugin\gallery\classes\Formatter;
use dokuwiki\plugin\gallery\classes\NamespaceGallery;
use dokuwiki\plugin\gallery\classes\Options;
/**
* Embed an image gallery
@ -43,638 +47,73 @@ class syntax_plugin_gallery extends DokuWiki_Syntax_Plugin
global $ID;
$match = substr($match, 10, -2); //strip markup from start and end
$data = array();
$options = new Options();
$data['galid'] = substr(md5($match), 0, 4);
// unique gallery ID
$options->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 .= '<div class="nothing">' . $this->getLang('nothingfound') . '</div>';
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 .= '<div class="gallery_page gallery__' . $data['galid'] . '" id="gallery__' . $data['galid'] . '_' . (++$page) . '">';
$close_pg = true;
}
// new table?
if ($i == 0 || ($data['paginate'] && ($i % $data['paginate'] == 0))) {
$ret .= '<table>';
}
// new row?
if ($i % $data['cols'] == 0) {
$ret .= '<tr>';
}
// an image cell
$ret .= '<td>';
$ret .= $this->formatThumbnail($img, $data);
$ret .= $this->formatName($img, $data);
$ret .= $this->formatTitle($img, $data);
$ret .= '</td>';
$i++;
// done with this row? close it
$close_tr = true;
if ($i % $data['cols'] == 0) {
$ret .= '</tr>';
$close_tr = false;
}
// close current page and table
if ($data['paginate'] && ($i % $data['paginate'] == 0)) {
if ($close_tr) {
// add remaining empty cells
while ($i % $data['cols']) {
$ret .= '<td></td>';
$i++;
}
$ret .= '</tr>';
}
$ret .= '</table>';
$ret .= '</div>';
$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 .= '<td></td>';
$i++;
}
$ret .= '</tr>';
}
if (!$data['paginate']) {
$ret .= '</table>';
} elseif ($close_pg) {
$ret .= '</table>';
$ret .= '</div>';
}
} else { // format as div sequence
$i = 0;
$close_pg = false;
foreach ($files as $img) {
if ($data['paginate'] && ($i % $data['paginate'] == 0)) {
$ret .= '<div class="gallery_page gallery__' . $data['galid'] . '" id="gallery__' . $data['galid'] . '_' . (++$page) . '">';
$close_pg = true;
}
$ret .= '<div>';
$ret .= $this->formatThumbnail($img, $data);
$ret .= $this->formatName($img, $data);
$ret .= $this->formatTitle($img, $data);
$ret .= '</div> ';
$i++;
if ($data['paginate'] && ($i % $data['paginate'] == 0)) {
$ret .= '</div>';
$close_pg = false;
}
}
if ($close_pg) $ret .= '</div>';
$ret .= '<br style="clear:both" />';
}
// pagination links
$pgret = '';
if ($page) {
$pgret .= '<div class="gallery_pages"><span>' . $this->getLang('pages') . ' </span>';
for ($j = 1; $j <= $page; $j++) {
$pgret .= '<a href="#gallery__' . $data['galid'] . '_' . $j . '" class="gallery_pgsel button">' . $j . '</a> ';
}
$pgret .= '</div>';
}
return '<div class="gallery' . $align . '"' . $xalign . '>' . $pgret . $ret . '<div class="clearer"></div></div>';
}
/**
* 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 .= '<a href="' . $href . '" ' . $aatt . '>';
$ret .= '<img src="' . $src . '" ' . $iatt . ' />';
$ret .= '</a>';
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 .= '<br /><a href="' . $lnk . '">';
$ret .= hsc($img['file']);
$ret .= '</a>';
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 .= '<br /><a href="' . $lnk . '">';
$ret .= hsc($this->readMeta($img, 'title'));
$ret .= '</a>';
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 <andi@splitbrain.org>
*/
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;
}
}