Add updated bug tracking example.
[ikiwiki] / doc / todo / structured_page_data.mdwn
1 This is an idea from [[JoshTriplett]].  --[[Joey]]
2
3 Some uses of ikiwiki, such as for a bug-tracking system (BTS), move a bit away from the wiki end
4 of the spectrum, and toward storing structured data about a page or instead
5 of a page. 
6
7 For example, in a bug report you might want to choose a severity from a
8 list, enter a version number, and have a bug submitter or owner recorded,
9 etc. When editing online, it would be nice if these were separate fields on
10 the form, rather than the data being edited in the big edit form.
11
12 There's a tension here between remaining a wiki with human-editable source
13 files, containing freeform markup, and more structured data storage. I
14 think that it would be best to include the structured data in the page,
15 using a directive. Something like:
16
17         part of page content
18         \[[data yaml="<arbitrary yaml here>"]]
19         rest of page content 
20
21 As long as the position of the directive is not significant, it could be
22 stripped out when web editing, the yaml used to generate/populate form fields, 
23 and then on save, the directive regenerated and inserted at top/bottom of
24 the page.
25
26 Josh thinks that yaml is probably a good choice, but the source could be a
27 `.yaml` file that contains no directives, and just yaml. An addition
28 complication in this scenario is, if the yaml included wiki page formatted content,
29 ikiwiki would have to guess or be told what markup language it used.
30
31 Either way, the yaml on the page would encode fields and their current content.
32 Information about data types would be encoded elsewhere, probably on a
33 parent page (using a separate directive). That way, all child pages could
34 be forced to have the same fields.
35
36 There would be some simple types like select, boolean, multiselect, string, wiki markup.
37 Probably lists of these (ie, list of strings). Possibly more complex data
38 structures.
39
40 It should also be possible for plugins to define new types, and the type
41 definitions should include validation of entered data, and how to prompt
42 the user for the data.
43
44 This seems conceptually straightforward, if possibly quite internally
45 complex to handle the more complicated types and validation.
46
47 One implementation wrinkle is how to build the html form. The editpage.tmpl
48 currently overrides the standard [[!cpan CGI::FormBuilder]] generated form,
49 which was done to make the edit page be laid out in a nice way. This,
50 however, means that new fields cannot be easily added to it using
51 [[!cpan CGI::FormBuilder]]. The attachment plugin uses the hack of bouilding
52 up html by hand and dumping it into the form via a template variable. 
53
54 It would be nice if the type implementation code could just use
55 FormBuilder, since its automatic form generation, and nice field validation
56 model is a perfect match for structured data. But this problem with
57 editpage.tmpl would have to be sorted out to allow that.
58
59 Additional tie-ins:
60
61 * Pagespecs that can select pages with a field with a given value, etc.
62   This should use a pagespec function like field(fieldname, value).  The
63   semantics of this will depend on the type of the field; text fields will
64   match value against the text, and link fields will check for a link
65   matching the pagespec value.
66 * The search plugin could allow searching for specific fields with specific
67   content. (xapian term search is a good fit).
68
69 See also:
70
71 [[tracking_bugs_with_dependencies]]
72
73 > I was also thinking about this for bug tracking.  I'm not sure what
74 > sort of structured data is wanted in a page, so I decided to brainstorm
75 > use cases:
76 >
77 > * You just want the page to be pretty.
78 > * You want to access the data from another page.  This would be almost like
79 >     like a database lookup, or the OpenOffice Calc [VLookup](http://wiki.services.openoffice.org/wiki/Documentation/How_Tos/Calc:_VLOOKUP_function) function.
80 > * You want to make a pagespec depend upon the data.  This could be used
81 >    for dependancy tracking - you could match against pages listed as dependencies,
82 >    rather than all pages linked from a given page.
83 >
84 >The first use case is handled by having a template in the page creation.  You could
85 >have some type of form to edit the data, but that's just sugar on top of the template.
86 >If you were going to have a web form to edit the data, I can imagine a few ways to do it:
87 >
88 > * Have a special page type which gets compiled into the form.  The page type would
89 >    need to define the form as well as hold the stored data.
90 > * Have special directives that allow you to insert form elements into a normal page.
91 >
92 >I'm happy with template based page creation as a first pass...
93 >
94 >The second use case could be handled by a regular expression directive. eg:
95 >
96 > \[[regex spec="myBug" regex="Depends: ([^\s]+)"]]
97 >
98 > The directive would be replaced with the match from the regex on the 'myBug' page... or something.
99 >
100 >The third use case requires a pagespec function.  One that matched a regex in the page might work.
101 >Otherwise, another option would be to annotate links with a type, and then check the type of links in
102 >a pagespec.  e.g. you could have `depends` links and normal links.
103 >
104 >Anyway, I just wanted to list the thoughts.  In none of these use cases is straight yaml or json the
105 >obvious answer.  -- [[Will]]
106
107 >> Okie.  I've had a play with this.  A 'form' plugin is included inline below, but it is only a rough first pass to
108 >> get a feel for the design space.
109 >>
110 >> The current design defines a new type of page - a 'form'.  The type of page holds YAML data
111 >> defining a FormBuilder form.  For example, if we add a file to the wiki source `test.form`:
112
113     ---
114     fields:
115       age:
116         comment: This is a test
117         validate: INT
118         value: 15
119
120 >> The YAML content is a series of nested hashes.  The outer hash is currently checked for two keys:
121 >> 'template', which specifies a parameter to pass to the FromBuilder as the template for the
122 >> form, and 'fields', which specifies the data for the fields on the form.
123 >> each 'field' is itself a hash.  The keys and values are arguments to the formbuilder form method.
124 >> The most important one is 'value', which specifies the value of that field.
125 >>
126 >> Using this, the plugin below can output a form when asked to generate HTML.  The Formbuilder
127 >> arguments are sanitized (need a thorough security audit here - I'm sure I've missed a bunch of
128 >> holes).  The form is generated with default values as supplied in the YAML data.  It also has an
129 >> 'Update Form' button at the bottom.
130 >>
131 >>  The 'Update Form' button in the generated HTML submits changed values back to IkiWiki.  The
132 >> plugin captures these new values, updates the YAML and writes it out again.  The form is
133 >> validated when edited using this method.  This method can only edit the values in the form.
134 >> You cannot add new fields this way.
135 >>
136 >> It is still possible to edit the YAML directly using the 'edit' button.  This allows adding new fields
137 >> to the form, or adding other formbuilder data to change how the form is displayed.
138 >>
139 >> One final part of the plugin is a new pagespec function.  `form_eq()` is a pagespec function that
140 >> takes two arguments (separated by a ',').  The first argument is a field name, the second argument
141 >> a value for that field.  The function matches forms (and not other page types) where the named
142 >> field exists and holds the value given in the second argument.  For example:
143     
144     \[[!inline pages="form_eq(age,15)" archive="yes"]]
145     
146 >> will include a link to the page generated above.
147
148 >>> Okie, I've just made another plugin to try and do things in a different way.
149 >>> This approach adds a 'data' directive.  There are two arguments, `key` and `value`.
150 >>> The directive is replaced by the value.  There is also a match function, which is similar
151 >>> to the one above.  It also takes two arguments, a key and a value.  It returns true if the
152 >>> page has that key/value pair in a data directive.  e.g.:
153
154     \[[!data key="age" value="15"]]
155
156 >>> then, in another page:
157
158     \[[!inline pages="data_eq(age,15)" archive="yes"]]
159
160 >>> I expect that we could have more match functions for each type of structured data,
161 >>> I just wanted to implement a rough prototype to get a feel for how it behaves.  -- [[Will]]
162
163 >> Anyway, here are the plugins.  As noted above these are only preliminary, exploratory, attempts. -- [[Will]]
164
165 >>>> I've just updated the second of the two patches below.  The two patches are not mutually
166 >>>> exclusive, but I'm leaning towards the second as more useful (for the things I'm doing). -- [[Will]]
167
168     #!/usr/bin/perl
169     # Interpret YAML data to make a web form
170     package IkiWiki::Plugin::form;
171     
172     use warnings;
173     use strict;
174     use CGI::FormBuilder;
175     use IkiWiki 2.00;
176     
177     sub import { #{{{
178         hook(type => "getsetup", id => "form", call => \&getsetup);
179         hook(type => "htmlize", id => "form", call => \&htmlize);
180         hook(type => "sessioncgi", id => "form", call => \&cgi_submit);
181     } # }}}
182     
183     sub getsetup () { #{{{
184         return
185                 plugin => {
186                         safe => 1,
187                         rebuild => 1, # format plugin
188                 },
189     } #}}}
190     
191     sub makeFormFromYAML ($$$) { #{{{
192         my $page = shift;
193         my $YAMLString = shift;
194         my $q = shift;
195     
196         eval q{use YAML};
197         error($@) if $@;
198         eval q{use CGI::FormBuilder};
199         error($@) if $@;
200         
201         my ($dataHashRef) = YAML::Load($YAMLString);
202         
203         my @fields = keys %{ $dataHashRef->{fields} };
204         
205         unshift(@fields, 'do');
206         unshift(@fields, 'page');
207         unshift(@fields, 'rcsinfo');
208         
209         # print STDERR "Fields: @fields\n";
210         
211         my $submittedPage;
212         
213         $submittedPage = $q->param('page') if defined $q;
214         
215         if (defined $q && defined $submittedPage && ! ($submittedPage eq $page)) {
216                 error("Submitted page doensn't match current page: $page, $submittedPage");
217         }
218         
219         error("Page not backed by file") unless defined $pagesources{$page};
220         my $file = $pagesources{$page};
221         
222         my $template;
223         
224         if (defined $dataHashRef->{template}) {
225                 $template = $dataHashRef->{template};
226         } else {
227                 $template = "form.tmpl";
228         }
229         
230         my $form = CGI::FormBuilder->new(
231                 fields => \@fields,
232                 charset => "utf-8",
233                 method => 'POST',
234                 required => [qw{page}],
235                 params => $q,
236                 action => $config{cgiurl},
237                 template => scalar IkiWiki::template_params($template),
238                 wikiname => $config{wikiname},
239                 header => 0,
240                 javascript => 0,
241                 keepextras => 0,
242                 title => $page,
243         );
244         
245         $form->field(name => 'do', value => 'Update Form', required => 1, force => 1, type => 'hidden');
246         $form->field(name => 'page', value => $page, required => 1, force => 1, type => 'hidden');
247         $form->field(name => 'rcsinfo', value => IkiWiki::rcs_prepedit($file), required => 1, force => 0, type => 'hidden');
248         
249         my %validkey;
250         foreach my $x (qw{label type multiple value fieldset growable message other required validate cleanopts columns comment disabled linebreaks class}) {
251                 $validkey{$x} = 1;
252         }
253     
254         while ( my ($name, $data) = each(%{ $dataHashRef->{fields} }) ) {
255                 next if $name eq 'page';
256                 next if $name eq 'rcsinfo';
257                 
258                 while ( my ($key, $value) = each(%{ $data }) ) {
259                         next unless $validkey{$key};
260                         next if $key eq 'validate' && !($value =~ /^[\w\s]+$/);
261                 
262                         # print STDERR "Adding to field $name: $key => $value\n";
263                         $form->field(name => $name, $key => $value);
264                 }
265         }
266         
267         # IkiWiki::decode_form_utf8($form);
268         
269         return $form;
270     } #}}}
271     
272     sub htmlize (@) { #{{{
273         my %params=@_;
274         my $content = $params{content};
275         my $page = $params{page};
276     
277         my $form = makeFormFromYAML($page, $content, undef);
278     
279         return $form->render(submit => 'Update Form');
280     } # }}}
281     
282     sub cgi_submit ($$) { #{{{
283         my $q=shift;
284         my $session=shift;
285         
286         my $do=$q->param('do');
287         return unless $do eq 'Update Form';
288         IkiWiki::decode_cgi_utf8($q);
289     
290         eval q{use YAML};
291         error($@) if $@;
292         eval q{use CGI::FormBuilder};
293         error($@) if $@;
294         
295         my $page = $q->param('page');
296         
297         return unless exists $pagesources{$page};
298         
299         return unless $pagesources{$page} =~ m/\.form$/ ;
300         
301         return unless IkiWiki::check_canedit($page, $q, $session);
302     
303         my $file = $pagesources{$page};
304         my $YAMLString = readfile(IkiWiki::srcfile($file));
305         my $form = makeFormFromYAML($page, $YAMLString, $q);
306     
307         my ($dataHashRef) = YAML::Load($YAMLString);
308     
309         if ($form->submitted eq 'Update Form' && $form->validate) {
310                 
311                 #first update our data structure
312                 
313                 while ( my ($name, $data) = each(%{ $dataHashRef->{fields} }) ) {
314                         next if $name eq 'page';
315                         next if $name eq 'rcs-data';
316                         
317                         if (defined $q->param($name)) {
318                                 $data->{value} = $q->param($name);
319                         }
320                 }
321                 
322                 # now write / commit the data
323                 
324                 writefile($file, $config{srcdir}, YAML::Dump($dataHashRef));
325     
326                 my $message = "Web form submission";
327     
328                 IkiWiki::disable_commit_hook();
329                 my $conflict=IkiWiki::rcs_commit($file, $message,
330                         $form->field("rcsinfo"),
331                         $session->param("name"), $ENV{REMOTE_ADDR});
332                 IkiWiki::enable_commit_hook();
333                 IkiWiki::rcs_update();
334     
335                 require IkiWiki::Render;
336                 IkiWiki::refresh();
337     
338                 IkiWiki::redirect($q, "$config{url}/".htmlpage($page)."?updated");
339     
340         } else {
341                 error("Invalid data!");
342         }
343     
344         exit;
345     } #}}}
346     
347     package IkiWiki::PageSpec;
348     
349     sub match_form_eq ($$;@) { #{{{
350         my $page=shift;
351         my $argSet=shift;
352         my @args=split(/,/, $argSet);
353         my $field=shift @args;
354         my $value=shift @args;
355     
356         my $file = $IkiWiki::pagesources{$page};
357         
358         if ($file !~ m/\.form$/) {
359                 return IkiWiki::FailReason->new("page is not a form");
360         }
361         
362         my $YAMLString = IkiWiki::readfile(IkiWiki::srcfile($file));
363     
364         eval q{use YAML};
365         error($@) if $@;
366     
367         my ($dataHashRef) = YAML::Load($YAMLString);
368     
369         if (! defined $dataHashRef->{fields}->{$field}) {
370                 return IkiWiki::FailReason->new("field '$field' not defined in page");
371         }
372     
373         my $formVal = $dataHashRef->{fields}->{$field}->{value};
374     
375         if ($formVal eq $value) {
376                 return IkiWiki::SuccessReason->new("field value matches");
377         } else {
378                 return IkiWiki::FailReason->new("field value does not match");
379         }
380     } #}}}
381     
382     1
383
384 ----
385
386     #!/usr/bin/perl
387     # Allow data embedded in a page to be checked for
388     package IkiWiki::Plugin::data;
389     
390     use warnings;
391     use strict;
392     use IkiWiki 2.00;
393     
394     my $inTable = 0;
395     
396     sub import { #{{{
397         hook(type => "getsetup", id => "data", call => \&getsetup);
398         hook(type => "needsbuild", id => "data", call => \&needsbuild);
399         hook(type => "preprocess", id => "data", call => \&preprocess, scan => 1);
400         hook(type => "preprocess", id => "datatable", call => \&preprocess_table, scan => 1);   # does this need scan?
401     } # }}}
402     
403     sub getsetup () { #{{{
404         return
405                 plugin => {
406                         safe => 1,
407                         rebuild => 1, # format plugin
408                 },
409     } #}}}
410     
411     sub needsbuild (@) { #{{{
412         my $needsbuild=shift;
413         foreach my $page (keys %pagestate) {
414                 if (exists $pagestate{$page}{data}) {
415                         if (exists $pagesources{$page} &&
416                             grep { $_ eq $pagesources{$page} } @$needsbuild) {
417                                 # remove state, it will be re-added
418                                 # if the preprocessor directive is still
419                                 # there during the rebuild
420                                 delete $pagestate{$page}{data};
421                         }
422                 }
423         }
424     }
425     
426     sub preprocess (@) { #{{{
427         my @argslist = @_;
428         my %params=@argslist;
429         
430         my $html = '';
431         my $class = defined $params{class}
432                         ? 'class="'.$params{class}.'"'
433                         : '';
434         
435         if ($inTable) {
436                 $html = "<th $class >$params{key}:</th><td $class >";
437         } else {
438                 $html = "<span $class >$params{key}:";
439         }
440         
441         while (scalar(@argslist) > 1) {
442                 my $type = shift @argslist;
443                 my $data = shift @argslist;
444                 if ($type eq 'link') {
445                         # store links raw
446                         $pagestate{$params{page}}{data}{$params{key}}{link}{$data} = 1;
447                         my $link=IkiWiki::linkpage($data);
448                         add_depends($params{page}, $link);
449                         $html .= ' ' . htmllink($params{page}, $params{destpage}, $link);
450                 } elsif ($type eq 'data') {
451                         $data = IkiWiki::preprocess($params{page}, $params{destpage}, 
452                                 IkiWiki::filter($params{page}, $params{destpage}, $data));
453                         $html .= ' ' . $data;
454                         # store data after processing - allows pagecounts to be stored, etc.
455                         $pagestate{$params{page}}{data}{$params{key}}{data}{$data} = 1;
456                 }
457         }
458                 
459         if ($inTable) {
460                 $html .= "</td>";
461         } else {
462                 $html .= "</span>";
463         }
464         
465         return $html;
466     } # }}}
467     
468     sub preprocess_table (@) { #{{{
469         my %params=@_;
470     
471         my @lines;
472         push @lines, defined $params{class}
473                         ? "<table class=\"".$params{class}.'">'
474                         : '<table>';
475     
476         $inTable = 1;
477     
478         foreach my $line (split(/\n/, $params{datalist})) {
479                 push @lines, "<tr>" . IkiWiki::preprocess($params{page}, $params{destpage}, 
480                         IkiWiki::filter($params{page}, $params{destpage}, $line)) . "</tr>";
481         }
482     
483         $inTable = 0;
484     
485         push @lines, '</table>';
486     
487         return join("\n", @lines);
488     } #}}}
489     
490     package IkiWiki::PageSpec;
491     
492     sub match_data_eq ($$;@) { #{{{
493         my $page=shift;
494         my $argSet=shift;
495         my @args=split(/,/, $argSet);
496         my $key=shift @args;
497         my $value=shift @args;
498     
499         if (! exists $IkiWiki::pagestate{$page}{data}) {
500                 return IkiWiki::FailReason->new("page does not contain any data directives");
501         }
502         
503         if (! exists $IkiWiki::pagestate{$page}{data}{$key}) {
504                 return IkiWiki::FailReason->new("page does not contain data key '$key'");
505         }
506         
507         if ($IkiWiki::pagestate{$page}{data}{$key}{data}{$value}) {
508                 return IkiWiki::SuccessReason->new("value matches");
509         } else {
510                 return IkiWiki::FailReason->new("value does not match");
511         }
512     } #}}}
513     
514     sub match_data_link ($$;@) { #{{{
515         my $page=shift;
516         my $argSet=shift;
517         my @params=@_;
518         my @args=split(/,/, $argSet);
519         my $key=shift @args;
520         my $value=shift @args;
521     
522         if (! exists $IkiWiki::pagestate{$page}{data}) {
523                 return IkiWiki::FailReason->new("page $page does not contain any data directives and so cannot match a link");
524         }
525         
526         if (! exists $IkiWiki::pagestate{$page}{data}{$key}) {
527                 return IkiWiki::FailReason->new("page $page does not contain data key '$key'");
528         }
529         
530         foreach my $link (keys %{ $IkiWiki::pagestate{$page}{data}{$key}{link} }) {
531                 # print STDERR "Checking if $link matches glob $value\n";
532                 if (match_glob($link, $value, @params)) {
533                         return IkiWiki::SuccessReason->new("Data link on page $page with key $key matches glob $value: $link");
534                 }
535         }
536     
537         return IkiWiki::FailReason->new("No data link on page $page with key $key matches glob $value");
538     } #}}}
539     
540     1