Feed creation refactoring

This breaks up the humongous functions from feed.php into multiple
classes. To keep compatibility with existing Plugin events, the basic
principle of how the feed is assembled has not been changed:

* depending on the given mode and other options lose arrays of items are
  gathered
* these items are then converted (again based on the various options)
  into proper FeedItems
* the FeedItems are then added to the Feed

The conversion from loosely typed item data to something more structured
is now done by the FeedItemProcessor classes. Some very basic tests have
been added. It does not cover erverything but covers more than before
(which was nothing).

Manual testing before merging this is highly recommended. I am not
confident that I ported over everything correctly.

No new features have been added, but especially media support could and
should be improved in the future.
This commit is contained in:
Andreas Gohr 2024-01-15 16:30:56 +01:00
parent 77d32594e8
commit fe9d054b30
9 changed files with 1191 additions and 45 deletions

View File

@ -0,0 +1,93 @@
<?php
namespace dokuwiki\test\Feed;
use dokuwiki\Feed\FeedMediaProcessor;
use DOMWrap\Document;
class FeedMediaProcessorTest extends \DokuWikiTest
{
public function provideData()
{
// an Item returned by FeedCreator::fetchItemsFromRecentChanges()
yield ([
array(
'date' => 1705511543,
'ip' => '::1',
'type' => 'C',
'id' => 'wiki:dokuwiki-128.png',
'user' => 'testuser',
'sum' => 'created',
'extra' => '',
'sizechange' => 52618,
'perms' => 8,
'mode' => 'media',
),
1705511543, // fixed revision
['testuser@undisclosed.example.com', 'Arthur Dent'], // proper author
'created', // summary
]);
// FeedCreator::fetchItemsFromNamespace() currently does not support media files
// FeedCreator::fetchItemsFromSearch() currently does not support media files
}
/**
* @dataProvider provideData
*/
public function testProcessing($data, $expectedMtime, $expectedAuthor, $expectedSummary)
{
global $conf;
$conf['useacl'] = 1;
$conf['showuseras'] = 'username';
$conf['useheading'] = 1;
$proc = new FeedMediaProcessor($data);
$this->assertEquals('wiki:dokuwiki-128.png', $proc->getId());
$this->assertEquals('dokuwiki-128.png', $proc->getTitle());
$this->assertEquals($expectedAuthor, $proc->getAuthor());
$this->assertEquals($expectedMtime, $proc->getRev());
$this->assertEquals(null, $proc->getPrev());
$this->assertTrue($proc->isExisting());
$this->assertTrue($proc->isExisting());
$this->assertEquals(['wiki'], $proc->getCategory());
$this->assertEquals($expectedSummary, $proc->getSummary());
$this->assertEquals(
"http://wiki.example.com/doku.php?image=wiki%3Adokuwiki-128.png&ns=wiki&rev=$expectedMtime&do=media",
$proc->getURL('page')
);
$this->assertEquals(
"http://wiki.example.com/doku.php?image=wiki%3Adokuwiki-128.png&ns=wiki&rev=$expectedMtime&tab_details=history&do=media",
$proc->getURL('rev')
);
$this->assertEquals(
"http://wiki.example.com/doku.php?image=wiki%3Adokuwiki-128.png&ns=wiki&do=media",
$proc->getURL('current')
);
$this->assertEquals(
"http://wiki.example.com/doku.php?image=wiki%3Adokuwiki-128.png&ns=wiki&rev=$expectedMtime&tab_details=history&media_do=diff&do=media",
$proc->getURL('diff')
);
$doc = new Document();
$doc->html($proc->getBody('diff'));
$th = $doc->find('table th');
$this->assertGreaterThanOrEqual(2, $th->count());
$doc = new Document();
$doc->html($proc->getBody('htmldiff'));
$th = $doc->find('table th');
$this->assertGreaterThanOrEqual(2, $th->count());
$doc = new Document();
$doc->html($proc->getBody('html'));
$home = $doc->find('img');
$this->assertEquals(1, $home->count());
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace dokuwiki\test\Feed;
use dokuwiki\Feed\FeedPageProcessor;
use DOMWrap\Document;
class FeedPageProcessorTest extends \DokuWikiTest
{
public function provideData()
{
// an Item returned by FeedCreator::fetchItemsFromRecentChanges()
yield ([
[
'date' => 1705501370,
'ip' => '::1',
'type' => 'E',
'id' => 'wiki:dokuwiki',
'user' => 'testuser',
'sum' => 'test editing',
'extra' => '',
'sizechange' => 41,
'perms' => 8,
'mode' => 'page',
],
1705501370, // fixed revision
['testuser@undisclosed.example.com', 'Arthur Dent'], // proper author
'test editing', // summary
]);
// an Item returned by FeedCreator::fetchItemsFromNamespace()
clearstatcache();
yield ([
[
'id' => 'wiki:dokuwiki',
'ns' => 'wiki',
'perm' => 8,
'type' => 'f',
'level' => 1,
'open' => true,
],
filemtime(wikiFN('wiki:dokuwiki')), // current revision
['anonymous@undisclosed.example.com', 'Anonymous'], // unknown author
'', // no summary
]);
// an Item returned by FeedCreator::fetchItemsFromSearch()
clearstatcache();
yield ([
[
'id' => 'wiki:dokuwiki',
],
filemtime(wikiFN('wiki:dokuwiki')), // current revision
['anonymous@undisclosed.example.com', 'Anonymous'], // unknown author
'', // no summary
]);
}
/**
* @dataProvider provideData
*/
public function testProcessing($data, $expectedMtime, $expectedAuthor, $expectedSummary)
{
global $conf;
$conf['useacl'] = 1;
$conf['showuseras'] = 'username';
$conf['useheading'] = 1;
$proc = new FeedPageProcessor($data);
$this->assertEquals('wiki:dokuwiki', $proc->getId());
$this->assertEquals('DokuWiki', $proc->getTitle());
$this->assertEquals($expectedAuthor, $proc->getAuthor());
$this->assertEquals($expectedMtime, $proc->getRev());
$this->assertEquals(null, $proc->getPrev());
$this->assertTrue($proc->isExisting());
$this->assertEquals(['wiki'], $proc->getCategory());
$this->assertStringContainsString('standards compliant', $proc->getAbstract());
$this->assertEquals($expectedSummary, $proc->getSummary());
$this->assertEquals(
"http://wiki.example.com/doku.php?id=wiki:dokuwiki&rev=$expectedMtime",
$proc->getURL('page')
);
$this->assertEquals(
"http://wiki.example.com/doku.php?id=wiki:dokuwiki&rev=$expectedMtime&do=revisions",
$proc->getURL('rev')
);
$this->assertEquals(
'http://wiki.example.com/doku.php?id=wiki:dokuwiki',
$proc->getURL('current')
);
$this->assertEquals(
"http://wiki.example.com/doku.php?id=wiki:dokuwiki&rev=$expectedMtime&do=diff",
$proc->getURL('diff')
);
$diff = explode("\n", $proc->getBody('diff'));
$this->assertEquals('<pre>', $diff[0]);
$this->assertStringStartsWith('@@', $diff[1]);
$doc = new Document();
$doc->html($proc->getBody('htmldiff'));
$th = $doc->find('table th');
$this->assertGreaterThanOrEqual(2, $th->count());
$doc = new Document();
$doc->html($proc->getBody('html'));
$home = $doc->find('a[href^="https://www.dokuwiki.org/manual"]');
$this->assertGreaterThanOrEqual(1, $home->count());
$this->assertStringContainsString('standards compliant', $proc->getBody('abstract'));
}
}

View File

@ -29,13 +29,16 @@ if (!actionOK('rss')) {
exit;
}
// get params
$opt = rss_parseOptions();
$options = new \dokuwiki\Feed\FeedCreatorOptions();
// the feed is dynamic - we need a cache for each combo
// (but most people just use the default feed so it's still effective)
$key = implode('', array_values($opt)) . '$' . $INPUT->server->str('REMOTE_USER')
. '$' . $INPUT->server->str('HTTP_HOST') . $INPUT->server->str('SERVER_PORT');
$key = implode('$', [
$options->getCacheKey(),
$INPUT->server->str('REMOTE_USER'),
$INPUT->server->str('HTTP_HOST'),
$INPUT->server->str('SERVER_PORT')
]);
$cache = new Cache($key, '.feed');
// prepare cache depends
@ -47,7 +50,7 @@ $depends['purge'] = $INPUT->bool('purge');
// time or the update interval has not passed, also handles conditional requests
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Content-Type: application/xml; charset=utf-8');
header('Content-Type: ' . $options->get('mime_type'));
header('X-Robots-Tag: noindex');
if ($cache->useCache($depends)) {
http_conditionalRequest($cache->getTime());
@ -59,48 +62,16 @@ if ($cache->useCache($depends)) {
}
// create new feed
$rss = new UniversalFeedCreator();
$rss->title = $conf['title'] . (($opt['namespace']) ? ' ' . $opt['namespace'] : '');
$rss->link = DOKU_URL;
$rss->syndicationURL = DOKU_URL . 'feed.php';
$rss->cssStyleSheet = DOKU_URL . 'lib/exe/css.php?s=feed';
$image = new FeedImage();
$image->title = $conf['title'];
$image->url = tpl_getMediaFile([':wiki:favicon.ico', ':favicon.ico', 'images/favicon.ico'], true);
$image->link = DOKU_URL;
$rss->image = $image;
$data = null;
$modes = [
'list' => 'rssListNamespace',
'search' => 'rssSearch',
'recent' => 'rssRecentChanges'
];
if (isset($modes[$opt['feed_mode']])) {
$data = $modes[$opt['feed_mode']]($opt);
} else {
$eventData = [
'opt' => &$opt,
'data' => &$data,
];
$event = new Event('FEED_MODE_UNKNOWN', $eventData);
if ($event->advise_before(true)) {
echo sprintf('<error>Unknown feed mode %s</error>', hsc($opt['feed_mode']));
exit;
}
$event->advise_after();
try {
$feed = (new \dokuwiki\Feed\FeedCreator($options))->build();
$cache->storeCache($feed);
echo $feed;
} catch (Exception $e) {
http_status(500);
echo '<error>' . hsc($e->getMessage()) . '</error>';
exit;
}
rss_buildItems($rss, $data, $opt);
$feed = $rss->createFeed($opt['feed_type']);
// save cachefile
$cache->storeCache($feed);
// finally deliver
echo $feed;
// ---------------------------------------------------------------- //

219
inc/Feed/FeedCreator.php Normal file
View File

@ -0,0 +1,219 @@
<?php
namespace dokuwiki\Feed;
use dokuwiki\Extension\Event;
class FeedCreator
{
/** @var \UniversalFeedCreator */
protected $feed;
/** @var FeedCreatorOptions */
protected $options;
/**
* @param FeedCreatorOptions $options
*/
public function __construct(FeedCreatorOptions $options)
{
$this->options = $options;
$this->feed = new \UniversalFeedCreator();
$this->feed->title = $this->options->get('title');
$this->feed->link = DOKU_URL;
$this->feed->syndicationURL = DOKU_URL . 'feed.php';
$this->feed->cssStyleSheet = DOKU_URL . 'lib/exe/css.php?s=feed';
$this->initLogo();
}
/**
* Build the feed
*
* @return string The raw XML for the feed
*/
public function build()
{
switch ($this->options->get('feed_mode')) {
case 'list':
$items = $this->fetchItemsFromNamespace();
break;
case 'search':
$items = $this->fetchItemsFromSearch();
break;
case 'recent':
$items = $this->fetchItemsFromRecentChanges();
break;
default:
$items = $this->fetchItemsFromPlugin();
}
foreach ($items as $item) {
$this->createAndAddItem($item);
}
return $this->feed->createFeed($this->options->get('type'));
}
/**
* Process the raw data, create feed item and add it to the feed
*
* @param array|string $data raw item data
* @return \FeedItem
* @triggers FEED_ITEM_ADD
*/
protected function createAndAddItem($data)
{
if (is_string($data)) {
$data = ['id' => $data];
}
if (($data['mode'] ?? '') == 'media' || isset($data['media'])) {
$data['id'] = $data['media'] ?? $data['id'];
$proc = new FeedMediaProcessor($data);
} else {
$proc = new FeedPageProcessor($data);
}
$item = new \FeedItem();
$item->title = $proc->getTitle();
if ($this->options->get('show_summary') && $proc->getSummary()) {
$item->title .= ' - ' . $proc->getSummary();
}
[$item->authorEmail, $item->author] = $proc->getAuthor();
$item->link = $proc->getURL($this->options->get('link_to'));
$item->description = $proc->getBody($this->options->get('item_content'));
$evdata = [
'item' => $item,
'opt' => &$this->options->options,
'ditem' => &$data,
'rss' => $this->feed,
];
$evt = new Event('FEED_ITEM_ADD', $evdata);
if ($evt->advise_before()) {
$this->feed->addItem($item);
}
$evt->advise_after();
return $item;
}
/**
* Read all pages from a namespace
*
* @todo this currently does not honor the rss_media setting and only ever lists pages
* @return array
*/
protected function fetchItemsFromNamespace()
{
global $conf;
$ns = ':' . cleanID($this->options->get('namespace'));
$ns = utf8_encodeFN(str_replace(':', '/', $ns));
$data = [];
$search_opts = [
'depth' => 1,
'pagesonly' => true,
'listfiles' => true
];
search(
$data,
$conf['datadir'],
'search_universal',
$search_opts,
$ns,
$lvl = 1,
$this->options->get('sort')
);
return $data;
}
/**
* Add the result of a full text search to the feed object
*
* @return array
*/
protected function fetchItemsFromSearch()
{
if (!actionOK('search')) throw new \RuntimeException('search is disabled');
if (!$this->options->get('search_query')) return [];
$data = ft_pageSearch($this->options->get('search_query'), $poswords);
return array_keys($data);
}
/**
* Add recent changed pages to the feed object
*
* @return array
*/
protected function fetchItemsFromRecentChanges()
{
global $conf;
$flags = 0;
if (!$this->options->get('show_deleted')) $flags += RECENTS_SKIP_DELETED;
if (!$this->options->get('show_minor')) $flags += RECENTS_SKIP_MINORS;
if ($this->options->get('only_new')) $flags += RECENTS_ONLY_CREATION;
if ($this->options->get('content_type') == 'media' && $conf['mediarevisions']) {
$flags += RECENTS_MEDIA_CHANGES;
}
if ($this->options->get('content_type') == 'both' && $conf['mediarevisions']) {
$flags += RECENTS_MEDIA_PAGES_MIXED;
}
return getRecents(0, $this->options->get('items'), $this->options->get('namespace'), $flags);
}
/**
* Add items from a plugin to the feed object
*
* @triggers FEED_MODE_UNKNOWN
* @return array
*/
protected function fetchItemsFromPlugin()
{
$eventData = [
'opt' => $this->options->options,
'data' => [],
];
$event = new Event('FEED_MODE_UNKNOWN', $eventData);
if ($event->advise_before(true)) {
throw new \RuntimeException('unknown feed mode');
}
$event->advise_after();
return $eventData['data'];
}
/**
* Add a logo to the feed
*
* Looks at different possible candidates for a logo and adds the first one
*
* @return void
*/
protected function initLogo()
{
global $conf;
$this->feed->image = new \FeedImage();
$this->feed->image->title = $conf['title'];
$this->feed->image->link = DOKU_URL;
$this->feed->image->url = tpl_getMediaFile([
':wiki:logo.svg',
':logo.svg',
':wiki:logo.png',
':logo.png',
':wiki:logo.jpg',
':logo.jpg',
':wiki:favicon.ico',
':favicon.ico',
'images/favicon.ico'
], true);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace dokuwiki\Feed;
use dokuwiki\Extension\Event;
/**
* Hold the options for feed generation
*/
class FeedCreatorOptions
{
/** @var array[] supported feed types */
protected $types = [
'rss' => [
'name' => 'RSS0.91',
'mime' => 'text/xml; charset=utf-8',
],
'rss2' => [
'name' => 'RSS2.0',
'mime' => 'text/xml; charset=utf-8',
],
'atom' => [
'name' => 'ATOM0.3',
'mime' => 'application/xml; charset=utf-8',
],
'atom1' => [
'name' => 'ATOM1.0',
'mime' => 'application/atom+xml; charset=utf-8',
],
];
/** @var array[] the set options */
public $options = [
'type' => 'rss',
'feed_mode' => 'recent',
'link_to' => 'page',
'item_content' => 'diff',
'namespace' => '',
'items' => 15,
'show_minor' => false,
'show_deleted' => false,
'show_summary' => false,
'only_new' => false,
'sort' => 'natural',
'search_query' => '',
'content_type' => 'pages',
'guardmail' => 'none',
'title' => '',
];
/**
* Initialize the options from the request, falling back to config defaults
*
* @triggers FEED_OPTS_POSTPROCESS
* @param array $options additional options to set (for testing)
*/
public function __construct($options = [])
{
global $conf;
global $INPUT;
$this->options['type'] = $INPUT->valid(
'type',
array_keys($this->types),
$conf['rss_type']
);
// we only support 'list', 'search', 'recent' but accept anything so plugins can take over
$this->options['feed_mode'] = $INPUT->str('mode', 'recent');
$this->options['link_to'] = $INPUT->valid(
'linkto',
['diff', 'page', 'rev', 'current'],
$conf['rss_linkto']
);
$this->options['item_content'] = $INPUT->valid(
'content',
['abstract', 'diff', 'htmldiff', 'html'],
$conf['rss_content']
);
$this->options['namespace'] = $INPUT->filter('cleanID')->str('ns');
$this->options['items'] = max(0, $INPUT->int('num', $conf['recent']));
$this->options['show_minor'] = $INPUT->bool('minor');
$this->options['show_deleted'] = $conf['rss_show_deleted'];
$this->options['show_summary'] = $conf['rss_show_summary'];
$this->options['only_new'] = $INPUT->bool('onlynewpages');
$this->options['sort'] = $INPUT->valid(
'sort',
['natural', 'date'],
'natural'
);
$this->options['search_query'] = $INPUT->str('q');
$this->options['content_type'] = $INPUT->valid(
'view',
['pages', 'media', 'both'],
$conf['rss_media']
);
$this->options['guardmail'] = $conf['mailguard'];
$this->options['title'] = $conf['title'];
if ($this->options['namespace']) {
$this->options['title'] .= ' - ' . $this->options['namespace'];
}
$this->options = array_merge($this->options, $options);
// initialization finished, let plugins know
$eventData = [
'opt' => &$this->options,
];
Event::createAndTrigger('FEED_OPTS_POSTPROCESS', $eventData);
}
/**
* The cache key to use for a feed with these options
*
* Does not contain user or host specific information yet
*
* @return string
*/
public function getCacheKey()
{
return implode('', array_values($this->options));
}
/**
* Return a feed option by name
*
* @param string $option The name of the option
* @param mixed $default default value if option is not set (should usually not happen)
* @return mixed
*/
public function get($option, $default = null)
{
if (isset($this->options[$option])) {
return $this->options[$option];
}
return $default;
}
/**
* Return the feed type for UniversalFeedCreator
*
* This returns the apropriate type for UniversalFeedCreator
*
* @return string
*/
public function getType()
{
return $this->types[$this->options['type']]['name'];
}
/**
* Return the feed mime type
*
* @return string
*/
public function getMimeType()
{
return $this->types[$this->options['type']]['mime'];
}
}

View File

@ -0,0 +1,172 @@
<?php
namespace dokuwiki\Feed;
use dokuwiki\Extension\AuthPlugin;
use RuntimeException;
/**
* Accept more or less arbitrary data to represent data to later construct a feed item from.
* Provide lazy loading accessors to all the data we need for feed generation.
*/
abstract class FeedItemProcessor
{
/** @var string This page's ID */
protected $id;
/** @var array bag of holding */
protected $data;
/**
* Constructor
*
* @param array $data Needs to have at least an 'id' key
*/
public function __construct($data)
{
if (!isset($data['id'])) throw new RuntimeException('Missing ID');
$this->id = cleanID($data['id']);
$this->data = $data;
}
/**
* Get the page ID
*
* @return string
*/
public function getId()
{
return $this->id;
}
/**
* Get the revision timestamp of this page
*
* If the input gave us a revision, date or lastmodified already, we trust that it is correct.
*
* Note: we only handle most current revisions in feeds, so the revision is usually just the
* lastmodifed timestamp of the page file. However, if the item does not exist, we need to
* determine the revision from the changelog.
*
* @return int
*/
public function getRev()
{
if ($this->data['rev'] ?? 0) return $this->data['rev'];
if (isset($this->data['date'])) {
$this->data['rev'] = (int)$this->data['date'];
}
if (isset($this->data['lastmodified'])) {
$this->data['rev'] = (int)$this->data['lastmodified'];
}
return $this->data['rev'] ?? 0;
}
/**
* Construct the URL for the feed item based on the link_to option
*
* @param string $linkto The link_to option
* @return string URL
*/
abstract public function getURL($linkto);
/**
* @return string
*/
public function getTitle()
{
return $this->data['title'] ?? noNS($this->getId());
}
/**
* Construct the body of the feed item based on the item_content option
*
* @param string $content The item_content option
* @return string
*/
abstract public function getBody($content);
/**
* Get the change summary for this item if any
*
* @return string
*/
public function getSummary()
{
return (string)($this->data['sum'] ?? '');
}
/**
* Get the author info for this item
*
* @return string[] [email, author]
*/
public function getAuthor()
{
global $conf;
global $auth;
$user = $this->data['user'] ?? '';
$author = 'Anonymous';
$email = 'anonymous@undisclosed.example.com';
if (!$user) return [$email, $author];
$author = $user;
$email = $user . '@undisclosed.example.com';
if ($conf['useacl'] && $auth instanceof AuthPlugin) {
$userInfo = $auth->getUserData($user);
if ($userInfo) {
switch ($conf['showuseras']) {
case 'username':
case 'username_link':
$author = $userInfo['name'];
break;
}
}
}
return [$email, $author];
}
/**
* Get the categories for this item
*
* @return string[]
*/
abstract public function getCategory();
/**
* Clean HTML for the use in feeds
*
* @param string $html
* @return string
*/
protected function cleanHTML($html)
{
global $conf;
// no TOC in feeds
$html = preg_replace('/(<!-- TOC START -->).*(<!-- TOC END -->)/s', '', $html);
// add alignment for images
$html = preg_replace('/(<img .*?class="medialeft")/s', '\\1 align="left"', $html);
$html = preg_replace('/(<img .*?class="mediaright")/s', '\\1 align="right"', $html);
// make URLs work when canonical is not set, regexp instead of rerendering!
if (!$conf['canonical']) {
$base = preg_quote(DOKU_REL, '/');
$html = preg_replace(
'/(<a href|<img src)="(' . $base . ')/s',
'$1="' . DOKU_URL,
$html
);
}
return $html;
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace dokuwiki\Feed;
use dokuwiki\ChangeLog\MediaChangeLog;
use dokuwiki\File\MediaFile;
use dokuwiki\Ui\Media\Display;
class FeedMediaProcessor extends FeedItemProcessor
{
/** @inheritdoc */
public function getURL($linkto)
{
switch ($linkto) {
case 'page':
$opt = [
'image' => $this->getId(),
'ns' => getNS($this->getId()),
'rev' => $this->getRev()
];
break;
case 'rev':
$opt = [
'image' => $this->getId(),
'ns' => getNS($this->getId()),
'rev' => $this->getRev(),
'tab_details' => 'history'
];
break;
case 'current':
$opt = [
'image' => $this->getId(),
'ns' => getNS($this->getId())
];
break;
case 'diff':
default:
$opt = [
'image' => $this->getId(),
'ns' => getNS($this->getId()),
'rev' => $this->getRev(),
'tab_details' => 'history',
'media_do' => 'diff'
];
}
return media_managerURL($opt, '&', true);
}
public function getBody($content)
{
switch ($content) {
case 'diff':
case 'htmldiff':
$prev = $this->getPrev();
if ($prev) {
if ($this->isExisting()) {
$src1 = new MediaFile($this->getId(), $prev);
$src2 = new MediaFile($this->getId());
} else {
$src1 = new MediaFile($this->getId(), $prev);
$src2 = null;
}
} else {
$src1 = null;
$src2 = new MediaFile($this->getId());
}
return $this->createDiffTable($src1, $src2);
case 'abstract':
case 'html':
default:
$src = new Display(new MediaFile($this->getId()));
return $this->cleanHTML($src->getPreviewHtml(500, 500));
}
}
/**
* @inheritdoc
* @todo read exif keywords
*/
public function getCategory()
{
return (array)getNS($this->getId());
}
/**
* Get the revision timestamp of this page
*
* Note: we only handle most current revisions in feeds, so the revision is usually just the
* lastmodifed timestamp of the page file. However, if the page does not exist, we need to
* determine the revision from the changelog.
* @return int
*/
public function getRev()
{
$rev = parent::getRev();
if ($rev) return $rev;
if (media_exists($this->id)) {
$this->data['rev'] = filemtime(mediaFN($this->id));
$this->data['exists'] = true;
} else {
$this->loadRevisions();
}
return $this->data['rev'];
}
/**
* Get the previous revision timestamp of this page
*
* @return int|null The previous revision or null if there is none
*/
public function getPrev()
{
if ($this->data['prev'] ?? 0) return $this->data['prev'];
$this->loadRevisions();
return $this->data['prev'];
}
/**
* Does this page exist?
*
* @return bool
*/
public function isExisting()
{
if (!isset($this->data['exists'])) {
$this->data['exists'] = media_exists($this->id);
}
return $this->data['exists'];
}
/**
* Load the current and previous revision from the changelog
* @return void
*/
protected function loadRevisions()
{
$changelog = new MediaChangeLog($this->id);
$revs = $changelog->getRevisions(0, 2); // FIXME check that this returns the current one correctly
if (!isset($this->data['rev'])) {
// prefer an already set date, only set if missing
// it should usally not happen that neither is available
$this->data['rev'] = $revs[0] ?? 0;
}
// a previous revision might not exist
$this->data['prev'] = $revs[1] ?? null;
}
/**
* Create a table showing the two media files
*
* @param MediaFile|null $src1
* @param MediaFile|null $src2
* @return string
*/
protected function createDiffTable($src1, $src2)
{
global $lang;
$content = '<table>';
$content .= '<tr>';
$content .= '<th width="50%">' . ($src1 ? $src1->getRev() : '') . '</th>';
$content .= '<th width="50%">' . $lang['current'] . '</th>';
$content .= '</tr>';
$content .= '<tr>';
$content .= '<td align="center">';
if ($src1) {
$display = new Display($src1);
$display->getPreviewHtml(300, 300);
}
$content .= '</td>';
$content .= '<td align="center">';
if ($src2) {
$display = new Display($src2);
$display->getPreviewHtml(300, 300);
}
$content .= '</td>';
$content .= '</tr>';
$content .= '</table>';
return $this->cleanHTML($content);
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace dokuwiki\Feed;
use Diff;
use dokuwiki\ChangeLog\PageChangeLog;
use TableDiffFormatter;
use UnifiedDiffFormatter;
/**
* Accept more or less arbitrary data to represent a page and provide lazy loading accessors
* to all the data we need for feed generation.
*/
class FeedPageProcessor extends FeedItemProcessor
{
/** @var array[] metadata */
protected $meta;
// region data processors
/** @inheritdoc */
public function getURL($linkto)
{
switch ($linkto) {
case 'page':
$opt = ['rev' => $this->getRev()];
break;
case 'rev':
$opt = ['rev' => $this->getRev(), 'do' => 'revisions'];
break;
case 'current':
$opt = [];
break;
case 'diff':
default:
$opt = ['rev' => $this->getRev(), 'do' => 'diff'];
}
return wl($this->getId(), $opt, true, '&');
}
/** @inheritdoc */
public function getBody($content)
{
global $lang;
switch ($content) {
case 'diff':
$diff = $this->getDiff();
// note: diff output must be escaped, UnifiedDiffFormatter provides plain text
$udf = new UnifiedDiffFormatter();
return "<pre>\n" . hsc($udf->format($diff)) . "\n</pre>";
case 'htmldiff':
$diff = $this->getDiff();
// note: no need to escape diff output, TableDiffFormatter provides 'safe' html
$tdf = new TableDiffFormatter();
$content = '<table>';
$content .= '<tr><th colspan="2" width="50%">' . dformat($this->getPrev()) . '</th>';
$content .= '<th colspan="2" width="50%">' . $lang['current'] . '</th></tr>';
$content .= $tdf->format($diff);
$content .= '</table>';
return $content;
case 'html':
if ($this->isExisting()) {
$html = p_wiki_xhtml($this->getId(), '', false);
} else {
$html = p_wiki_xhtml($this->getId(), $this->getRev(), false);
}
return $this->cleanHTML($html);
case 'abstract':
default:
return $this->getAbstract();
}
}
/** @inheritdoc */
public function getCategory()
{
$meta = $this->getMetaData();
return (array)($meta['subject'] ?? (string)getNS($this->getId()));
}
// endregion
// region data accessors
/**
* Get the page abstract
*
* @return string
*/
public function getAbstract()
{
if (!isset($this->data['abstract'])) {
$meta = $this->getMetaData();
if (isset($meta['description']['abstract'])) {
$this->data['abstract'] = (string)$meta['description']['abstract'];
} else {
$this->data['abstract'] = '';
}
}
return $this->data['abstract'];
}
/** @inheritdoc */
public function getRev()
{
$rev = parent::getRev();
if($rev) return $rev;
if (page_exists($this->id)) {
$this->data['rev'] = filemtime(wikiFN($this->id));
$this->data['exists'] = true;
} else {
$this->loadRevisions();
}
return $this->data['rev'];
}
/**
* Get the previous revision timestamp of this page
*
* @return int|null The previous revision or null if there is none
*/
public function getPrev()
{
if ($this->data['prev'] ?? 0) return $this->data['prev'];
$this->loadRevisions();
return $this->data['prev'];
}
/**
* Does this page exist?
*
* @return bool
*/
public function isExisting()
{
if (!isset($this->data['exists'])) {
$this->data['exists'] = page_exists($this->id);
}
return $this->data['exists'];
}
/**
* Get the title of this page
*
* @return string
*/
public function getTitle()
{
global $conf;
if (!isset($this->data['title'])) {
if ($conf['useheading']) {
$this->data['title'] = p_get_first_heading($this->id);
} else {
$this->data['title'] = noNS($this->id);
}
}
return $this->data['title'];
}
// endregion
/**
* Get the metadata of this page
*
* @return array[]
*/
protected function getMetaData()
{
if (!isset($this->meta)) {
$this->meta = (array)p_get_metadata($this->id);
}
return $this->meta;
}
/**
* Load the current and previous revision from the changelog
* @return void
*/
protected function loadRevisions()
{
$changelog = new PageChangeLog($this->id);
$revs = $changelog->getRevisions(0, 2); // FIXME check that this returns the current one correctly
if (!isset($this->data['rev'])) {
// prefer an already set date, only set if missing
// it should usally not happen that neither is available
$this->data['rev'] = $revs[0] ?? 0;
}
// a previous revision might not exist
$this->data['prev'] = $revs[1] ?? null;
}
/**
* Get a diff between this and the previous revision
*
* @return Diff
*/
protected function getDiff()
{
$prev = $this->getPrev();
if ($prev) {
return new Diff(
explode("\n", rawWiki($this->getId(), $prev)),
explode("\n", rawWiki($this->getId(), ''))
);
}
return new Diff([''], explode("\n", rawWiki($this->getId(), '')));
}
}

View File

@ -7,6 +7,7 @@ use JpegMeta;
class MediaFile
{
protected $id;
protected $rev;
protected $path;
protected $mime;
@ -26,6 +27,7 @@ class MediaFile
{
$this->id = $id; //FIXME should it be cleaned?
$this->path = mediaFN($id, $rev);
$this->rev = $rev;
[$this->ext, $this->mime, $this->downloadable] = mimetype($this->path, false);
}
@ -36,6 +38,12 @@ class MediaFile
return $this->id;
}
/** @return string|int Empty string for current version */
public function getRev()
{
return $this->rev;
}
/** @return string */
public function getPath()
{