46fb11fa3021b4dd4a411a450daa0c331a67b074
[scpubgit/stemmaweb.git] / lib / stemmaweb / Controller / Relation.pm
1 package stemmaweb::Controller::Relation;
2 use JSON qw/ to_json from_json /;
3 use Moose;
4 use Module::Load;
5 use namespace::autoclean;
6 use TryCatch;
7
8 BEGIN { extends 'Catalyst::Controller' }
9
10
11 =head1 NAME
12
13 stemmaweb::Controller::Relation - Controller for the relationship mapper
14
15 =head1 DESCRIPTION
16
17 The reading relationship mapper with draggable nodes.
18
19 =head1 METHODS
20
21 =head2 index
22
23  GET relation/$textid
24  
25 Renders the application for the text identified by $textid.
26
27 =cut
28
29 sub index :Path :Args(0) {
30         my( $self, $c ) = @_;
31         $c->stash->{'template'} = 'relate.tt';
32 }
33
34 =head2 text
35
36  GET relation/$textid/
37  
38  Runs the relationship mapper for the specified text ID.
39  
40 =cut
41
42 sub text :Chained('/') :PathPart('relation') :CaptureArgs(1) {
43         my( $self, $c, $textid ) = @_;
44         my $tradition = $c->model('Directory')->tradition( $textid );
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         
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     }
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
63         $c->stash->{'textid'} = $textid;
64         $c->stash->{'tradition'} = $tradition;
65 }
66
67 sub main :Chained('text') :PathPart('') :Args(0) {
68         my( $self, $c ) = @_;
69         my $tradition = delete $c->stash->{'tradition'};
70         my $collation = $tradition->collation;
71         
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         
82         # See how big the tradition is. Edges are more important than nodes
83         # when it comes to rendering difficulty.
84         my $numnodes = scalar $collation->readings;
85         my $numedges = scalar $collation->paths;
86         my $length = $collation->end->rank;
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 ) {
96                 # Segment the tradition in order not to overload the browser.
97                 my @divs;
98                 my $r = 0;
99                 while( $r + $margin < $length ) {
100                         push( @divs, $r );
101                         $r += $segsize;
102                 }
103                 $c->stash->{'textsegments'} = [];
104                 foreach my $i ( 0..$#divs ) {
105                         my $seg = { 'start' => $divs[$i] };
106                         $seg->{'display'} = "Segment " . ($i+1);
107                         push( @{$c->stash->{'textsegments'}}, $seg );
108                 }
109         }
110         my $startseg = $c->req->param('start');
111         my $svgopts;
112         if( $startseg ) {
113                 # Only render the subgraph from startseg to endseg or to END,
114                 # whichever is less.
115                 my $endseg = $startseg + $segsize + $margin;
116                 $svgopts = { 'from' => $startseg };
117                 $svgopts->{'to'} = $endseg if $endseg < $collation->end->rank;
118         } elsif( exists $c->stash->{'textsegments'} ) {
119                 # This is the unqualified load of a long tradition. We implicitly start 
120                 # at zero, but go only as far as our segment size.
121                 my $endseg = $segsize + $margin;
122                 $startseg = 0;
123                 $svgopts = { 'to' => $endseg };
124         }
125         # Spit out the SVG
126         my $svg_str = $collation->as_svg( $svgopts );
127         $svg_str =~ s/\n//gs;
128         $c->stash->{'startseg'} = $startseg if defined $startseg;
129         $c->stash->{'svg_string'} = $svg_str;
130         $c->stash->{'text_title'} = $tradition->name;
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         }
137         $c->stash->{'template'} = 'relate.tt';
138 }
139
140 sub _typesort {
141         my( $a, $b ) = @_;
142         my $blsort = $a->bindlevel <=> $b->bindlevel;
143         return $blsort if $blsort;
144         return $a->name cmp $b->name;
145 }
146
147 =head2 help
148
149  GET relation/help/$language
150
151 Returns the help window HTML.
152
153 =cut
154
155 sub 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');
166                 if( $has_mod ) {
167                         my $tagset = &$has_mod;
168                         $c->stash->{'tagset'} = $tagset;
169                 }
170         }
171         $c->stash->{'template'} = 'relatehelp.tt';
172 }
173
174 =head2 relationships
175
176  GET relation/$textid/relationships
177
178 Returns the list of relationships defined for this text.
179
180  POST relation/$textid/relationships { request }
181  
182 Attempts to define the requested relationship within the text. Returns 200 on
183 success or 403 on error.
184
185  DELETE relation/$textid/relationships { request }
186  
187
188 =cut
189
190 sub relationships :Chained('text') :PathPart :Args(0) {
191         my( $self, $c ) = @_;
192         my $tradition = delete $c->stash->{'tradition'};
193         my $ok = _check_permission( $c, $tradition );
194         return unless $ok;
195         my $collation = $tradition->collation;
196         my $m = $c->model('Directory');
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 );
202                         next if $relobj->type eq 'collated'; # Don't show these
203                         next if $p->[0] eq $p->[1]; # HACK until bugfix
204                         my $relhash = { source => $p->[0], target => $p->[1], 
205                                   type => $relobj->type, scope => $relobj->scope };
206                         $relhash->{'note'} = $relobj->annotation if $relobj->has_annotation;
207                         push( @all_relations, $relhash );
208                 }
209                 $c->stash->{'result'} = \@all_relations;
210         } else {
211                 # Check write permissions first of all
212                 if( $c->stash->{'permission'} ne 'full' ) {
213                         $c->response->status( '403' );
214                         $c->stash->{'result'} = { 
215                                 'error' => 'You do not have permission to modify this tradition.' };
216                         $c->detach( 'View::JSON' );
217                 } elsif( $c->request->method eq 'POST' ) {
218                         my $node = $c->request->param('source_id');
219                         my $target = $c->request->param('target_id');
220                         my $relation = $c->request->param('rel_type');
221                         my $note = $c->request->param('note');
222                         my $scope = $c->request->param('scope');
223                 
224                         my $opts = { 'type' => $relation, 'propagate' => 1 };
225                         $opts->{'scope'} = $scope if $scope;
226                         $opts->{'annotation'} = $note if $note;
227                         
228                         try {
229                                 my @vectors = $collation->add_relationship( $node, $target, $opts );
230                                 $c->stash->{'result'} = \@vectors;
231                                 $m->save( $tradition );
232                         } catch( Text::Tradition::Error $e ) {
233                                 $c->response->status( '403' );
234                                 $c->stash->{'result'} = { 'error' => $e->message };
235                         }
236                 } elsif( $c->request->method eq 'DELETE' ) {
237                         my $node = $c->request->param('source_id');
238                         my $target = $c->request->param('target_id');
239                         my $scopewide = $c->request->param('scopewide') 
240                                 && $c->request->param('scopewide') eq 'true';
241                         try {
242                                 my @vectors = $collation->del_relationship( $node, $target, $scopewide );
243                                 $m->save( $tradition );
244                                 $c->stash->{'result'} = \@vectors;
245                         } catch( Text::Tradition::Error $e ) {
246                                 $c->response->status( '403' );
247                                 $c->stash->{'result'} = { 'error' => $e->message };
248                         }       
249                 }
250         }
251         $c->forward('View::JSON');
252 }
253
254 =head2 readings
255
256  GET relation/$textid/readings
257
258 Returns the list of readings defined for this text along with their metadata.
259
260 =cut
261
262 my %read_write_keys = (
263         'id' => 0,
264         'text' => 0,
265         'is_meta' => 0,
266         'grammar_invalid' => 1,
267         'is_nonsense' => 1,
268         'normal_form' => 1,
269 );
270
271 sub _reading_struct {
272         my( $reading ) = @_;
273         # Return a JSONable struct of the useful keys.  Keys meant to be writable
274         # have a true value; read-only keys have a false value.
275         my $struct = {};
276         map { $struct->{$_} = $reading->$_ if $reading->can( $_ ) } keys( %read_write_keys );
277         # Special case
278         $struct->{'lexemes'} = $reading->can( 'lexemes' ) ? [ $reading->lexemes ] : [];
279         # Look up any words related via spelling or orthography
280         my $sameword = sub { 
281                 my $t = $_[0]->type;
282                 return $t eq 'spelling' || $t eq 'orthographic';
283         };
284         # Now add the list data
285         $struct->{'variants'} = [ map { $_->text } $reading->related_readings( $sameword ) ];
286         $struct->{'witnesses'} = [ $reading->witnesses ];
287         return $struct;
288 }
289
290 sub readings :Chained('text') :PathPart :Args(0) {
291         my( $self, $c ) = @_;
292         my $tradition = delete $c->stash->{'tradition'};
293         my $ok = _check_permission( $c, $tradition );
294         return unless $ok;
295         my $collation = $tradition->collation;
296         my $m = $c->model('Directory');
297         if( $c->request->method eq 'GET' ) {
298                 my $rdginfo = {};
299                 foreach my $rdg ( $collation->readings ) {
300                         $rdginfo->{$rdg->id} = _reading_struct( $rdg );
301                 }
302                 $c->stash->{'result'} = $rdginfo;
303         }
304         $c->forward('View::JSON');
305 }
306
307 =head2 reading
308
309  GET relation/$textid/reading/$id
310
311 Returns the list of readings defined for this text along with their metadata.
312
313  POST relation/$textid/reading/$id { request }
314  
315 Alters the reading according to the values in request. Returns 403 Forbidden if
316 the alteration isn't allowed.
317
318 =cut
319
320 sub reading :Chained('text') :PathPart :Args(1) {
321         my( $self, $c, $reading_id ) = @_;
322         my $tradition = delete $c->stash->{'tradition'};
323         my $collation = $tradition->collation;
324         my $rdg = $collation->reading( $reading_id );
325         my $m = $c->model('Directory');
326         if( $c->request->method eq 'GET' ) {
327                 $c->stash->{'result'} = $rdg ? _reading_struct( $rdg )
328                         : { 'error' => "No reading with ID $reading_id" };
329         } elsif ( $c->request->method eq 'POST' ) {
330                 if( $c->stash->{'permission'} ne 'full' ) {
331                         $c->response->status( '403' );
332                         $c->stash->{'result'} = { 
333                                 'error' => 'You do not have permission to modify this tradition.' };
334                         $c->detach('View::JSON');
335                         return;
336                 }
337                 my $errmsg;
338                 if( $rdg && $rdg->does('Text::Tradition::Morphology') ) {
339                         # Are we re-lemmatizing?
340                         if( $c->request->param('relemmatize') ) {
341                                 my $nf = $c->request->param('normal_form');
342                                 # TODO throw error unless $nf
343                                 $rdg->normal_form( $nf );
344                                 # TODO throw error if lemmatization fails
345                                 # TODO skip this if normal form hasn't changed
346                                 $rdg->lemmatize();
347                         } else {
348                                 # Set all the values that we have for the reading.
349                                 # TODO error handling
350                                 foreach my $p ( keys %{$c->request->params} ) {
351                                         if( $p =~ /^morphology_(\d+)$/ ) {
352                                                 # Set the form on the correct lexeme
353                                                 my $morphval = $c->request->param( $p );
354                                                 next unless $morphval;
355                                                 my $midx = $1;
356                                                 my $lx = $rdg->lexeme( $midx );
357                                                 my $strrep = $rdg->language . ' // ' . $morphval;
358                                                 my $idx = $lx->has_form( $strrep );
359                                                 unless( defined $idx ) {
360                                                         # Make the word form and add it to the lexeme.
361                                                         try {
362                                                                 $idx = $lx->add_matching_form( $strrep ) - 1;
363                                                         } catch( Text::Tradition::Error $e ) {
364                                                                 $c->response->status( '403' );
365                                                                 $errmsg = $e->message;
366                                                         } catch {
367                                                                 # Something else went wrong, probably a Moose error
368                                                                 $c->response->status( '403' );
369                                                                 $errmsg = 'Something went wrong with the request';      
370                                                         }
371                                                 }
372                                                 $lx->disambiguate( $idx ) if defined $idx;
373                                         } elsif( $read_write_keys{$p} ) {
374                                                 my $val = _clean_booleans( $rdg, $p, $c->request->param( $p ) );
375                                                 $rdg->$p( $val );
376                                         }
377                                 }               
378                         }
379                         $m->save( $rdg );
380                 } else {
381                         $errmsg = "Reading does not exist or cannot be morphologized";
382                 }
383                 $c->stash->{'result'} = $errmsg ? { 'error' => $errmsg }
384                         : _reading_struct( $rdg );
385
386         }
387         $c->forward('View::JSON');
388
389 }
390
391 =head2 duplicate
392
393  POST relation/$textid/duplicate { data }
394  
395 Duplicates the requested readings, detaching the witnesses specified in
396 the list to use the new reading(s) instead of the old. The data to be
397 passed should be a JSON structure:
398
399  { readings: rid1,rid2,rid3,...
400    witnesses: [ wit1, ... ] }
401
402 =cut
403
404 sub duplicate :Chained('text') :PathPart :Args(0) {
405         my( $self, $c ) = @_;
406         my $tradition = delete $c->stash->{'tradition'};
407         my $collation = $tradition->collation;
408         my $m = $c->model('Directory');
409         if( $c->request->method eq 'POST' ) {
410                 if( $c->stash->{'permission'} ne 'full' ) {
411                         $c->response->status( '403' );
412                         $c->stash->{'result'} = { 
413                                 'error' => 'You do not have permission to modify this tradition.' };
414                         $c->detach('View::JSON');
415                         return;
416                 }
417                 my $errmsg;
418                 my $response = {};
419                 # Sort out which readings need to be duplicated from the set given, and
420                 # ensure that all the given wits bear each relevant reading.
421                 
422                 my %wits = ();
423                 map { $wits{$_} = 1 } $c->request->param('witnesses[]');
424                 my %rdgranks = ();
425                 foreach my $rid ( $c->request->param('readings[]') ) {
426                         my $numwits = 0;
427                         my $rdg = $collation->reading( $rid );
428                         $DB::single = 1;
429                         foreach my $rwit ( $rdg->witnesses( $rid ) ) {
430                                 $numwits++ if exists $wits{$rwit};
431                         }
432                         next unless $numwits; # Disregard readings with none of our witnesses
433                         if( $numwits < keys( %wits ) ) {
434                                 $errmsg = "Reading $rid contains some but not all of the specified witnesses.";
435                                 last;
436                         } elsif( exists $rdgranks{ $rdg->rank } ) {
437                                 $errmsg = "More than one reading would be detached along with $rid at rank " . $rdg->rank;
438                                 last;
439                         } else {
440                                 $rdgranks{ $rdg->rank } = $rid;
441                         }
442                 }
443                 
444                 # Now check that the readings make a single sequence.
445                 unless( $errmsg ) {
446                         my $prior;
447                         foreach my $rank ( sort { $a <=> $b } keys %rdgranks ) {
448                                 my $rid = $rdgranks{$rank};
449                                 if( $prior ) {
450                                         # Check that there is only one path between $prior and $rdg.
451                                         foreach my $wit ( keys %wits ) {
452                                                 unless( $collation->prior_reading( $rid, $wit ) eq $prior ) {
453                                                         $errmsg = "Diverging witness paths from $prior to $rid at $wit";
454                                                         last;
455                                                 }
456                                         }
457                                 }
458                                 $prior = $rid;
459                         }
460                 }
461                 
462                 # Abort if we've run into a problem.
463                 if( $errmsg ) {
464                         $c->stash->{'result'} = { 'error' => $errmsg };
465                         $c->response->status( '403' );
466                         $c->forward('View::JSON');
467                         return;
468                 }
469                 
470                 # Otherwise, do the dirty work.
471                 my @witlist = keys %wits;
472                 foreach my $rank ( sort { $a <=> $b } keys %rdgranks ) {
473                         my $newrdg;
474                         my $reading_id = $rdgranks{$rank};
475                         try {
476                                 $newrdg = $collation->duplicate_reading( $reading_id, @witlist );
477                         } catch( Text::Tradition::Error $e ) {
478                                 $c->response->status( '403' );
479                                 $errmsg = $e->message;
480                         } catch {
481                                 # Something else went wrong, probably a Moose error
482                                 $c->response->status( '403' );
483                                 $errmsg = 'Something went wrong with the request';      
484                         }
485                         if( $newrdg ) {
486                                 my $data = _reading_struct( $newrdg );
487                                 $data->{'orig_rdg'} = $reading_id;
488                                 $response->{"$newrdg"} = $data;
489                         }
490                 } 
491                 if( $errmsg ) {
492                         $c->stash->{'result'} = { 'error' => $errmsg };
493                 } else {
494                         $m->save( $collation );
495                         $c->stash->{'result'} = $response;
496                 }
497         }
498         $c->forward('View::JSON');
499 }
500
501
502
503 sub _check_permission {
504         my( $c, $tradition ) = @_;
505     my $user = $c->user_exists ? $c->user->get_object : undef;
506     # Does this user have access?
507     if( $user ) {
508                 if( $user->is_admin || 
509                         ( $tradition->has_user && $tradition->user->id eq $user->id ) ) {
510                         $c->stash->{'permission'} = 'full';
511                         return 1;
512                 }
513     } 
514     # Is it public?
515     if( $tradition->public ) {
516         $c->stash->{'permission'} = 'readonly';
517         return 1;
518     } 
519         # Forbidden!
520         $c->response->status( 403 );
521         $c->response->body( 'You do not have permission to view this tradition.' );
522         $c->detach( 'View::Plain' );
523         return 0;
524 }
525
526 sub _clean_booleans {
527         my( $rdg, $param, $val ) = @_;
528         if( $rdg->meta->get_attribute( $param )->type_constraint->name eq 'Bool' ) {
529                 $val = 1 if $val eq 'true';
530                 $val = undef if $val eq 'false';
531         } 
532         return $val;
533 }
534
535 =head2 end
536
537 Attempt to render a view, if needed.
538
539 =cut
540
541 sub end : ActionClass('RenderView') {}
542
543 =head1 AUTHOR
544
545 Tara L Andrews
546
547 =head1 LICENSE
548
549 This library is free software. You can redistribute it and/or modify
550 it under the same terms as Perl itself.
551
552 =cut
553
554 __PACKAGE__->meta->make_immutable;
555
556 1;