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