Difference between revisions of "Bliki 3.0"

From Organic Design
Jump to: navigation, search
(Change source-code blocks to standard format)
Line 14: Line 14:
  
 
The following configuration in ''LocalSettings.php'' are needed as well (this is in addition to the inclusion of the extensions and their default configuration options).
 
The following configuration in ''LocalSettings.php'' are needed as well (this is in addition to the inclusion of the extensions and their default configuration options).
{{code|<pre>
+
<source>
 
$wgRawHtml = true;
 
$wgRawHtml = true;
 
$wgPFEnableStringFunctions = true;
 
$wgPFEnableStringFunctions = true;
 
$smwgQSubcategoryDepth = 0;
 
$smwgQSubcategoryDepth = 0;
 
$smwgPageSpecialProperties = array( '_CDAT', '_LEDT' );
 
$smwgPageSpecialProperties = array( '_CDAT', '_LEDT' );
</pre>}}
+
</source>
 
*'''Note1:''' the [http://www.mediawiki.org/wiki/Manual:$wgRawHtml $wgRawHtml] global has been set to create the blog post form. This is option is only safe if the wiki doesn't allow public editing, otherwise something like ''HTMLets'' should be used instead.
 
*'''Note1:''' the [http://www.mediawiki.org/wiki/Manual:$wgRawHtml $wgRawHtml] global has been set to create the blog post form. This is option is only safe if the wiki doesn't allow public editing, otherwise something like ''HTMLets'' should be used instead.
 
*'''Note2:''' ''$smwgQSubcategoryDepth'' being set to zero is of particular importance in our installation since the blog functionality must only return direct category members.
 
*'''Note2:''' ''$smwgQSubcategoryDepth'' being set to zero is of particular importance in our installation since the blog functionality must only return direct category members.
Line 36: Line 36:
 
{|width=100%
 
{|width=100%
 
|
 
|
{{code|<pre>
+
<source>
 
{{#tag:html|<form class="blog" method="POST">
 
{{#tag:html|<form class="blog" method="POST">
 
<b>Title: </b><input name="newtitle" size="50" value="{{REQUEST:newtitle}}" />
 
<b>Title: </b><input name="newtitle" size="50" value="{{REQUEST:newtitle}}" />
Line 50: Line 50:
 
<input type="hidden" name="action" value="blog" />
 
<input type="hidden" name="action" value="blog" />
 
</form>}}
 
</form>}}
</pre>}}
+
</source>
 
|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 
|&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 
|valign=top|<div style="border: 1px solid #ccc;float: right;padding:5px;margin-top:15px">[[File:Blog post form.jpg|300px]]</div>
 
|valign=top|<div style="border: 1px solid #ccc;float: right;padding:5px;margin-top:15px">[[File:Blog post form.jpg|300px]]</div>
Line 57: Line 57:
  
 
And here's the content of the ''Checkbox'' template which is called by the ''#ask'' query in the form to render a checkbox for every member of the ''Tags'' category. It first defines an ''name'' for the checkbox input that has any spaces replaced by underscores and prepends "tag" just in case it conflicts with an existing item such as "title", then it builds an ''input'' element using the name and adds a ''checked'' attribute if the name exists in the post data (i.e. if the item is being previewed.
 
And here's the content of the ''Checkbox'' template which is called by the ''#ask'' query in the form to render a checkbox for every member of the ''Tags'' category. It first defines an ''name'' for the checkbox input that has any spaces replaced by underscores and prepends "tag" just in case it conflicts with an existing item such as "title", then it builds an ''input'' element using the name and adds a ''checked'' attribute if the name exists in the post data (i.e. if the item is being previewed.
{{code|<pre>
+
<source>
 
{{#vardefine:name|tag{{#replace:{{{1}}}| |_}}}}
 
{{#vardefine:name|tag{{#replace:{{{1}}}| |_}}}}
 
{{#tag:html|<div class="news-tags-input"><input type="checkbox" name="{{#var:name}}" {{#if:{{REQUEST:{{#var:id}}}}|checked}} />&nbsp;{{{1}}}</div>}}
 
{{#tag:html|<div class="news-tags-input"><input type="checkbox" name="{{#var:name}}" {{#if:{{REQUEST:{{#var:id}}}}|checked}} />&nbsp;{{{1}}}</div>}}
</pre>}}
+
</source>
  
 
=== Processing the form ===
 
=== Processing the form ===
 
In form above a hidden value called ''action'' has been added which is set to the value "blog", so to create a new blog item from the posted form some PHP needs to be added to the unknown action hook. The main part of the code will be something like the following which creates some article content from the ''summary'' and ''content'' fields of the form within a ''Blog'' template and then adds all the ''tag'' categories (as well as a handy ''Blog item'' and "Posts by USERNAME" category too). These are wrapped in ''noinclude'' tags so that any query pages that embed blog items don't get categorised into all the tag categories.
 
In form above a hidden value called ''action'' has been added which is set to the value "blog", so to create a new blog item from the posted form some PHP needs to be added to the unknown action hook. The main part of the code will be something like the following which creates some article content from the ''summary'' and ''content'' fields of the form within a ''Blog'' template and then adds all the ''tag'' categories (as well as a handy ''Blog item'' and "Posts by USERNAME" category too). These are wrapped in ''noinclude'' tags so that any query pages that embed blog items don't get categorised into all the tag categories.
{{code|<php>
+
<source lang="php">
 
$wikitext = '{{' . "Blog|1=$summary|2=$content" . '}}';
 
$wikitext = '{{' . "Blog|1=$summary|2=$content" . '}}';
 
$wikitext .= "<noinclude>[[Category:News]][[Category:Posts by $user]]";
 
$wikitext .= "<noinclude>[[Category:News]][[Category:Posts by $user]]";
Line 76: Line 76:
 
$article->doEdit( $wikitext, 'Blog item created via post form', EDIT_NEW );
 
$article->doEdit( $wikitext, 'Blog item created via post form', EDIT_NEW );
 
$wgOut->redirect( $title->getFullURL() );
 
$wgOut->redirect( $title->getFullURL() );
</php>}}
+
</source>
 
This code is incomplete as it only shows the actual article creation part. The complete version needs to attach to the ''UnknownAction'' hook, and needs to allow the preview buttons to work, and to raise a warning if the title is invalid or already exists first. There are two preview buttons so that users can test what it will look like when visiting the page proper or when its rendered within another page in a query. The ''Blog'' template renders both cases differently so that the full page has all the content as well as comments, but the "blog rolls" show only the summary and wrap it in a table.  
 
This code is incomplete as it only shows the actual article creation part. The complete version needs to attach to the ''UnknownAction'' hook, and needs to allow the preview buttons to work, and to raise a warning if the title is invalid or already exists first. There are two preview buttons so that users can test what it will look like when visiting the page proper or when its rendered within another page in a query. The ''Blog'' template renders both cases differently so that the full page has all the content as well as comments, but the "blog rolls" show only the summary and wrap it in a table.  
  
Line 85: Line 85:
  
 
Here's the content of the template, see [[Template:Blog]] for the actual wikitext as this has white-space added and has the ''noinclude'' section and ''includeonly'' tags removed for clarity.
 
Here's the content of the template, see [[Template:Blog]] for the actual wikitext as this has white-space added and has the ''noinclude'' section and ''includeonly'' tags removed for clarity.
{{code|<pre>
+
<source>
 
{{#vardefine:item|{{#show:{{FULLPAGENAME}}|?Category:Blog items}}}}
 
{{#vardefine:item|{{#show:{{FULLPAGENAME}}|?Category:Blog items}}}}
 
{{#if:{{#var:item}}|
 
{{#if:{{#var:item}}|
Line 101: Line 101:
 
   {{#widget:DISQUS|id=childrenarewelcome|uniqid={{FULLPAGENAME}}|url={{fullurl:{{FULLPAGENAME}}}}}}
 
   {{#widget:DISQUS|id=childrenarewelcome|uniqid={{FULLPAGENAME}}|url={{fullurl:{{FULLPAGENAME}}}}}}
 
}}
 
}}
</pre>}}
+
</source>
 
The first line defines a variable called ''item'' which is the result of an SMW ''#show'' query that test whether the current title is in the ''Blog items'' category. At the highest level the rest of the template is three sections, the first and the third are wrapped in conditions so that they only occur if ''item'' is true (i.e. the current title is an actual blog item not a query of a list of blog items. The second section is just <tt><nowiki>{{{1}}}</nowiki></tt> which is the summary text. So this means that queries of many blog items will be only the naked summary text and nothing else, where as actual blog item articles will also have the first section preceding the summary text, and the third section following it.
 
The first line defines a variable called ''item'' which is the result of an SMW ''#show'' query that test whether the current title is in the ''Blog items'' category. At the highest level the rest of the template is three sections, the first and the third are wrapped in conditions so that they only occur if ''item'' is true (i.e. the current title is an actual blog item not a query of a list of blog items. The second section is just <tt><nowiki>{{{1}}}</nowiki></tt> which is the summary text. So this means that queries of many blog items will be only the naked summary text and nothing else, where as actual blog item articles will also have the first section preceding the summary text, and the third section following it.
  
Line 112: Line 112:
 
== Blog queries ==
 
== Blog queries ==
 
The main functionality of the blog system is the ability to render a "blogroll", i.e. a list of blog items in summary format ordered by date and all filtered to show only items containing a particular tag or by a particular user. In this "bliki" system I've made s single page for this simply called [[Blog]]. The Blog page contains an SMW query that renders the "blogroll" and filters the results to a category if one is passed in the "q" query-string parameter. The query looks like this:
 
The main functionality of the blog system is the ability to render a "blogroll", i.e. a list of blog items in summary format ordered by date and all filtered to show only items containing a particular tag or by a particular user. In this "bliki" system I've made s single page for this simply called [[Blog]]. The Blog page contains an SMW query that renders the "blogroll" and filters the results to a category if one is passed in the "q" query-string parameter. The query looks like this:
{{code|<pre>
+
<source>
 
{{#ask:[[Category:{{REQUEST:q|Blog items}}]]
 
{{#ask:[[Category:{{REQUEST:q|Blog items}}]]
 
  | ?Creation date
 
  | ?Creation date
Line 124: Line 124:
 
  | template    = BlogItem
 
  | template    = BlogItem
 
}}
 
}}
</pre>}}
+
</source>
 
The first line is the ''#ask'' query itself which specifies which category to select the blog items from which uses the ''REQUEST'' magic word from the [[Extension:ExtraMagic|ExtraMagic extension]] to get the category name from the "q" query-string parameter defaulting to [[:Category:Blog items]] (all posts) if none is supplied.
 
The first line is the ''#ask'' query itself which specifies which category to select the blog items from which uses the ''REQUEST'' magic word from the [[Extension:ExtraMagic|ExtraMagic extension]] to get the category name from the "q" query-string parameter defaulting to [[:Category:Blog items]] (all posts) if none is supplied.
  
Line 130: Line 130:
  
 
The ''format'' and ''template'' parameters specify that we want the results passed to [[Template:BlogItem]] for rendering rather than using any of the native SMW rendering formats. The first parameter sent to the template is always the page title of the result, then subsequent parameters are any additional data that has been requested for the results, in this case the two special properties "Creation date" and "Last editor is". The content of the template is as follows:
 
The ''format'' and ''template'' parameters specify that we want the results passed to [[Template:BlogItem]] for rendering rather than using any of the native SMW rendering formats. The first parameter sent to the template is always the page title of the result, then subsequent parameters are any additional data that has been requested for the results, in this case the two special properties "Creation date" and "Last editor is". The content of the template is as follows:
{{code|<pre>
+
<source>
 
{| class=blog
 
{| class=blog
 
|
 
|
Line 141: Line 141:
 
|{{:{{{1}}}}}
 
|{{:{{{1}}}}}
 
|}
 
|}
</pre>}}
+
</source>
 
This renders the item in a table that can be formatted with CSS with the first row being a linkable title allowing the user to click through to the actual blog item article. The second row is the signature showing the author and date (parameters 2 and 3 sent by the ''#ask'' query). The user name is a link back to the blog page but with a "q" parameter that filters the results to all the posts by that user. The date uses the ''#time'' parser-function to format the date in a more friendly manor.
 
This renders the item in a table that can be formatted with CSS with the first row being a linkable title allowing the user to click through to the actual blog item article. The second row is the signature showing the author and date (parameters 2 and 3 sent by the ''#ask'' query). The user name is a link back to the blog page but with a "q" parameter that filters the results to all the posts by that user. The date uses the ''#time'' parser-function to format the date in a more friendly manor.
  
Line 156: Line 156:
  
 
To set the default behaviour to be a feed, I've simply checked if the ''feed'' query-string parameter is present and if not, set it to "rss" which allows other feed formats to be used, but if none are specified it will default to RSS. Also the ''days'' parameter's default value has been changed here to ''1000'' instead of ''7'' so that there's effectively no limit on the age of posts that are retrieved by the query.
 
To set the default behaviour to be a feed, I've simply checked if the ''feed'' query-string parameter is present and if not, set it to "rss" which allows other feed formats to be used, but if none are specified it will default to RSS. Also the ''days'' parameter's default value has been changed here to ''1000'' instead of ''7'' so that there's effectively no limit on the age of posts that are retrieved by the query.
{{code|<php>
+
<source lang="php">
 
public function __construct() {
 
public function __construct() {
 
global $wgHooks;
 
global $wgHooks;
Line 164: Line 164:
 
parent::__construct( 'BlikiFeed' );
 
parent::__construct( 'BlikiFeed' );
 
}
 
}
</php>}}
+
</source>
  
 
=== Filtering for only blog items ===
 
=== Filtering for only blog items ===
Line 172: Line 172:
  
 
'''NOTE:''' A bug was found whereby our filtering of the query to just the selected category using a right join (see next section) was failing unless the page join also exists. This only exists for logged in users that have the ''rollback'' right though, so I've temporarily added the right before the execution of the query, and then removed it again as soon as it's done. There's probably a better way to do the category filtering though which doesn't require this hack.
 
'''NOTE:''' A bug was found whereby our filtering of the query to just the selected category using a right join (see next section) was failing unless the page join also exists. This only exists for logged in users that have the ''rollback'' right though, so I've temporarily added the right before the execution of the query, and then removed it again as soon as it's done. There's probably a better way to do the category filtering though which doesn't require this hack.
{{code|<php>
+
<source lang="php">
 
public function doMainQuery( $conds, $opts ) {
 
public function doMainQuery( $conds, $opts ) {
 
$opts->add( 'bliki', false );
 
$opts->add( 'bliki', false );
Line 186: Line 186:
 
return $res;
 
return $res;
 
}
 
}
</php>}}
+
</source>
  
  
 
Now we're ready to define our hook function, which checks for the new option and if it's there adjusts the SQL conditions of the query to filter it to only new items and only items within the category specified by the new "bliki" option. The category condition has been updated to allow the ''q'' query-string parameter to be an array of categories. If the value is an array the condition uses is of the format ''cl_to IN ( 'CAT1', 'CAT2'... )'' instead of a simple ''cl_to='CAT' ''condition.
 
Now we're ready to define our hook function, which checks for the new option and if it's there adjusts the SQL conditions of the query to filter it to only new items and only items within the category specified by the new "bliki" option. The category condition has been updated to allow the ''q'' query-string parameter to be an array of categories. If the value is an array the condition uses is of the format ''cl_to IN ( 'CAT1', 'CAT2'... )'' instead of a simple ''cl_to='CAT' ''condition.
{{code|<php>
+
<source lang="php">
 
public static function onSpecialRecentChangesQuery( &$conds, &$tables, &$join_conds, $opts, &$query_options, &$fields ) {
 
public static function onSpecialRecentChangesQuery( &$conds, &$tables, &$join_conds, $opts, &$query_options, &$fields ) {
 
if( $opts->validateName( 'bliki' ) ) {
 
if( $opts->validateName( 'bliki' ) ) {
Line 204: Line 204:
 
return true;
 
return true;
 
}
 
}
</php>}}
+
</source>
  
 
This will construct a final query such as the following. The ''LEFT JOIN'' statement that joins the ''page'' table is the one that causes the new ''RIGHT JOIN'' to fail if it's not included, and it's only included if the user has the ''rollback'' right.
 
This will construct a final query such as the following. The ''LEFT JOIN'' statement that joins the ''page'' table is the one that causes the new ''RIGHT JOIN'' to fail if it's not included, and it's only included if the user has the ''rollback'' right.
Line 220: Line 220:
 
=== Cleaning up the feed description ===
 
=== Cleaning up the feed description ===
 
The first thing we do here is override the ''getFeedObject'' method as that's the method which instantiates the class that will do the main guts of the feed rendering. This method is completely modified and doesn't call the parent version at all, below I've shown just the end of the method which instantiates a new version of the ''ChangesFeed'' class which will have the modified description code. The rest of the method which isn't included here is just creating more appropriate title, description and link fields for the feed as a whole.
 
The first thing we do here is override the ''getFeedObject'' method as that's the method which instantiates the class that will do the main guts of the feed rendering. This method is completely modified and doesn't call the parent version at all, below I've shown just the end of the method which instantiates a new version of the ''ChangesFeed'' class which will have the modified description code. The rest of the method which isn't included here is just creating more appropriate title, description and link fields for the feed as a whole.
{{code|<php>
+
<source lang="php">
 
public function getFeedObject( $feedFormat ) {
 
public function getFeedObject( $feedFormat ) {
 
                . . .
 
                . . .
Line 227: Line 227:
 
return array( $changesFeed, $formatter );
 
return array( $changesFeed, $formatter );
 
}
 
}
</php>}}
+
</source>
  
  
 
Our ''BlikiChangesFeed'' class overrides the ''generateFeed'' method which is the method that defines the fields for the individual feed items for the resulting rows of the main query and calls their output method. Our version of this function is far more simple and compact than the original parent version because many of the conditions present in the parent version will never apply since we're not concerned with diffs or deleted content etc. For the description field of the newly created ''FeedItem'' we call a specialised method called ''desc'' that extracts the summary information from our blog item (parameter one of the call to [[Template:Blog]]) and then parse it to HTML and strip its tags out to give a plain-text result.
 
Our ''BlikiChangesFeed'' class overrides the ''generateFeed'' method which is the method that defines the fields for the individual feed items for the resulting rows of the main query and calls their output method. Our version of this function is far more simple and compact than the original parent version because many of the conditions present in the parent version will never apply since we're not concerned with diffs or deleted content etc. For the description field of the newly created ''FeedItem'' we call a specialised method called ''desc'' that extracts the summary information from our blog item (parameter one of the call to [[Template:Blog]]) and then parse it to HTML and strip its tags out to give a plain-text result.
{{code|<php>
+
<source lang="php">
 
public static function generateFeed( $rows, &$feed ) {
 
public static function generateFeed( $rows, &$feed ) {
 
$feed->outHeader();
 
$feed->outHeader();
Line 251: Line 251:
 
return $desc;
 
return $desc;
 
}
 
}
</php>}}
+
</source>
 
'''Note:''' we also had to override the ''execute'' method with an exact copy of the parent's version because its this method that calls ''generateFeed'', but it's called statically using ''self::generateFeed'' which means that our new version of the method doesn't get called since ''self'' is set to the parent class.
 
'''Note:''' we also had to override the ''execute'' method with an exact copy of the parent's version because its this method that calls ''generateFeed'', but it's called statically using ''self::generateFeed'' which means that our new version of the method doesn't get called since ''self'' is set to the parent class.
  

Revision as of 18:11, 22 May 2015

Bliki 2.0.jpg
I recently worked on a job that required a blog-like functionality to be done in the wiki. I started off basing the system on my Bliki 1.0 method, but started to run into problems implementing a tagging solution (having a tags category of categories, and having each tag page contain a blog-roll query for items of that tag. I got the system to work, but it was messy because the DynamicPageList (DPL) extension that the Bliki functionality is based on ignores includeonly tags and therefore caused all the pages containing blog-roll queries to become categorised into all the tag categories. These query pages could be filtered out of the query results, but it wasn't elegant.

The site I was implementing this functionality on uses Semantic MediaWiki (SMW) for a lot of its other functionality, so I decided to see if I could implement the Bliki functionality with SMW as well and drop DPL all together. This worked out very well and didn't have any messy categorisation problems and was overall a lot more efficient as well.

Note that this method is not really "semantic" because it doesn't make use of properties or types at all, it's purely category-based, but it's SMW that's doing all the work to query these categories and render the results in a way that produces the desired functionality. Following is a description of all the aspects required to reproduce the functionality on other MediaWiki installations.

Dependencies

The bliki system requires a few extensions other than just Semantic MediaWiki (and Validator that SMW depends on).

The following configuration in LocalSettings.php are needed as well (this is in addition to the inclusion of the extensions and their default configuration options).

$wgRawHtml = true;
$wgPFEnableStringFunctions = true;
$smwgQSubcategoryDepth = 0;
$smwgPageSpecialProperties = array( '_CDAT', '_LEDT' );
  • Note1: the $wgRawHtml global has been set to create the blog post form. This is option is only safe if the wiki doesn't allow public editing, otherwise something like HTMLets should be used instead.
  • Note2: $smwgQSubcategoryDepth being set to zero is of particular importance in our installation since the blog functionality must only return direct category members.
  • Note3: $smwgPageSpecialProperties is added to include the creation and last editor information in an article's semantic properties.
  • Note4: A function needs to be added to the UnkownAction hook to for processing the blog-post form. This is explained in detail below.

The bliki extension and content

The bliki system itself requires some code for processing the blog-post form and implementing a feed system more appropriate for blog information. This functionality has been packaged up into Extension:Bliki. A lot of the functionality of the bliki system is in the form of SMW queries and so are defined within the wiki content itself. All the articles that are required to make the bliki system function are members of Category:Bliki and can be downloaded as a single XML export from Special:Export that can then be easily imported for implementation in other wikis.

Following is a detailed step-by-step description of how the system works. Much of the code snippets shown are incomplete and for clarity and explanation only, to view the complete functional version, download Extension:Bliki or view it from our websvn or GitHub mirror.

Blog post form

The easiest place to start with the description of blog functionality is with the post form since that's where it all begins from a users perspective. If the wiki was running the Semantic Forms extension, then that may well be the best approach to constructing a form to create the blog posts. But in this example I'm making a custom form with raw HTML (Note: $wgRawHTML should only ever be used on wikis that don't allow public editing! if you allow untrusted users to edit, then using something like HTMLets of Widgets would be a better approach).

Below is an example of a form created using raw HTML. The form includes a section for tags which are just articles that are members of a "Tags" category. Tags will be described in more detail below, but what we have here in this form regarding tags is an #ask query that calls a template called Checkbox for every one of the members in Category:Tags. Here's the content of the form page which uses the #tag parser-function to allow us to construct the content of an html tag that contains other parser-functions and templates. The REQUEST magic word is from the ExtraMagic extension and returns the specified query-string or post variable which allows the data in the form to persist after preview is clicked.

{{#tag:html|<form class="blog" method="POST">
<b>Title: </b><input name="newtitle" size="50" value="{{REQUEST:newtitle}}" />
<b>Summary</b>
<textarea name="summary" rows="3">{{REQUEST:summary}}</textarea>
<b>Content</b>
<textarea name="content" rows="10">{{REQUEST:content}}</textarea>
<b>Tags</b>
{{#ask:[[Category:Tags]]|format=template|template=Checkbox|link=none}}
<input type="submit" name="type" value="Post" />
<input type="submit" name="type" value="Preview" />
<input type="submit" name="type" value="News preview" />
<input type="hidden" name="action" value="blog" />
</form>}}
       
Blog post form.jpg


And here's the content of the Checkbox template which is called by the #ask query in the form to render a checkbox for every member of the Tags category. It first defines an name for the checkbox input that has any spaces replaced by underscores and prepends "tag" just in case it conflicts with an existing item such as "title", then it builds an input element using the name and adds a checked attribute if the name exists in the post data (i.e. if the item is being previewed.

{{#vardefine:name|tag{{#replace:{{{1}}}| |_}}}}
{{#tag:html|<div class="news-tags-input"><input type="checkbox" name="{{#var:name}}" {{#if:{{REQUEST:{{#var:id}}}}|checked}} />&nbsp;{{{1}}}</div>}}

Processing the form

In form above a hidden value called action has been added which is set to the value "blog", so to create a new blog item from the posted form some PHP needs to be added to the unknown action hook. The main part of the code will be something like the following which creates some article content from the summary and content fields of the form within a Blog template and then adds all the tag categories (as well as a handy Blog item and "Posts by USERNAME" category too). These are wrapped in noinclude tags so that any query pages that embed blog items don't get categorised into all the tag categories.

$wikitext = '{{' . "Blog|1=$summary|2=$content" . '}}';
$wikitext .= "<noinclude>[[Category:News]][[Category:Posts by $user]]";
foreach( array_keys( $_POST ) as $k ) {
	if( preg_match( "|^tag(.+)$|", $k, $m ) ) {
		$wikitext .= '[[Category:' . str_replace( '_', ' ', $m[1] ) . ']]';
	}
}
$wikitext .= "</noinclude>";
$article = new Article( $title );
$article->doEdit( $wikitext, 'Blog item created via post form', EDIT_NEW );
$wgOut->redirect( $title->getFullURL() );

This code is incomplete as it only shows the actual article creation part. The complete version needs to attach to the UnknownAction hook, and needs to allow the preview buttons to work, and to raise a warning if the title is invalid or already exists first. There are two preview buttons so that users can test what it will look like when visiting the page proper or when its rendered within another page in a query. The Blog template renders both cases differently so that the full page has all the content as well as comments, but the "blog rolls" show only the summary and wrap it in a table.

The Blog template

Blog-item articles are created with all the content passed within the Template:Blog which adds the author, tags and post date above the item, and adds a section below for users to add comments (here we use the DISQUS widgit, but the Extension:AjaxComments could also be used). Note that the template returns just the plain summary of the blog-item if the current page is not the actual post item so that queries on posts can decide on their own formatting and don't have all the comments and main content shown for every post in the list.

Template:Blog determines whether the current page is the actual news item rather than a page querying news items by checking if the current article is a member of Category:Blog items since only blog items are automatically categorised into this category by the post-form processing code shown above.

Here's the content of the template, see Template:Blog for the actual wikitext as this has white-space added and has the noinclude section and includeonly tags removed for clarity.

{{#vardefine:item|{{#show:{{FULLPAGENAME}}|?Category:Blog items}}}}
{{#if:{{#var:item}}|
  <div class="blog-sig">
    Posted by [[User:{{REVISIONUSER}}|{{REVISIONUSER}}]]
    on {{#time:d F Y \a\t H:i|{{#show:{{FULLPAGENAME}}|?Creation date}}}}
  </div>
  {{TagList|{{FULLPAGENAME}}}}
}}

{{{1}}}

{{#if:{{#var:item}}|
  {{{2}}}
  {{#widget:DISQUS|id=childrenarewelcome|uniqid={{FULLPAGENAME}}|url={{fullurl:{{FULLPAGENAME}}}}}}
}}

The first line defines a variable called item which is the result of an SMW #show query that test whether the current title is in the Blog items category. At the highest level the rest of the template is three sections, the first and the third are wrapped in conditions so that they only occur if item is true (i.e. the current title is an actual blog item not a query of a list of blog items. The second section is just {{{1}}} which is the summary text. So this means that queries of many blog items will be only the naked summary text and nothing else, where as actual blog item articles will also have the first section preceding the summary text, and the third section following it.

The first section can be broken down into two parts. The first the text that sites below the page title saying who posted the item and at what time and date. The use is simply obtained from the built-in REVISIONUSER magic word, and the date is obtained using the SMW #show query on the special property Creation date and then reformatted with #time provided by the ParserFunctions extension. The second part is a list of all tags the blog item belongs to which uses Template:TagList which we'll look at in detail below.

The third section is two parts, first just {{{2}}} which displays the main text content of the post, and then the next displays a section below the content for user feedback, in this case using the DISQUS widget.

Tags

Blog queries

The main functionality of the blog system is the ability to render a "blogroll", i.e. a list of blog items in summary format ordered by date and all filtered to show only items containing a particular tag or by a particular user. In this "bliki" system I've made s single page for this simply called Blog. The Blog page contains an SMW query that renders the "blogroll" and filters the results to a category if one is passed in the "q" query-string parameter. The query looks like this:

{{#ask:[[Category:{{REQUEST:q|Blog items}}]]
 | ?Creation date
 | ?Last editor is
 | limit       = {{REQUEST:n|10}}
 | sort        = Creation date
 | order       = DESC
 | link        = none
 | searchlabel =
 | format      = template
 | template    = BlogItem
}}

The first line is the #ask query itself which specifies which category to select the blog items from which uses the REQUEST magic word from the ExtraMagic extension to get the category name from the "q" query-string parameter defaulting to Category:Blog items (all posts) if none is supplied.

In the next couple of lines we specify that we need to have the two special properties "Creation date" and "Last editor is" included in the results. The limit parameter specifies the maximum number of posts to display which defaults to 10 but can be overridden with a query-string parameter called "n". The sort and order parameters specify to order by creation date starting with the most recent first. The link parameter specifies that none of the results should be in plain text format rather than using wikitext link syntax. The empty searchlabel parameter prevents the "further results" from showing at the bottom of the page since we'll need to do that a different way to maintain our "blogroll" format.

The format and template parameters specify that we want the results passed to Template:BlogItem for rendering rather than using any of the native SMW rendering formats. The first parameter sent to the template is always the page title of the result, then subsequent parameters are any additional data that has been requested for the results, in this case the two special properties "Creation date" and "Last editor is". The content of the template is as follows:

{| class=blog
|
== [[{{{1}}}]] ==
|-
!Posted by [{{SERVER}}/Blog?q={{urlencode:Posts by {{{3}}}}} {{{3}}}] on {{#time:d F Y \a\t H:i|{{{2}}}}}
|-
|{{TagList|{{{1}}}}}
|-
|{{:{{{1}}}}}
|}

This renders the item in a table that can be formatted with CSS with the first row being a linkable title allowing the user to click through to the actual blog item article. The second row is the signature showing the author and date (parameters 2 and 3 sent by the #ask query). The user name is a link back to the blog page but with a "q" parameter that filters the results to all the posts by that user. The date uses the #time parser-function to format the date in a more friendly manor.

The next table row shows the list of tags associated with the post (the categories it belongs to that are themselves members of Category:Tags). This uses Template:TagList which was also shown above in Template:Blog and will be described in detail below.

And finally the last table row is the content of the post item which is rendered by transcluding the title using {{:{{{1}}}}}. As shown above when explaining how Template:Blog works, this transclusion will just render the plain summary and nothing more. The content, discussion and tags etc are only rendered when visiting the item article itself, when it's transcluded only the summary shows.

RSS feeds

BlikiFeed.jpg
MediaWiki has already done most of the work for us concerning RSS feeds since we can use the SpecialRecentChangesQuery hook to filter the changes to a specific category in conjunction with the "feed" query-string parameter to use RSS or Atom for the output format. Note that the $wgAllowCategorizedRecentChanges setting can't be used for this as we need to filter the items to only newly created pages in addition to the basic category filter (also I don't think that setting works for feeds, but I'm not 100% sure on that).

Just the filtering aspect is not quite perfect though, because also the description of each feed item is a mess containing all the wikitext markup including the {{Blog...}} template syntax. There are no hooks we can use to modify this behaviour so the next best thing is to create a new special page based on the current one with some minor changes made to suit our feed requirements.

So the first thing we need to override in our new class is the constructor since it needs to create the special page with a different name, set the output type to a feed, and install the filter hook.

To set the default behaviour to be a feed, I've simply checked if the feed query-string parameter is present and if not, set it to "rss" which allows other feed formats to be used, but if none are specified it will default to RSS. Also the days parameter's default value has been changed here to 1000 instead of 7 so that there's effectively no limit on the age of posts that are retrieved by the query.

public function __construct() {
	global $wgHooks;
	$wgHooks['SpecialRecentChangesQuery'][] = $this;
	if( !$this->getRequest()->getVal( 'feed' ) ) $this->getRequest()->setVal( 'feed', 'rss' );
	if( !$this->getRequest()->getVal( 'days' ) ) $this->getRequest()->setVal( 'days', 1000 );
	parent::__construct( 'BlikiFeed' );
}

Filtering for only blog items

Before we implement the hook code, we need to set up a mechanism by which the hook code can tell if it's being called from the new BlikiFeed changes or the normal Recent Changes page. There's no way for it to know because the hook function is called statically by the main MediaWiki code, not directly by the class that the hook was run from.

One simple way is to put some kind of an indication into one of the parameters that are passed to the hook, and in this case a useful parameter is $opts which is a FormOptions object containing all the user options set for the recent changes query. Since we need to have information about what category to filter the results to, a useful place to put it is in this object, which also means we can test for the presence of this option to determine whether the hook is being called from the BlikiFeed or RecentChanges. We can override the doMainQuery method which is also called with $opts as a parameter and add our new option before passing execution to the parent classes version of the method.

NOTE: A bug was found whereby our filtering of the query to just the selected category using a right join (see next section) was failing unless the page join also exists. This only exists for logged in users that have the rollback right though, so I've temporarily added the right before the execution of the query, and then removed it again as soon as it's done. There's probably a better way to do the category filtering though which doesn't require this hack.

public function doMainQuery( $conds, $opts ) {
	$opts->add( 'bliki', false );
	$opts['bliki'] = array_key_exists( 'q', $_REQUEST ) ? $_REQUEST['q'] : 'Blog items';

	// Add the rollback right to the user object so that the page join exists, because without it the new category join fails
	$user = $this->getUser();
	$rights = $user->mRights;
	$user->mRights[] = 'rollback';
	$res = parent::doMainQuery( $conds, $opts );
	$user->mRights = $rights;

	return $res;
}


Now we're ready to define our hook function, which checks for the new option and if it's there adjusts the SQL conditions of the query to filter it to only new items and only items within the category specified by the new "bliki" option. The category condition has been updated to allow the q query-string parameter to be an array of categories. If the value is an array the condition uses is of the format cl_to IN ( 'CAT1', 'CAT2'... ) instead of a simple cl_to='CAT' condition.

public static function onSpecialRecentChangesQuery( &$conds, &$tables, &$join_conds, $opts, &$query_options, &$fields ) {
	if( $opts->validateName( 'bliki' ) ) {
		$tables[] = 'categorylinks';
		$conds[] = 'rc_new=1';
		$dbr = wfGetDB( DB_SLAVE );
		if( is_array( $opts['bliki'] ) ) {
			foreach( $opts['bliki'] as $i => $cat ) $opts['bliki'][$i] = Title::newFromText( $cat )->getDBkey();
			$catCond = 'cl_to IN (' . $dbr->makeList( $opts['bliki'] ) . ')';
		} else $catCond = 'cl_to =' . $dbr->addQuotes( Title::newFromText( $opts['bliki'] )->getDBkey() );
		$join_conds['categorylinks'] = array( 'RIGHT JOIN', "cl_from=page_id AND $catCond" );
	}
	return true;
}

This will construct a final query such as the following. The LEFT JOIN statement that joins the page table is the one that causes the new RIGHT JOIN to fail if it's not included, and it's only included if the user has the rollback right.

<mysql>

SELECT rc_id,rc_timestamp,rc_cur_time, ... page_latest,ts_tags FROM `recentchanges` FORCE INDEX (rc_timestamp) LEFT JOIN `watchlist` ON (wl_user = '3' AND (wl_title=rc_title) AND (wl_namespace=rc_namespace)) LEFT JOIN `page` ON ((rc_cur_id=page_id)) LEFT JOIN `tag_summary` ON ((ts_rc_id=rc_id)) RIGHT JOIN `categorylinks` ON ((cl_from=page_id AND cl_to ='Blog_items')) WHERE (rc_timestamp >= '20111020000000') AND rc_bot = '0' AND (rc_new=1) ORDER BY rc_timestamp DESC LIMIT 50 </mysql>

Cleaning up the feed description

The first thing we do here is override the getFeedObject method as that's the method which instantiates the class that will do the main guts of the feed rendering. This method is completely modified and doesn't call the parent version at all, below I've shown just the end of the method which instantiates a new version of the ChangesFeed class which will have the modified description code. The rest of the method which isn't included here is just creating more appropriate title, description and link fields for the feed as a whole.

public function getFeedObject( $feedFormat ) {
	                 . . .
	$changesFeed = new BlikiChangesFeed( $feedFormat, 'rcfeed' );
	$formatter = $changesFeed->getFeedObject( $title, $desc, $url );
	return array( $changesFeed, $formatter );
}


Our BlikiChangesFeed class overrides the generateFeed method which is the method that defines the fields for the individual feed items for the resulting rows of the main query and calls their output method. Our version of this function is far more simple and compact than the original parent version because many of the conditions present in the parent version will never apply since we're not concerned with diffs or deleted content etc. For the description field of the newly created FeedItem we call a specialised method called desc that extracts the summary information from our blog item (parameter one of the call to Template:Blog) and then parse it to HTML and strip its tags out to give a plain-text result.

public static function generateFeed( $rows, &$feed ) {
	$feed->outHeader();
	foreach( $rows as $obj ) {
		$title = Title::makeTitle( $obj->rc_namespace, $obj->rc_title );
		$url = $title->getFullURL();
		$item = new FeedItem( $title->getPrefixedText(), self::desc( $title ), $url, $obj->rc_timestamp, $obj->rc_user_text, $url );
		$feed->outItem( $item );
	}
	$feed->outFooter();
}

static function desc( $title ) {
	global $wgParser;
	$article = new Article( $title );
	$content = $article->getContent();
	$desc = preg_match( "/^.+?1=(.+?)\|2=/", $content, $m ) ? $m[1] : $title->getText();
	$desc = strip_tags( $wgParser->parse( $desc, $title, new ParserOptions(), true, true )->getText() );
	return $desc;
}

Note: we also had to override the execute method with an exact copy of the parent's version because its this method that calls generateFeed, but it's called statically using self::generateFeed which means that our new version of the method doesn't get called since self is set to the parent class.

Notes

  • Special:Newpages may be a better base-class for the bliki feed since it's already based on new pages rather than changes.