Added an anchor for referring to this place.
[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
86 >have some type of form to edit the data, but that's just sugar on top of the template.
87 >If you were going to have a web form to edit the data, I can imagine a few ways to do it:
88 >
89 > * Have a special page type which gets compiled into the form.  The page type would
90 >    need to define the form as well as hold the stored data.
91 > * Have special directives that allow you to insert form elements into a normal page.
92 >
93 >I'm happy with template based page creation as a first pass...
94 >
95 >The second use case could be handled by a regular expression directive. eg:
96 >
97 > \[[regex spec="myBug" regex="Depends: ([^\s]+)"]]
98 >
99 > The directive would be replaced with the match from the regex on the 'myBug' page... or something.
100 >
101 >The third use case requires a pagespec function.  One that matched a regex in the page might work.
102 >Otherwise, another option would be to annotate links with a type, and then check the type of links in
103 >a pagespec.  e.g. you could have `depends` links and normal links.
104 >
105 >Anyway, I just wanted to list the thoughts.  In none of these use cases is straight yaml or json the
106 >obvious answer.  -- [[Will]]
107
108 >> Okie.  I've had a play with this.  A 'form' plugin is included inline below, but it is only a rough first pass to
109 >> get a feel for the design space.
110 >>
111 >> The current design defines a new type of page - a 'form'.  The type of page holds YAML data
112 >> defining a FormBuilder form.  For example, if we add a file to the wiki source `test.form`:
113
114     ---
115     fields:
116       age:
117         comment: This is a test
118         validate: INT
119         value: 15
120
121 >> The YAML content is a series of nested hashes.  The outer hash is currently checked for two keys:
122 >> 'template', which specifies a parameter to pass to the FromBuilder as the template for the
123 >> form, and 'fields', which specifies the data for the fields on the form.
124 >> each 'field' is itself a hash.  The keys and values are arguments to the formbuilder form method.
125 >> The most important one is 'value', which specifies the value of that field.
126 >>
127 >> Using this, the plugin below can output a form when asked to generate HTML.  The Formbuilder
128 >> arguments are sanitized (need a thorough security audit here - I'm sure I've missed a bunch of
129 >> holes).  The form is generated with default values as supplied in the YAML data.  It also has an
130 >> 'Update Form' button at the bottom.
131 >>
132 >>  The 'Update Form' button in the generated HTML submits changed values back to IkiWiki.  The
133 >> plugin captures these new values, updates the YAML and writes it out again.  The form is
134 >> validated when edited using this method.  This method can only edit the values in the form.
135 >> You cannot add new fields this way.
136 >>
137 >> It is still possible to edit the YAML directly using the 'edit' button.  This allows adding new fields
138 >> to the form, or adding other formbuilder data to change how the form is displayed.
139 >>
140 >> One final part of the plugin is a new pagespec function.  `form_eq()` is a pagespec function that
141 >> takes two arguments (separated by a ',').  The first argument is a field name, the second argument
142 >> a value for that field.  The function matches forms (and not other page types) where the named
143 >> field exists and holds the value given in the second argument.  For example:
144     
145     \[[!inline pages="form_eq(age,15)" archive="yes"]]
146     
147 >> will include a link to the page generated above.
148
149 >>> Okie, I've just made another plugin to try and do things in a different way.
150 >>> This approach adds a 'data' directive.  There are two arguments, `key` and `value`.
151 >>> The directive is replaced by the value.  There is also a match function, which is similar
152 >>> to the one above.  It also takes two arguments, a key and a value.  It returns true if the
153 >>> page has that key/value pair in a data directive.  e.g.:
154
155     \[[!data key="age" value="15"]]
156
157 >>> then, in another page:
158
159     \[[!inline pages="data_eq(age,15)" archive="yes"]]
160
161 >>> I expect that we could have more match functions for each type of structured data,
162 >>> I just wanted to implement a rough prototype to get a feel for how it behaves.  -- [[Will]]
163
164 >> Anyway, here are the plugins.  As noted above these are only preliminary, exploratory, attempts. -- [[Will]]
165
166 >>>> I've just updated the second of the two patches below.  The two patches are not mutually
167 >>>> exclusive, but I'm leaning towards the second as more useful (for the things I'm doing). -- [[Will]]
168
169 I think it's awesome that you're writing this code to explore the problem
170 space, [[Will]] -- and these plugins are good stabs at at least part of it.
171 Let me respond to a few of your comments.. --[[Joey]]
172
173 On use cases, one use case is a user posting a bug report with structured
174 data in it. A template is one way, but then the user has to deal with the
175 format used to store the structured data. This is where a edit-time form
176 becomes essential.
177
178 > This was the idea with the 'form' plugin.  With the 'data' plugin I was exploring
179 > a different approach: try to keep the markup simple enough that the user can edit
180 > the markup directly, and still have that be ok.  I admit it is a stretch, but I thought
181 > it worth exploring.
182
183 Another use case is, after many such bugs have been filed,
184 wanting to add a new field to each bug report. To avoid needing to edit
185 every bug report it would be good if the fields in a bug report were
186 defined somewhere else, so that just that one place can be edited to add
187 the new field, and it will show up in each bug report (and in each bug
188 report's edit page, as a new form field).
189
190 > If I was going to do that, I'd use a perl script on a checked out
191 > workspace.  I think you're describing a rare operation and
192 > so I'd be happy not having a web interface for it.  Having said that,
193 > if you just wanted to change the form for *new* pages, then you
194 > can just edit the template used to create new pages.
195
196 Re the form plugin, I'm uncomfortable with tying things into
197 [[!cpan CGI::FormBuilder]] quite so tightly as you have.
198
199 > Yeah :).  But I wanted to explore the space and that was the
200 > easiest way to start.
201
202 CGI::FormBuilder
203 could easily change in a way that broke whole wikis full of pages. Also,
204 needing to sanitize FormBuilder fields with security implications is asking
205 for trouble, since new FormBuilder features could add new fields, or
206 add new features to existing fields (FormBuilder is very DWIM) that open
207 new security holes. 
208
209 > There is a list of allowed fields.  I only interpret those.
210
211 I think that having a type system, that allows defining specific types,
212 like "email address", by writing code (that in turn can use FormBuilder),
213 is a better approach, since it should avoid becoming a security problem.
214
215 > That would be possible.  I think an extension to the 'data' plugin might
216 > work here.
217
218 One specific security hole, BTW, is that if you allow the `validate` field,
219 FormBuilder will happily treat it as a regexp, and we don't want to expose
220 arbitrary perl regexps, since they can at least DOS a system, and can
221 probably be used to run arbitrary perl code.
222
223 > I validate the validate field :).  It only allows validate fields that match
224 > `/^[\w\s]+$/`.  This means you can really only use the pre-defined
225 > validation types in FormBuilder.
226
227 The data plugin only deals with a fairly small corner of the problem space,
228 but I think does a nice job at what it does. And could probably be useful
229 in a large number of other cases.
230
231 > I think the data plugin is more likely to be useful than the form plugin.
232 > I was thinking of extending the data directive by allowing an 'id' parameter.
233 > When you have an id parameter, then you can display a small form for that
234 > data element.  The submission handler would look through the page source
235 > for the data directive with the right id parameter and edit it.  This would
236 > make the data directive more like the current 'form' plugin.
237
238 > That is making things significantly more complex for less significant gain though. --[[Will]]
239
240 > Oh, one quick other note.  The data plugin below was designed to handle multiple
241 > data elements in a single directive.  e.g.
242
243     \[[!data key="Depends on" link="bugs/bugA" link="bugs/bugB" value=6]]
244
245 > would match `data_eq(Depends on,6)`, `data_link(Depends on,bugs/bugA)`, `data_link(Depends on,bugs/bugB)`
246 > or, if you applied the patch in [[todo/tracking_bugs_with_dependencies]] then you can use 'defined pagespecs'
247 > such as `data_link(Depends on,~openBugs)`. <a id="another_kind_of_links" />The ability to label links like this allows separation of
248 > dependencies between bugs from arbitrary links.
249
250 ----
251
252     #!/usr/bin/perl
253     # Interpret YAML data to make a web form
254     package IkiWiki::Plugin::form;
255     
256     use warnings;
257     use strict;
258     use CGI::FormBuilder;
259     use IkiWiki 2.00;
260     
261     sub import {
262         hook(type => "getsetup", id => "form", call => \&getsetup);
263         hook(type => "htmlize", id => "form", call => \&htmlize);
264         hook(type => "sessioncgi", id => "form", call => \&cgi_submit);
265     }
266     
267     sub getsetup () {
268         return
269                 plugin => {
270                         safe => 1,
271                         rebuild => 1, # format plugin
272                 },
273     }
274     
275     sub makeFormFromYAML ($$$) {
276         my $page = shift;
277         my $YAMLString = shift;
278         my $q = shift;
279     
280         eval q{use YAML};
281         error($@) if $@;
282         eval q{use CGI::FormBuilder};
283         error($@) if $@;
284         
285         my ($dataHashRef) = YAML::Load($YAMLString);
286         
287         my @fields = keys %{ $dataHashRef->{fields} };
288         
289         unshift(@fields, 'do');
290         unshift(@fields, 'page');
291         unshift(@fields, 'rcsinfo');
292         
293         # print STDERR "Fields: @fields\n";
294         
295         my $submittedPage;
296         
297         $submittedPage = $q->param('page') if defined $q;
298         
299         if (defined $q && defined $submittedPage && ! ($submittedPage eq $page)) {
300                 error("Submitted page doensn't match current page: $page, $submittedPage");
301         }
302         
303         error("Page not backed by file") unless defined $pagesources{$page};
304         my $file = $pagesources{$page};
305         
306         my $template;
307         
308         if (defined $dataHashRef->{template}) {
309                 $template = $dataHashRef->{template};
310         } else {
311                 $template = "form.tmpl";
312         }
313         
314         my $form = CGI::FormBuilder->new(
315                 fields => \@fields,
316                 charset => "utf-8",
317                 method => 'POST',
318                 required => [qw{page}],
319                 params => $q,
320                 action => $config{cgiurl},
321                 template => scalar IkiWiki::template_params($template),
322                 wikiname => $config{wikiname},
323                 header => 0,
324                 javascript => 0,
325                 keepextras => 0,
326                 title => $page,
327         );
328         
329         $form->field(name => 'do', value => 'Update Form', required => 1, force => 1, type => 'hidden');
330         $form->field(name => 'page', value => $page, required => 1, force => 1, type => 'hidden');
331         $form->field(name => 'rcsinfo', value => IkiWiki::rcs_prepedit($file), required => 1, force => 0, type => 'hidden');
332         
333         my %validkey;
334         foreach my $x (qw{label type multiple value fieldset growable message other required validate cleanopts columns comment disabled linebreaks class}) {
335                 $validkey{$x} = 1;
336         }
337     
338         while ( my ($name, $data) = each(%{ $dataHashRef->{fields} }) ) {
339                 next if $name eq 'page';
340                 next if $name eq 'rcsinfo';
341                 
342                 while ( my ($key, $value) = each(%{ $data }) ) {
343                         next unless $validkey{$key};
344                         next if $key eq 'validate' && !($value =~ /^[\w\s]+$/);
345                 
346                         # print STDERR "Adding to field $name: $key => $value\n";
347                         $form->field(name => $name, $key => $value);
348                 }
349         }
350         
351         # IkiWiki::decode_form_utf8($form);
352         
353         return $form;
354     }
355     
356     sub htmlize (@) {
357         my %params=@_;
358         my $content = $params{content};
359         my $page = $params{page};
360     
361         my $form = makeFormFromYAML($page, $content, undef);
362     
363         return $form->render(submit => 'Update Form');
364     }
365     
366     sub cgi_submit ($$) {
367         my $q=shift;
368         my $session=shift;
369         
370         my $do=$q->param('do');
371         return unless $do eq 'Update Form';
372         IkiWiki::decode_cgi_utf8($q);
373     
374         eval q{use YAML};
375         error($@) if $@;
376         eval q{use CGI::FormBuilder};
377         error($@) if $@;
378         
379         my $page = $q->param('page');
380         
381         return unless exists $pagesources{$page};
382         
383         return unless $pagesources{$page} =~ m/\.form$/ ;
384         
385         return unless IkiWiki::check_canedit($page, $q, $session);
386     
387         my $file = $pagesources{$page};
388         my $YAMLString = readfile(IkiWiki::srcfile($file));
389         my $form = makeFormFromYAML($page, $YAMLString, $q);
390     
391         my ($dataHashRef) = YAML::Load($YAMLString);
392     
393         if ($form->submitted eq 'Update Form' && $form->validate) {
394                 
395                 #first update our data structure
396                 
397                 while ( my ($name, $data) = each(%{ $dataHashRef->{fields} }) ) {
398                         next if $name eq 'page';
399                         next if $name eq 'rcs-data';
400                         
401                         if (defined $q->param($name)) {
402                                 $data->{value} = $q->param($name);
403                         }
404                 }
405                 
406                 # now write / commit the data
407                 
408                 writefile($file, $config{srcdir}, YAML::Dump($dataHashRef));
409     
410                 my $message = "Web form submission";
411     
412                 IkiWiki::disable_commit_hook();
413                 my $conflict=IkiWiki::rcs_commit($file, $message,
414                         $form->field("rcsinfo"),
415                         $session->param("name"), $ENV{REMOTE_ADDR});
416                 IkiWiki::enable_commit_hook();
417                 IkiWiki::rcs_update();
418     
419                 require IkiWiki::Render;
420                 IkiWiki::refresh();
421     
422                 IkiWiki::redirect($q, "$config{url}/".htmlpage($page)."?updated");
423     
424         } else {
425                 error("Invalid data!");
426         }
427     
428         exit;
429     }
430     
431     package IkiWiki::PageSpec;
432     
433     sub match_form_eq ($$;@) {
434         my $page=shift;
435         my $argSet=shift;
436         my @args=split(/,/, $argSet);
437         my $field=shift @args;
438         my $value=shift @args;
439     
440         my $file = $IkiWiki::pagesources{$page};
441         
442         if ($file !~ m/\.form$/) {
443                 return IkiWiki::FailReason->new("page is not a form");
444         }
445         
446         my $YAMLString = IkiWiki::readfile(IkiWiki::srcfile($file));
447     
448         eval q{use YAML};
449         error($@) if $@;
450     
451         my ($dataHashRef) = YAML::Load($YAMLString);
452     
453         if (! defined $dataHashRef->{fields}->{$field}) {
454                 return IkiWiki::FailReason->new("field '$field' not defined in page");
455         }
456     
457         my $formVal = $dataHashRef->{fields}->{$field}->{value};
458     
459         if ($formVal eq $value) {
460                 return IkiWiki::SuccessReason->new("field value matches");
461         } else {
462                 return IkiWiki::FailReason->new("field value does not match");
463         }
464     }
465     
466     1
467
468 ----
469
470     #!/usr/bin/perl
471     # Allow data embedded in a page to be checked for
472     package IkiWiki::Plugin::data;
473     
474     use warnings;
475     use strict;
476     use IkiWiki 2.00;
477     
478     my $inTable = 0;
479     
480     sub import {
481         hook(type => "getsetup", id => "data", call => \&getsetup);
482         hook(type => "needsbuild", id => "data", call => \&needsbuild);
483         hook(type => "preprocess", id => "data", call => \&preprocess, scan => 1);
484         hook(type => "preprocess", id => "datatable", call => \&preprocess_table, scan => 1);   # does this need scan?
485     }
486     
487     sub getsetup () {
488         return
489                 plugin => {
490                         safe => 1,
491                         rebuild => 1, # format plugin
492                 },
493     }
494     
495     sub needsbuild (@) {
496         my $needsbuild=shift;
497         foreach my $page (keys %pagestate) {
498                 if (exists $pagestate{$page}{data}) {
499                         if (exists $pagesources{$page} &&
500                             grep { $_ eq $pagesources{$page} } @$needsbuild) {
501                                 # remove state, it will be re-added
502                                 # if the preprocessor directive is still
503                                 # there during the rebuild
504                                 delete $pagestate{$page}{data};
505                         }
506                 }
507         }
508     }
509     
510     sub preprocess (@) {
511         my @argslist = @_;
512         my %params=@argslist;
513         
514         my $html = '';
515         my $class = defined $params{class}
516                         ? 'class="'.$params{class}.'"'
517                         : '';
518         
519         if ($inTable) {
520                 $html = "<th $class >$params{key}:</th><td $class >";
521         } else {
522                 $html = "<span $class >$params{key}:";
523         }
524         
525         while (scalar(@argslist) > 1) {
526                 my $type = shift @argslist;
527                 my $data = shift @argslist;
528                 if ($type eq 'link') {
529                         # store links raw
530                         $pagestate{$params{page}}{data}{$params{key}}{link}{$data} = 1;
531                         my $link=IkiWiki::linkpage($data);
532                         add_depends($params{page}, $link);
533                         $html .= ' ' . htmllink($params{page}, $params{destpage}, $link);
534                 } elsif ($type eq 'data') {
535                         $data = IkiWiki::preprocess($params{page}, $params{destpage}, 
536                                 IkiWiki::filter($params{page}, $params{destpage}, $data));
537                         $html .= ' ' . $data;
538                         # store data after processing - allows pagecounts to be stored, etc.
539                         $pagestate{$params{page}}{data}{$params{key}}{data}{$data} = 1;
540                 }
541         }
542                 
543         if ($inTable) {
544                 $html .= "</td>";
545         } else {
546                 $html .= "</span>";
547         }
548         
549         return $html;
550     }
551     
552     sub preprocess_table (@) {
553         my %params=@_;
554     
555         my @lines;
556         push @lines, defined $params{class}
557                         ? "<table class=\"".$params{class}.'">'
558                         : '<table>';
559     
560         $inTable = 1;
561     
562         foreach my $line (split(/\n/, $params{datalist})) {
563                 push @lines, "<tr>" . IkiWiki::preprocess($params{page}, $params{destpage}, 
564                         IkiWiki::filter($params{page}, $params{destpage}, $line)) . "</tr>";
565         }
566     
567         $inTable = 0;
568     
569         push @lines, '</table>';
570     
571         return join("\n", @lines);
572     }
573     
574     package IkiWiki::PageSpec;
575     
576     sub match_data_eq ($$;@) {
577         my $page=shift;
578         my $argSet=shift;
579         my @args=split(/,/, $argSet);
580         my $key=shift @args;
581         my $value=shift @args;
582     
583         if (! exists $IkiWiki::pagestate{$page}{data}) {
584                 return IkiWiki::FailReason->new("page does not contain any data directives");
585         }
586         
587         if (! exists $IkiWiki::pagestate{$page}{data}{$key}) {
588                 return IkiWiki::FailReason->new("page does not contain data key '$key'");
589         }
590         
591         if ($IkiWiki::pagestate{$page}{data}{$key}{data}{$value}) {
592                 return IkiWiki::SuccessReason->new("value matches");
593         } else {
594                 return IkiWiki::FailReason->new("value does not match");
595         }
596     }
597     
598     sub match_data_link ($$;@) {
599         my $page=shift;
600         my $argSet=shift;
601         my @params=@_;
602         my @args=split(/,/, $argSet);
603         my $key=shift @args;
604         my $value=shift @args;
605     
606         if (! exists $IkiWiki::pagestate{$page}{data}) {
607                 return IkiWiki::FailReason->new("page $page does not contain any data directives and so cannot match a link");
608         }
609         
610         if (! exists $IkiWiki::pagestate{$page}{data}{$key}) {
611                 return IkiWiki::FailReason->new("page $page does not contain data key '$key'");
612         }
613         
614         foreach my $link (keys %{ $IkiWiki::pagestate{$page}{data}{$key}{link} }) {
615                 # print STDERR "Checking if $link matches glob $value\n";
616                 if (match_glob($link, $value, @params)) {
617                         return IkiWiki::SuccessReason->new("Data link on page $page with key $key matches glob $value: $link");
618                 }
619         }
620     
621         return IkiWiki::FailReason->new("No data link on page $page with key $key matches glob $value");
622     }
623     
624     1