Slash's Wiki Plugin
Pages: 1, 2, 3, 4
Special Features
There are a handful of other convenient features Wikis often have. First, it's nice to be able to search for pages by title without having to edit URI query strings. This is very simple, as the search feature is already built into the page displaying logic. Another nice feature lists the most recently modified pages, and is a simple database query. The final special feature is related, and allows users to browse the last several versions of a page.
Other Wikis have additional features, such as searching within page text and deleting and restoring versions of a page, but those aren't in SlashWiki, yet. (It would also be nice to be able to promote a page to a Story, but that's probably further down the road.)
Formatting Rules
The basic Wiki formatting rules are very simple. Newlines are handled automatically. Words of the StudlyCaps form are automatically linked. Anything wrapped in two single quotes (''libre'') is marked as emphasized, and anything wrapped in three single quotes ('''sucrose''') is marked as strong. Lists are indented one tabstop or four spaces, and list items start with an asterisk (for simple bullet points) or a number or a letter (for ordered lists). Anything else indented four spaces or one tabstop is considered to be code, or preformatted text, and will be rendered in a monospaced font as is.
Whew.
Since I already had a Wiki formatting processor lying around
(from the book-sidetracked Jellybean
project), I generalized it and made it into a module called
Text::WikiFormat that will wing its way to the CPAN shortly. As it passes the
unit tests, we'll consider this to be a solved problem.
This is only important for one additional thing--extended
linking semantics.
If you enable this feature (more on this in a moment),
people will also be able to create links by wrapping them in
square brackets, like [WebmistressHannah]. This is, admittedly,
the only way I can create links to my pseudonym. Another
feature shamelessly stolen from Everything is the idea of
optional link titles. If there's a pipe in the brackets,
everything afterwards is considered a title. A link such as
[ProgrammerSunny|my best student] would link to the
ProgrammerSunny page but appear in text as my best student.
Plugin Architecture
With all of that explanation out of the way, the plugin
itself is really very simple. It consists of one applet file
(standard Perl program that becomes a mod_perl
handler), one module (the Wiki formatter), seven templates, and
two SQL files.
The Database
As the Wiki pages are tied very closely to the database, the
first step in creating the plugin was to define the storage
schema. There are two tables, wikipage and
wikitext:
CREATE TABLE wikipage ( title varchar(255) NOT NULL UNIQUE, version smallint UNSIGNED, wid mediumint UNSIGNED NOT NULL AUTO_INCREMENT, KEY (wid) ); CREATE TABLE wikitext ( wid mediumint UNSIGNED NOT NULL, version smallint UNSIGNED NOT NULL, uid mediumint UNSIGNED NOT NULL, raw text DEFAULT '' NOT NULL, date datetime, description varchar(255) DEFAULT '' NOT NULL, cooked text, KEY pagever (wid, version) );
As you can see, the wikipage table stores only
the page title, the current version of the page, and a unique
identifier, used to join to the wikitext
table.
wikitext has the identifier, of course, and
also holds the raw (Wiki-formatted) and cooked (HTML-formatted)
versions of a page. In addition, it contains the uid of the
user who created the particular page version (found in the user
tables), a description of the version, the date at which the
version was created, and a version key. Each version of a page
is stored in this table, and can be retrieved with the wid and
version.
The trick here was to make things flexible enough to support the basic Wiki features while not imposing arbitrary limitations and still trying to keep performance high. As Wiki pages can be 64 kilobytes in length, I tried to keep page metadata in a separate table from the text itself. (Slash uses this technique.) I ended up with two tables, one that tracks the basic information current version of the page, and another that tracks all revisions. If performance is still a problem, another table could be created just to hold raw and cooked text. Efficient normalizers will do that, but it didn't have much payoff in my very small tests, and would complicate my examples. Patches welcome.
Normally, I'd use the MySQL datestamp column
type, but Slash uses the datetime type internally.
While the latter isn't automatically updated, it does have the
benefit of working with Slash's timeCalc()
function. That's enough of a benefit to do things
differently.
Thinking about database access, reading the current version
of a page is easy. We just have to join the two tables on the
wid field. Reading a specific version is also easy, as the
version number can be a constraint on the wikitext
table. Writes are a little trickier. We need to know the
current version of a page and either insert a row into the
wikipage table, or update a row. For a write,
we'll always insert a row into the wikitext table,
but we do need to know the version number. Still, it's not
difficult.
The wiki.pl Applet
All access to the Wiki takes place through the
wiki.pl applet. (In Slash terms, it is also called a
``program'', ``script'', or ``page''. None of these names are
completely descriptive, so we'll pick the first, alphabetically
speaking.)
Another ancient Slash convention is the use of the
op parameter to dispatch to different operations. That
is, any applet that does multiple things generally has a
dispatch table keyed on the value of the op parameter to decide
what to do. wiki.pl uses the %ops
variable for this, and the brains of the applet are:
%ops = (
update => \&update,
list => \&list,
display => \&display,
);
main();
return();
sub main {
my $form = getCurrentForm();
my $op = $form->{op};
unless (exists $ops{$op}) {
$op = 'display';
}
$ops{$op}->($form);
}
Whenever a request comes in, the applet reads the parsed CGI
form variables into the $form variable with the
getCurrentForm() command. These variables are
parsed at an earlier stage of the request cycle. The
op parameter must be verified against the operations we
support. Otherwise, the default operation is to display a page.
The applet calls the appropriate subroutine and then returns.
The return() call exists because mod_perl will
turn this into an anonymous subroutine. For something this
simple, we can probably get away without it, but it's better to
be explicit to avoid nasty surprises later.
The display()
Function
The main guts of displaying a Wiki page live in
display(). First, it must set up some variables. These
are the form parameters from main(), as well as
the database and the site configuration variables:
my $form = shift;
my $slashdb = getCurrentDB();
my $constants = getCurrentStatic();
At this point, it's possible to add access checks, turning
away anonymous users (with the isAnon() function)
or comparing the user's seclev against a configuration variable
like $constants->{wikiReadSeclev}. By
default, access is open to everyone.
Next, the sub needs to decide which page to display. If the
user has specified one, it will be in the page
parameter. Otherwise, use the default page specified in site
configuration variables. If that doesn't exist, fall back to
the default page, and print a header with the
chosen page:
my $page = $form->{page} || $constants->{wikiDefaultPage} || 'default';
header("SlashWiki: $page");
The only other parameter that matters is the
version parameter. With it, the user is requesting a
specific version of the page. Without it, the user wants to see
the latest version. This is used to determine what kind of
WHERE clause to use when requesting a page from the
database:
my $version = $form->{version} || '';
$version = 0 if $version =~ /\D/;
my $where = "title = " . $slashdb->sqlQuote($page) .
" AND wikipage.wid = wikitext.wid ";
if ($version) {
$where .= "AND wikitext.version = " . $slashdb->sqlQuote($version);
} else {
$where .= "AND wikipage.version = wikitext.version ";
}
Retrieval is done with the sqlSelect() method
of the Slash::DB object stored in
$slashdb. As with nearly all of Slash's database
operations, the first argument is the field list, the second is
a table list, the third is an optional WHERE clause, and the
fourth is an optional extra clause (you'll see one later):
my %page_data = (
title => $page,
);
@page_data{qw(raw cooked nickname date wid version curver)} =
$slashdb->sqlSelect('raw, cooked, nickname, date, wikipage.wid,' .
'wikitext.version, wikipage.version',
'wikipage, wikitext, users', $where
);
This fills out the %page_data hash with fields
to be passed to a template. Before that happens, two things
must be changed. First, because the user might want to view an
old version of a page, we need to suppress editing. It's not
right to have someone modify the historical record, even
accidentally. If the user requested a page version and that
version isn't the same as the current version, mark the page as
readonly:
$page_data{readonly} = ($version && $version == $page_data{curver});
The final modification is to mark up the date in the user's
preferred timezone and format. This is the
timeCalc() subroutine mentioned earlier:
$page_data{date} = timeCalc($page_data{date});
All that's left to be done is to display the wikiView template with the page data, and to display the page footer. Both are very simple:
slashDisplay('wikiView', \%page_data);
footer();