[Home]WikiPatches/HierarchicalUsemodWiki

UseModWiki | WikiPatches | RecentChanges | Preferences

Hierarchical Usemod Wiki

Description

This patch allows wiki pages to be organized into a tree.

The tree has a virtual root, called top. A page can have a single parent, and any number of children. If a page's parent is top, the page is on the top of the hierarchy. In turn, if another page's parent is this toplevel page, that page will be on the second level of the hierarchy, and so on.

The hierarchy is useful for navigation, and is displayed beside the page content. This is similar to the way content management systems work, and in fact the screenshot that you see below was inspired by Plone (http://plone.org), an open source CMS.

The following screenshot demonstrates how usemod looks after this patch is applied:

How does it work

Attributes about the pages are stored inside the text of the page itself. The following attributes are used:

So, in order to make a page a toplevel page in the hierarchy, start it with the following line:

    <parent top> <title Start here> <icon exclamation.gif>

This might look confusing, but it's really simple, once you get the hang of it.

Installation

The installation of this modification is a little more involved than simply applying a patch. You need to do the following steps:

  1. Create a directory in the web server for the icons. You need to put at least two icons there: folder_icon.gif and document_icon.gif.
  2. Create an empty file in the $DataDir, called metadata. This file must exist, or the patched wiki will not run.
  3. Add the following variables to the config file (also in the $DataDir): $IconDir? $LeftColumnTop? $LeftColumnBottom?. Edit the to suit your needs. $IconDir should be a (relative or absolute) path to the directory where the new icons reside (it must end with a / slash). $LeftColumnTop and $LeftColumnBottom should be some HTML code which will be added before and after the navigation view. These can be empty.
  4. Edit the CSS of your wiki (see: $StyleSheet), see below.
  5. Apply the patch to your wiki script
  6. After you apply the patch, run your script with action=maintain (see AdminFeatures and Actions). This will create the metadata in your database directory. If the metadata goes out of synch (shouldn't happen under normal circumstances), re-run maintain on your site.

Notes

The hierarchy can be as deep as desired. This does not allow usemod SubPages to be deeper than usual (2 levels), this hierarchy is independent of the subpages. This patch can be used to organize the normally "flat" wiki pages into a tree.

The patch makes usemod better suited for company knowledge bases, and in fact was developed for my current employer.

The patch

CSS changes

Add the following to your CSS:

.wikifooter, .spacer { clear: both; }
.spacer { clear: both; }
#mainColumn { margin-left: 200px; padding: 10px; }
#leftColumn { width: 190px; float: left; font-size: 75%; padding: 5px; }
#leftColumn UL { padding: 0; margin: 0 0 0 1em; list-style-type: none; }
#leftColumn LI { padding: 0; margin: 0; }
#leftColumn LI DIV { background-repeat: no-repeat; background-position: 0 0.5em; }
#leftColumn A { padding: 0.5em; text-decoration: none; padding-left: 24px; display: block; }
#leftColumn .currentItem A { color: #000; font-weight: bold; }

Script changes

--- OriginalUsemodWiki.pl	2003-09-11 14:21:02.000000000 +0200
+++ HierarchicalUsemodWiki.pl	2006-04-27 17:58:22.000000000 +0200
@@ -63,6 +63,8 @@
   $q $Now $UserID $TimeZoneOffset $ScriptName $BrowseCode $OtherCode
   $AnchoredLinkPattern @HeadingNumbers $TableOfContents $QuotedFullUrl
   $ConfigError $UploadPattern );
+  
+use vars qw(%Metadata $IconDir $LeftColumnTop $LeftColumnBottom $UpperLetter $LowerLetter);
 
 # == Configuration =====================================================
 $DataDir     = "/tmp/mywikidb"; # Main wiki directory
@@ -89,6 +91,8 @@
 $NotFoundPg  = "";              # Page for not-found links ("" for blank pg)
 $EmailFrom   = "Wiki";          # Text for "From: " field of email notes.
 $SendMail    = "/usr/sbin/sendmail";  # Full path to sendmail executable
+$LeftColumnTop = '';            # HTML above the navigation in the left col
+$LeftColumnBottom = '';         # HTML after the navigation in the left col
 $FooterNote  = "";              # HTML for bottom of every page
 $EditNote    = "";              # HTML notice above buttons on edit page
 $MaxPost     = 1024 * 210;      # Maximum 210K posts (about 200K for pages)
@@ -208,6 +212,7 @@
 $RcOldFile   = "$DataDir/oldrclog"; # Old RecentChanges logfile
 $IndexFile   = "$DataDir/pageidx";  # List of all pages
 $EmailFile   = "$DataDir/emails";   # Email notification lists
+$IconDir     = "./";                # Path to icons (must have trailing slash!)
 
 if ($RepInterMap) {
   push @ReplaceableFiles, $InterFile;
@@ -242,7 +247,7 @@
 
 # == Common and cache-browsing code ====================================
 sub InitLinkPatterns {
-  my ($UpperLetter, $LowerLetter, $AnyLetter, $LpA, $LpB, $QDelim);
+  my ($AnyLetter, $LpA, $LpB, $QDelim);
 
   # Field separators are used in the URL-style patterns below.
   if ($NewFS) {
@@ -416,6 +421,7 @@
   %InterSite = ();
   $MainPage = ".";       # For subpages only, the name of the top-level page
   $OpenPageName = "";    # Currently open page
+  &LoadMetadata();
   &CreateDir($DataDir);  # Create directory if it doesn't exist
   if (!-d $DataDir) {
     &ReportError(Ts('Could not create %s', $DataDir) . ": $!");
@@ -540,7 +546,7 @@
   }
   $MainPage = $id;
   $MainPage =~ s|/.*||;  # Only the main page name (remove subpage)
-  $fullHtml = &GetHeader($id, &QuoteHtml($id), $oldId);
+  $fullHtml = &GetHeader($id, '', $oldId);
   if ($revision ne '') {
     if (($revision eq $Page{'revision'}) || ($goodRevision ne '')) {
       $fullHtml .= '<b>' . Ts('Showing revision %s', $revision) . "</b><br>";
@@ -985,7 +991,7 @@
   my ($id) = @_;
   my ($html, $canEdit, $row, $newText);
 
-  print &GetHeader('', Ts('History of %s', $id), '') . '<br>';
+  print &GetHeader($id, Ts('History of %s', $id), '') . '<br>';
   &OpenPage($id);
   &OpenDefaultText();
   $newText = $Text{'text'};
@@ -1082,6 +1088,197 @@
   return $html;
 }
 
+sub GetPageMetadata
+{
+  my ($id) = @_;
+  my ($metadata, $title);
+  
+  $title = $id;
+  $title =~ s/($LowerLetter)($UpperLetter)/$1 $2/g;
+
+  unless (defined $Metadata{$id}) {
+    return { 'title' =>$title, 'children' => [], };
+  }
+	
+  $metadata = {
+    'children' => [],
+    %{$Metadata{$id}}
+  };
+  $metadata->{title} = $title unless $metadata->{title};
+  return $metadata;
+}
+
+sub SetPageMetadata
+{
+  my ($id, $text) = @_;
+  my ($title, $parent, $icon, $oldmetadata, $oldparent, $children);
+  
+  ($title) = $text =~ /<title\s+([^>]+)\s*>/;
+  ($parent) = $text =~ /<parent\s+([^>]+)\s*>/;
+  ($icon) = $text =~ /<icon\s+([^>]+)\s*>/;
+  
+  unless ($parent) {
+    ($parent) = $text =~ /\[\[([^\]]*)\]\]\s*$/s;
+  }
+  
+  $oldmetadata = &GetPageMetadata($id);
+  $oldparent = $oldmetadata->{parent};
+  
+  unless ($oldparent eq $parent) {
+    if ($oldparent) {
+      $oldmetadata = &GetPageMetadata($oldparent);
+      @{$oldmetadata->{children}} = ( grep($_ ne $id, @{$oldmetadata->{children}}) );
+    }
+    if ($parent) {
+      $oldmetadata = &GetPageMetadata($parent);
+      @{$oldmetadata->{children}} = sort ( @{$oldmetadata->{children}}, $id );
+    }
+  }
+  
+  $Metadata{$id} = {} unless exists $Metadata{$id};
+  
+  $Metadata{$id} = {
+    %{$Metadata{$id}},
+    'title' => $title,
+    'parent' => $parent,
+    'icon' => $icon,
+  };
+}
+
+sub WriteScalar
+{
+  my ($s) = @_;
+  
+  return "''" unless defined $s;
+  $s =~ s/'/\\'/g;
+  return "'$s'";
+}
+
+sub WriteList
+{
+  my ($l) = @_;
+  my ($item, $result);
+  
+  $result = '[';
+  foreach  $item (@{$l}) {
+    $result .= &WriteScalar($item) . ',';
+  }
+  $result .= ']';
+}
+
+sub GetHierarchy {
+  my ($id) = @_;
+  my (%expanded, $parent);
+  
+  $parent = $id;
+  
+  while (not exists $expanded{$parent}) {
+    $expanded{$parent} = 1;
+    $parent = &GetPageMetadata($parent)->{parent};
+    last unless defined $parent;
+  }
+  
+  return &GetHierarchyFrom('top', $id, \%expanded, 0);
+}
+
+sub GetHierarchyListItem {
+  my ($id, $metadata, $iscurrent, $ishidden) = @_;
+  my ($result, $icon) = @_;
+  
+  $result = '<LI>';
+  $icon = $metadata->{icon};
+  unless ($icon) {
+    if ( scalar @{$metadata->{children}} ) {
+      $icon = 'folder_icon.gif';
+    } else {
+      $icon = 'document_icon.gif';
+    }
+  }
+  if ($icon) {
+    $icon = &QuoteHtml($icon);
+    $icon =~ s/\"/&quot;/g;
+    $icon =~ s/\)/&\#41;/g;
+    $result .= "<div style=\"background-image: url(http://anonymouse.org/cgi-bin/anon-www.cgi/http://www.usemod.com/cgi-bin/$IconDir$icon)\">";
+  }
+  if ($iscurrent) {
+    $result .= '<span class=currentItem>' . &GetPageLinkText($id, $metadata->{title}) . '</span>';
+  } else {
+    $result .= &GetPageLinkText($id, $metadata->{title});
+  }
+  if ($icon) {
+    $result .= "</div>";
+  }
+  return $result;
+}
+
+sub GetHierarchyFrom {
+  my ($id, $top, $expanded, $level) = @_;
+  my ($result, $metadata, $child, $children, $childmetadata, $icon);
+  
+  $result = '';
+  $metadata = &GetPageMetadata($id);
+  $children = $metadata->{children};
+  
+  if ( scalar @{$children} and $level < 10 ) {
+    $result = "<UL>\n";
+    foreach $child (@{$children}) {
+      $childmetadata = &GetPageMetadata($child);
+      $result .= &GetHierarchyListItem($child, $childmetadata, $child eq $top);
+      $result .= &GetHierarchyFrom($child, $top, $expanded, $level + 1) if exists $expanded->{$child};
+      $result .= "</LI>\n";
+    }
+    $result .= "</UL>\n";
+  }
+  return $result;
+}
+
+sub LoadMetadata
+{
+  my ($data);
+
+  %Metadata = ();
+  $data = &ReadFileOrDie("$DataDir/metadata");
+  eval $data; die $@ if $@;
+}
+
+sub SaveMetadata
+{
+  my ($id, $metadata);
+  
+  open (OUT, ">$DataDir/metadata") or die(T('can not write metadata'));
+  print OUT "\%Metadata = (\n";
+  
+  while (($id, $metadata) = each %Metadata) {
+    print OUT " '$id' => {",
+      "  'title' => ", &WriteScalar( $metadata->{title} ),
+      ", 'icon' => ", &WriteScalar( $metadata->{icon} ),
+      ", 'parent' => ", &WriteScalar( $metadata->{parent} ),
+      ", 'children' => ", &WriteList( $metadata->{children} ),
+      " }, \n";
+  }
+  
+  print OUT ");\n";
+  close OUT;
+}
+
+sub RebuildMetadata
+{
+  my ($id, @allpageslist);
+  
+  @allpageslist = &AllPagesList();
+  %Metadata = ();
+  
+  foreach $id (@allpageslist, 'top') {
+    $Metadata{$id} = { 'id'=>$id, 'children' => [] };
+  }
+  
+  foreach $id (@allpageslist) {
+    &OpenPage($id);
+    &OpenDefaultText();
+    &SetPageMetadata($id, $Text{'text'});
+  }
+}
+
 # ==== HTML and page-oriented functions ====
 sub ScriptLinkChar {
   if ($SlashLinks) {
@@ -1291,7 +1488,7 @@
   if ($FreeLinks) {
     $title =~ s/_/ /g;   # Display as spaces
   }
-  $result .= &GetHtmlHeader("$SiteName: $title");
+  $result .= &GetHtmlHeader("$SiteName: " . ($title ? $title : &QuoteHtml($id)));
   return $result  if ($embed);
 
   $result .= '<div class=wikiheader>';
@@ -1306,7 +1503,7 @@
     }
     $header = &ScriptLink($HomePage, "<$logoImage>");
   }
-  if ($id ne '') {
+  if ($id ne '' and $title eq '') {
     $result .= $q->h1($header . &GetBackLinksSearchLink($id));
   } else {
     $result .= $q->h1($header . $title);
@@ -1315,6 +1512,9 @@
     $result .= &GetGotoBar($id) . "<hr class=wikilineheader>";
   }
   $result .= '</div>';
+  $result .= "\n<div id=leftColumn>\n$LeftColumnTop\n" . &GetHierarchy($id) . "\n$LeftColumnBottom\n</div>\n";
+  $result .= "<div id=mainColumn>\n";
+
   return $result;
 }
 
@@ -1379,14 +1579,20 @@
   return $html;
 }
 
+sub GetEndOfMainColumn {
+  return "<div class=spacer> &nbsp; </div>\n</div>\n";
+}
+
 sub GetFooterText {
   my ($id, $rev) = @_;
   my $result;
 
+  $result = &GetEndOfMainColumn();
+
   if (&GetParam('embed', $EmbedWiki)) {
-    return $q->end_html;
+    return $result . $q->end_html;
   }
-  $result = '<div class=wikifooter>';
+  $result .= '<div class=wikifooter>';
   $result .= &GetFormStart();
   $result .= &GetGotoBar($id);
   if (&UserCanEdit($id, 0)) {
@@ -1443,14 +1649,16 @@
     $result .= T($FooterNote);
   }
   $result .= '</div>';
-  $result .= &GetMinimumFooter();
+  $result .= $q->end_html;
   return $result;
 }
 
 sub GetCommonFooter {
   my ($html);
 
-  $html = '<hr class=wikilinefooter>' . '<div class=wikifooter>'
+  $html = &GetEndOfMainColumn();
+
+  $html .= '<hr class=wikilinefooter>' . '<div class=wikifooter>'
           . &GetFormStart() . &GetGotoBar('')
           . &GetSearchForm() . $q->endform;
   if ($FooterNote ne '') {
@@ -1461,7 +1669,7 @@
 }
 
 sub GetMinimumFooter {
-  return $q->end_html;
+  return &GetEndOfMainColumn() . $q->end_html;
 }
 
 sub GetFormStart {
@@ -1570,6 +1778,9 @@
   if ($RawHtml) {
     $pageText =~ s/<html>((.|\n)*?)<\/html>/&StoreRaw($1)/ige;
   }
+  $pageText =~ s/<title[^>]*>//;
+  $pageText =~ s/<icon[^>]*>//;
+  $pageText =~ s/<parent[^>]*>//;
   $pageText = &QuoteHtml($pageText);
   $pageText =~ s/\\ *\r?\n/ /g;          # Join lines with backslash at end
   if ($ParseParas) {
@@ -3182,7 +3393,7 @@
     $id = &FreeToNormal($id);  # Take care of users like Markus Lude :-)
   }
   if (!&UserCanEdit($id, 1)) {
-    print &GetHeader("", T('Editing Denied'), "");
+    print &GetHeader($id, T('Editing Denied'), "");
     if (&UserIsBanned()) {
       print T('Editing not allowed: user, ip, or network is blocked.');
       print "<p>";
@@ -3217,7 +3428,7 @@
   }
   $editRows = &GetParam("editrows", 20);
   $editCols = &GetParam("editcols", 65);
-  print &GetHeader('', &QuoteHtml($header), '');
+  print &GetHeader($id, &QuoteHtml($header), '');
   if ($revision ne '') {
     print "\n<b>"
           . Ts('Editing old revision %s.', $revision) . "  "
@@ -3300,12 +3511,13 @@
     print "<h2>", T('Preview only, not yet saved'), "</h2>\n";
     print '</div>';
   }
+  print $q->endform;
+  print &GetEndOfMainColumn();
   print '<div class=wikifooter>';
   print &GetHistoryLink($id, T('View other revisions')) . "<br>\n";
   print &GetGotoBar($id);
-  print $q->endform;
   print '</div>';
-  print &GetMinimumFooter();
+  print $q->end_html;
 }
 
 sub GetTextArea {
@@ -3404,12 +3616,13 @@
         &GetFormText('stylesheet', "", 30, 150);
   print '<br>', $q->submit(-name=>'Save', -value=>T('Save')), "\n";
   print '</div>';
+  print &GetEndOfMainColumn();
   print "<hr class=wikilinefooter>\n";
   print '<div class=wikifooter>';
   print &GetGotoBar('');
   print $q->endform;
   print '</div>';
-  print &GetMinimumFooter();
+  print $q->end_html;
 }
 
 sub GetFormText {
@@ -3660,9 +3873,10 @@
     print Ts('Login for user ID %s failed.', $uid);
   }
   print "<hr class=wikilinefooter>\n";
+  print &GetEndOfMainColumn();
   print &GetGotoBar('');
   print $q->endform;
-  print &GetMinimumFooter();
+  print $q->end_html;
 }
 
 sub GetNewUserId {
@@ -3990,6 +4204,8 @@
   $Section{'host'} = &GetRemoteHost(1);
   &SaveDefaultText(); 
   &SavePage();
+  &SetPageMetadata($id, $string);
+  &SaveMetadata();
   &WriteRcLog($id, $summary, $isEdit, $editTime, $Section{'revision'},
               $user, $Section{'host'});
   if ($UseCache) {
@@ -4274,6 +4490,8 @@
     $status = &TrimRc();  # Consider error messages?
     &ReleaseLock();
   }
+  &RebuildMetadata();
+  &SaveMetadata();
   print &GetCommonFooter();
 }
 
@@ -4550,6 +4768,8 @@
     &UpdateLinksList($commandList, $doRC, $doText);
     print "<p>Finished command list.";
   }
+  &RebuildMetadata();
+  &SaveMetadata();
   print &GetCommonFooter();
 }
 
@@ -4613,6 +4833,9 @@
   unlink($IndexFile)  if ($UseIndex);
   &EditRecentChanges(1, $page, "")  if ($doRC);  # Delete page
   # Currently don't do anything with page text
+  &SetPageMetadata($page, '');
+  delete $Metadata{$page};
+  &SaveMetadata();
 }
 
 # Given text, returns substituted text

UngarPeter


Comments

(db) Very interesting concept.

I see you have programmed a function to create a page. Would it be possible to put the patch on the Wiki patches? This is something that could certainly be put in the next Usemod version. Excellent idea! Can we create pages in a sub-group?

My friend and I are working on a new wiki and we'll try your patch.

Since the outline on the left window can get very big, we'll put a sort of research box right on the top of the outline window in order to find a word very quickly in the outline. We'll send you the code if you are interested.

Thanks Peter!


UseModWiki | WikiPatches | RecentChanges | Preferences
Edit text of this page | View other revisions | Search MetaWiki
Last edited April 7, 2007 10:03 pm by youhide.com (diff)
Search: