allow relationship colors in SVG download; work around inability to close Download...
[scpubgit/stemmaweb.git] / lib / stemmaweb / Controller / Relation.pm
CommitLineData
b8a92065 1package stemmaweb::Controller::Relation;
5539cba3 2use JSON qw/ to_json from_json /;
b8a92065 3use Moose;
cc86fa11 4use Module::Load;
b8a92065 5use namespace::autoclean;
b28e606e 6use TryCatch;
b8a92065 7
8BEGIN { extends 'Catalyst::Controller' }
9
10
11=head1 NAME
12
13stemmaweb::Controller::Relation - Controller for the relationship mapper
14
15=head1 DESCRIPTION
16
b28e606e 17The reading relationship mapper with draggable nodes.
b8a92065 18
19=head1 METHODS
20
b28e606e 21=head2 index
22
b8a92065 23 GET relation/$textid
24
25Renders the application for the text identified by $textid.
26
b8a92065 27=cut
28
9529f69c 29sub index :Path :Args(0) {
30 my( $self, $c ) = @_;
b28e606e 31 $c->stash->{'template'} = 'relate.tt';
32}
33
9529f69c 34=head2 text
b28e606e 35
9529f69c 36 GET relation/$textid/
37
38 Runs the relationship mapper for the specified text ID.
39
b28e606e 40=cut
41
9529f69c 42sub text :Chained('/') :PathPart('relation') :CaptureArgs(1) {
43 my( $self, $c, $textid ) = @_;
13aa153c 44 my $tradition = $c->model('Directory')->tradition( $textid );
cd3f7f55 45 unless( $tradition ) {
46 $c->response->status('404');
47 $c->response->body("No such tradition with ID $textid");
48 $c->detach('View::Plain');
49 return;
50 }
51
7562a27b 52 # Account for a bad interaction between FastCGI and KiokuDB
53 unless( $tradition->collation->tradition ) {
54 $c->log->warn( "Fixing broken tradition link" );
55 $tradition->collation->_set_tradition( $tradition );
56 $c->model('Directory')->save( $tradition );
57 }
20198e59 58 # Check permissions. Will return 403 if denied, otherwise will
59 # put the appropriate value in the stash.
60 my $ok = _check_permission( $c, $tradition );
61 return unless $ok;
62
8843c8b9 63 $c->stash->{'textid'} = $textid;
64 $c->stash->{'tradition'} = $tradition;
65}
66
67sub main :Chained('text') :PathPart('') :Args(0) {
68 my( $self, $c ) = @_;
69 my $tradition = delete $c->stash->{'tradition'};
70 my $collation = $tradition->collation;
71
56e3972e 72 # Stash the relationship definitions
73 $c->stash->{'relationship_scopes'} = to_json( [ qw/ local global / ] );
74 my @reltypeinfo;
75 foreach my $type ( sort { _typesort( $a, $b ) } $collation->relations->types ) {
76 next if $type->is_weak;
77 my $struct = { name => $type->name, description => $type->description };
78 push( @reltypeinfo, $struct );
79 }
80 $c->stash->{'relationship_types'} = to_json( \@reltypeinfo );
81
d58766c0 82 # See how big the tradition is. Edges are more important than nodes
83 # when it comes to rendering difficulty.
8843c8b9 84 my $numnodes = scalar $collation->readings;
85 my $numedges = scalar $collation->paths;
86 my $length = $collation->end->rank;
d58766c0 87 # We should display no more than roughly 500 nodes, or roughly 700
88 # edges, at a time.
89 my $segments = $numnodes / 500;
90 if( $numedges / 700 > $segments ) {
91 $segments = $numedges / 700;
92 }
93 my $segsize = sprintf( "%.0f", $length / $segments );
94 my $margin = sprintf( "%.0f", $segsize / 10 );
95 if( $segments > 1 ) {
13aa153c 96 # Segment the tradition in order not to overload the browser.
13aa153c 97 my @divs;
98 my $r = 0;
d58766c0 99 while( $r + $margin < $length ) {
13aa153c 100 push( @divs, $r );
d58766c0 101 $r += $segsize;
13aa153c 102 }
103 $c->stash->{'textsegments'} = [];
ea8e8b3c 104 foreach my $i ( 0..$#divs ) {
105 my $seg = { 'start' => $divs[$i] };
106 $seg->{'display'} = "Segment " . ($i+1);
13aa153c 107 push( @{$c->stash->{'textsegments'}}, $seg );
108 }
109 }
13aa153c 110 my $startseg = $c->req->param('start');
13aa153c 111 my $svgopts;
112 if( $startseg ) {
d58766c0 113 # Only render the subgraph from startseg to endseg or to END,
13aa153c 114 # whichever is less.
8843c8b9 115 my $endseg = $startseg + $segsize + $margin;
13aa153c 116 $svgopts = { 'from' => $startseg };
d58766c0 117 $svgopts->{'to'} = $endseg if $endseg < $collation->end->rank;
13aa153c 118 } elsif( exists $c->stash->{'textsegments'} ) {
119 # This is the unqualified load of a long tradition. We implicitly start
8843c8b9 120 # at zero, but go only as far as our segment size.
121 my $endseg = $segsize + $margin;
ea8e8b3c 122 $startseg = 0;
d58766c0 123 $svgopts = { 'to' => $endseg };
13aa153c 124 }
8843c8b9 125 # Spit out the SVG
13aa153c 126 my $svg_str = $collation->as_svg( $svgopts );
9529f69c 127 $svg_str =~ s/\n//gs;
ea8e8b3c 128 $c->stash->{'startseg'} = $startseg if defined $startseg;
9529f69c 129 $c->stash->{'svg_string'} = $svg_str;
130 $c->stash->{'text_title'} = $tradition->name;
487674b9 131 if( $tradition->can('language') ) {
132 $c->stash->{'text_lang'} = $tradition->language;
133 $c->stash->{'can_morphologize'} = 1;
134 } else {
135 $c->stash->{'text_lang'} = 'Default';
136 }
9529f69c 137 $c->stash->{'template'} = 'relate.tt';
b28e606e 138}
139
56e3972e 140sub _typesort {
141 my( $a, $b ) = @_;
142 my $blsort = $a->bindlevel <=> $b->bindlevel;
143 return $blsort if $blsort;
144 return $a->name cmp $b->name;
8843c8b9 145}
146
cc86fa11 147=head2 help
148
149 GET relation/help/$language
150
151Returns the help window HTML.
152
153=cut
154
155sub help :Local :Args(1) {
156 my( $self, $c, $lang ) = @_;
157 # Display the morphological help for the language if it is defined.
158 if( $lang && $lang ne 'Default' ) {
159 my $mod = 'Text::Tradition::Language::' . $lang;
160 try {
161 load( $mod );
162 } catch {
163 $c->log->debug("Warning: could not load $mod");
164 }
165 my $has_mod = $mod->can('morphology_tags');
cc86fa11 166 if( $has_mod ) {
167 my $tagset = &$has_mod;
168 $c->stash->{'tagset'} = $tagset;
169 }
170 }
171 $c->stash->{'template'} = 'relatehelp.tt';
172}
173
b28e606e 174=head2 relationships
175
13aa153c 176 GET relation/$textid/relationships
9529f69c 177
178Returns the list of relationships defined for this text.
b28e606e 179
13aa153c 180 POST relation/$textid/relationships { request }
9529f69c 181
182Attempts to define the requested relationship within the text. Returns 200 on
183success or 403 on error.
b28e606e 184
13aa153c 185 DELETE relation/$textid/relationships { request }
9529f69c 186
b28e606e 187
188=cut
189
9529f69c 190sub relationships :Chained('text') :PathPart :Args(0) {
b28e606e 191 my( $self, $c ) = @_;
6d124a83 192 my $tradition = delete $c->stash->{'tradition'};
20198e59 193 my $ok = _check_permission( $c, $tradition );
194 return unless $ok;
6d124a83 195 my $collation = $tradition->collation;
cdd592f3 196 my $m = $c->model('Directory');
9529f69c 197 if( $c->request->method eq 'GET' ) {
198 my @pairs = $collation->relationships; # returns the edges
199 my @all_relations;
200 foreach my $p ( @pairs ) {
201 my $relobj = $collation->relations->get_relationship( @$p );
545163a2 202 next if $relobj->type eq 'collated'; # Don't show these
7562a27b 203 next if $p->[0] eq $p->[1]; # HACK until bugfix
eefe56ac 204 my $relhash = { source_id => $p->[0], target_id => $p->[1],
205 source_text => $collation->reading( $p->[0] )->text,
206 target_text => $collation->reading( $p->[1] )->text,
207 type => $relobj->type, scope => $relobj->scope,
208 a_derivable_from_b => $relobj->a_derivable_from_b,
209 b_derivable_from_a => $relobj->b_derivable_from_a,
210 non_independent => $relobj->non_independent,
211 };
69a19c91 212 $relhash->{'note'} = $relobj->annotation if $relobj->has_annotation;
213 push( @all_relations, $relhash );
9529f69c 214 }
215 $c->stash->{'result'} = \@all_relations;
20198e59 216 } else {
217 # Check write permissions first of all
218 if( $c->stash->{'permission'} ne 'full' ) {
9529f69c 219 $c->response->status( '403' );
20198e59 220 $c->stash->{'result'} = {
56e3972e 221 'error' => 'You do not have permission to modify this tradition.' };
222 $c->detach( 'View::JSON' );
20198e59 223 } elsif( $c->request->method eq 'POST' ) {
eefe56ac 224 my $opts = $c->request->params;
225
226 # Retrieve the source / target from the options
227 my $node = delete $opts->{source_id};
228 my $target = delete $opts->{target_id};
229
230 # Make sure we didn't send a blank or invalid relationship type
231 my $relation = $opts->{type};
232 unless( $collation->get_relationship_type( $relation ) ) {
233 my $errmsg = $relation ? "No such relationship type $relation" :
234 "You must specify a relationship type";
235 $c->stash->{'result'} = { error => $errmsg };
236 $c->response->status( '400' );
237 $c->detach( 'View::JSON' );
238 }
239
240 # Keep the data clean
241 my @booleans = qw/ a_derivable_from_b b_derivable_from_a non_independent /;
242 foreach my $k ( keys %$opts ) {
243 if( $opts->{$k} && grep { $_ eq $k } @booleans ) {
244 $opts->{$k} = 1;
245 }
246 }
20198e59 247
eefe56ac 248 delete $opts->{scope} unless $opts->{scope};
249 delete $opts->{annotation} unless $opts->{annotation};
250 $opts->{propagate} = 1;
20198e59 251
252 try {
253 my @vectors = $collation->add_relationship( $node, $target, $opts );
254 $c->stash->{'result'} = \@vectors;
255 $m->save( $tradition );
256 } catch( Text::Tradition::Error $e ) {
257 $c->response->status( '403' );
258 $c->stash->{'result'} = { 'error' => $e->message };
259 }
260 } elsif( $c->request->method eq 'DELETE' ) {
261 my $node = $c->request->param('source_id');
262 my $target = $c->request->param('target_id');
088a14af 263 my $scopewide = $c->request->param('scopewide')
264 && $c->request->param('scopewide') eq 'true';
20198e59 265 try {
088a14af 266 my @vectors = $collation->del_relationship( $node, $target, $scopewide );
20198e59 267 $m->save( $tradition );
268 $c->stash->{'result'} = \@vectors;
269 } catch( Text::Tradition::Error $e ) {
270 $c->response->status( '403' );
271 $c->stash->{'result'} = { 'error' => $e->message };
272 }
9529f69c 273 }
b28e606e 274 }
b28e606e 275 $c->forward('View::JSON');
5f15640c 276}
277
278=head2 readings
279
280 GET relation/$textid/readings
281
282Returns the list of readings defined for this text along with their metadata.
283
284=cut
285
0dcdd5ec 286my %read_write_keys = (
287 'id' => 0,
288 'text' => 0,
289 'is_meta' => 0,
290 'grammar_invalid' => 1,
291 'is_nonsense' => 1,
292 'normal_form' => 1,
293);
294
5f15640c 295sub _reading_struct {
296 my( $reading ) = @_;
297 # Return a JSONable struct of the useful keys. Keys meant to be writable
298 # have a true value; read-only keys have a false value.
5f15640c 299 my $struct = {};
2be76d3f 300 map { $struct->{$_} = $reading->$_ if $reading->can( $_ ) } keys( %read_write_keys );
5f15640c 301 # Special case
2be76d3f 302 $struct->{'lexemes'} = $reading->can( 'lexemes' ) ? [ $reading->lexemes ] : [];
0dcdd5ec 303 # Look up any words related via spelling or orthography
304 my $sameword = sub {
305 my $t = $_[0]->type;
306 return $t eq 'spelling' || $t eq 'orthographic';
307 };
5539cba3 308 # Now add the list data
309 $struct->{'variants'} = [ map { $_->text } $reading->related_readings( $sameword ) ];
310 $struct->{'witnesses'} = [ $reading->witnesses ];
5f15640c 311 return $struct;
312}
313
314sub readings :Chained('text') :PathPart :Args(0) {
315 my( $self, $c ) = @_;
316 my $tradition = delete $c->stash->{'tradition'};
20198e59 317 my $ok = _check_permission( $c, $tradition );
318 return unless $ok;
5f15640c 319 my $collation = $tradition->collation;
320 my $m = $c->model('Directory');
321 if( $c->request->method eq 'GET' ) {
322 my $rdginfo = {};
323 foreach my $rdg ( $collation->readings ) {
324 $rdginfo->{$rdg->id} = _reading_struct( $rdg );
325 }
326 $c->stash->{'result'} = $rdginfo;
327 }
328 $c->forward('View::JSON');
329}
330
331=head2 reading
332
333 GET relation/$textid/reading/$id
334
335Returns the list of readings defined for this text along with their metadata.
336
337 POST relation/$textid/reading/$id { request }
338
339Alters the reading according to the values in request. Returns 403 Forbidden if
340the alteration isn't allowed.
341
342=cut
343
344sub reading :Chained('text') :PathPart :Args(1) {
345 my( $self, $c, $reading_id ) = @_;
346 my $tradition = delete $c->stash->{'tradition'};
347 my $collation = $tradition->collation;
0dcdd5ec 348 my $rdg = $collation->reading( $reading_id );
5f15640c 349 my $m = $c->model('Directory');
350 if( $c->request->method eq 'GET' ) {
5f15640c 351 $c->stash->{'result'} = $rdg ? _reading_struct( $rdg )
352 : { 'error' => "No reading with ID $reading_id" };
353 } elsif ( $c->request->method eq 'POST' ) {
20198e59 354 if( $c->stash->{'permission'} ne 'full' ) {
355 $c->response->status( '403' );
356 $c->stash->{'result'} = {
5539cba3 357 'error' => 'You do not have permission to modify this tradition.' };
20198e59 358 $c->detach('View::JSON');
487674b9 359 return;
20198e59 360 }
6666d111 361 my $errmsg;
487674b9 362 if( $rdg && $rdg->does('Text::Tradition::Morphology') ) {
363 # Are we re-lemmatizing?
364 if( $c->request->param('relemmatize') ) {
365 my $nf = $c->request->param('normal_form');
366 # TODO throw error unless $nf
367 $rdg->normal_form( $nf );
368 # TODO throw error if lemmatization fails
369 # TODO skip this if normal form hasn't changed
370 $rdg->lemmatize();
371 } else {
372 # Set all the values that we have for the reading.
373 # TODO error handling
374 foreach my $p ( keys %{$c->request->params} ) {
375 if( $p =~ /^morphology_(\d+)$/ ) {
376 # Set the form on the correct lexeme
377 my $morphval = $c->request->param( $p );
378 next unless $morphval;
379 my $midx = $1;
380 my $lx = $rdg->lexeme( $midx );
381 my $strrep = $rdg->language . ' // ' . $morphval;
382 my $idx = $lx->has_form( $strrep );
383 unless( defined $idx ) {
384 # Make the word form and add it to the lexeme.
385 try {
386 $idx = $lx->add_matching_form( $strrep ) - 1;
387 } catch( Text::Tradition::Error $e ) {
388 $c->response->status( '403' );
389 $errmsg = $e->message;
390 } catch {
391 # Something else went wrong, probably a Moose error
392 $c->response->status( '403' );
393 $errmsg = 'Something went wrong with the request';
394 }
6666d111 395 }
487674b9 396 $lx->disambiguate( $idx ) if defined $idx;
397 } elsif( $read_write_keys{$p} ) {
398 my $val = _clean_booleans( $rdg, $p, $c->request->param( $p ) );
399 $rdg->$p( $val );
0dcdd5ec 400 }
487674b9 401 }
402 }
403 $m->save( $rdg );
404 } else {
405 $errmsg = "Reading does not exist or cannot be morphologized";
0dcdd5ec 406 }
6666d111 407 $c->stash->{'result'} = $errmsg ? { 'error' => $errmsg }
408 : _reading_struct( $rdg );
0dcdd5ec 409
5f15640c 410 }
411 $c->forward('View::JSON');
412
413}
b28e606e 414
b001c73d 415=head2 merge
416
417 POST relation/$textid/merge { data }
418
419Merges the requested readings, combining the witnesses of both readings into
420the target reading. All non-conflicting source relationships are inherited by
421the target relationship.
422
423=cut
424
425sub merge :Chained('text') :PathPart :Args(0) {
426 my( $self, $c ) = @_;
427 my $tradition = delete $c->stash->{'tradition'};
428 my $collation = $tradition->collation;
429 my $m = $c->model('Directory');
430 if( $c->request->method eq 'POST' ) {
431 if( $c->stash->{'permission'} ne 'full' ) {
432 $c->response->status( '403' );
433 $c->stash->{'result'} = {
434 'error' => 'You do not have permission to modify this tradition.' };
435 $c->detach('View::JSON');
436 return;
437 }
438 my $errmsg;
439 my $response;
440
441 my $main = $c->request->param('target_id');
442 my $second = $c->request->param('source_id');
443 # Find the common successor of these, so that we can detect other
444 # potentially identical readings.
445 my $csucc = $collation->common_successor( $main, $second );
446
447 # Try the merge if these are parallel readings.
448 if( $csucc->id eq $main || $csucc->id eq $second ) {
449 $errmsg = "Cannot merge readings in the same path";
450 } else {
451 try {
452 $collation->merge_readings( $main, $second );
453 } catch( Text::Tradition::Error $e ) {
454 $c->response->status( '403' );
455 $errmsg = $e->message;
456 } catch {
457 # Something else went wrong, probably a Moose error
458 $c->response->status( '403' );
459 $errmsg = 'Something went wrong with the request';
460 }
461 }
462
463 # Look for readings that are now identical.
464 if( $errmsg ) {
465 $response = { status => 'error', error => $errmsg };
466 } else {
467 $response = { status => 'ok' };
8880c19d 468 unless( $c->request->param('single') ) {
469 my @identical = $collation->identical_readings(
470 start => $main, end => $csucc->id );
471 if( @identical ) {
472 $response->{'checkalign'} = [
473 map { [ $_->[0]->id, $_->[1]->id ] } @identical ];
474 }
b001c73d 475 }
476 $m->save( $collation );
477 }
478 $c->stash->{'result'} = $response;
479 $c->forward('View::JSON');
480 }
481}
482
5539cba3 483=head2 duplicate
484
fdb37581 485 POST relation/$textid/duplicate { data }
5539cba3 486
fdb37581 487Duplicates the requested readings, detaching the witnesses specified in
488the list to use the new reading(s) instead of the old. The data to be
489passed should be a JSON structure:
490
491 { readings: rid1,rid2,rid3,...
492 witnesses: [ wit1, ... ] }
5539cba3 493
494=cut
495
fdb37581 496sub duplicate :Chained('text') :PathPart :Args(0) {
497 my( $self, $c ) = @_;
5539cba3 498 my $tradition = delete $c->stash->{'tradition'};
499 my $collation = $tradition->collation;
5539cba3 500 my $m = $c->model('Directory');
501 if( $c->request->method eq 'POST' ) {
502 if( $c->stash->{'permission'} ne 'full' ) {
503 $c->response->status( '403' );
504 $c->stash->{'result'} = {
505 'error' => 'You do not have permission to modify this tradition.' };
506 $c->detach('View::JSON');
507 return;
508 }
509 my $errmsg;
510 my $response = {};
fdb37581 511 # Sort out which readings need to be duplicated from the set given, and
512 # ensure that all the given wits bear each relevant reading.
513
514 my %wits = ();
515 map { $wits{$_} = 1 } $c->request->param('witnesses[]');
516 my %rdgranks = ();
517 foreach my $rid ( $c->request->param('readings[]') ) {
518 my $numwits = 0;
519 my $rdg = $collation->reading( $rid );
520 foreach my $rwit ( $rdg->witnesses( $rid ) ) {
521 $numwits++ if exists $wits{$rwit};
522 }
769401c3 523 next unless $numwits; # Disregard readings with none of our witnesses
524 if( $numwits < keys( %wits ) ) {
fdb37581 525 $errmsg = "Reading $rid contains some but not all of the specified witnesses.";
526 last;
527 } elsif( exists $rdgranks{ $rdg->rank } ) {
528 $errmsg = "More than one reading would be detached along with $rid at rank " . $rdg->rank;
529 last;
530 } else {
531 $rdgranks{ $rdg->rank } = $rid;
532 }
533 }
534
535 # Now check that the readings make a single sequence.
536 unless( $errmsg ) {
537 my $prior;
538 foreach my $rank ( sort { $a <=> $b } keys %rdgranks ) {
539 my $rid = $rdgranks{$rank};
540 if( $prior ) {
541 # Check that there is only one path between $prior and $rdg.
542 foreach my $wit ( keys %wits ) {
543 unless( $collation->prior_reading( $rid, $wit ) eq $prior ) {
544 $errmsg = "Diverging witness paths from $prior to $rid at $wit";
545 last;
546 }
547 }
548 }
549 $prior = $rid;
550 }
551 }
552
553 # Abort if we've run into a problem.
554 if( $errmsg ) {
555 $c->stash->{'result'} = { 'error' => $errmsg };
556 $c->response->status( '403' );
557 $c->forward('View::JSON');
558 return;
559 }
560
561 # Otherwise, do the dirty work.
562 my @witlist = keys %wits;
217f5e64 563 my @deleted_relations;
fdb37581 564 foreach my $rank ( sort { $a <=> $b } keys %rdgranks ) {
5539cba3 565 my $newrdg;
fdb37581 566 my $reading_id = $rdgranks{$rank};
217f5e64 567 my @delrels;
5539cba3 568 try {
217f5e64 569 ( $newrdg, @delrels ) =
570 $collation->duplicate_reading( $reading_id, @witlist );
5539cba3 571 } catch( Text::Tradition::Error $e ) {
572 $c->response->status( '403' );
573 $errmsg = $e->message;
574 } catch {
575 # Something else went wrong, probably a Moose error
576 $c->response->status( '403' );
577 $errmsg = 'Something went wrong with the request';
578 }
579 if( $newrdg ) {
ea77ecb8 580 my $data = _reading_struct( $newrdg );
581 $data->{'orig_rdg'} = $reading_id;
582 $response->{"$newrdg"} = $data;
217f5e64 583 push( @deleted_relations, @delrels );
5539cba3 584 }
fdb37581 585 }
586 if( $errmsg ) {
587 $c->stash->{'result'} = { 'error' => $errmsg };
5539cba3 588 } else {
fdb37581 589 $m->save( $collation );
217f5e64 590 $response->{'DELETED'} = \@deleted_relations;
fdb37581 591 $c->stash->{'result'} = $response;
5539cba3 592 }
5539cba3 593 }
594 $c->forward('View::JSON');
595}
596
597
598
20198e59 599sub _check_permission {
600 my( $c, $tradition ) = @_;
601 my $user = $c->user_exists ? $c->user->get_object : undef;
b0524272 602 # Does this user have access?
20198e59 603 if( $user ) {
b0524272 604 if( $user->is_admin ||
605 ( $tradition->has_user && $tradition->user->id eq $user->id ) ) {
606 $c->stash->{'permission'} = 'full';
607 return 1;
608 }
080f8a02 609 }
610 # Is it public?
611 if( $tradition->public ) {
20198e59 612 $c->stash->{'permission'} = 'readonly';
613 return 1;
080f8a02 614 }
615 # Forbidden!
616 $c->response->status( 403 );
617 $c->response->body( 'You do not have permission to view this tradition.' );
618 $c->detach( 'View::Plain' );
619 return 0;
20198e59 620}
621
997ebe92 622sub _clean_booleans {
eefe56ac 623 my( $obj, $param, $val ) = @_;
624 if( $obj->meta->get_attribute( $param )->type_constraint->name eq 'Bool' ) {
997ebe92 625 $val = 1 if $val eq 'true';
626 $val = undef if $val eq 'false';
627 }
628 return $val;
629}
630
b8a92065 631=head2 end
632
633Attempt to render a view, if needed.
634
635=cut
636
637sub end : ActionClass('RenderView') {}
638
639=head1 AUTHOR
640
641Tara L Andrews
642
643=head1 LICENSE
644
645This library is free software. You can redistribute it and/or modify
646it under the same terms as Perl itself.
647
648=cut
649
650__PACKAGE__->meta->make_immutable;
651
6521;