c5f3281ed05c71ef615acb6d29ba46bd6d0cb73f
[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_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                                   };
212                         $relhash->{'note'} = $relobj->annotation if $relobj->has_annotation;
213                         push( @all_relations, $relhash );
214                 }
215                 $c->stash->{'result'} = \@all_relations;
216         } else {
217                 # Check write permissions first of all
218                 if( $c->stash->{'permission'} ne 'full' ) {
219                         $c->response->status( '403' );
220                         $c->stash->{'result'} = { 
221                                 'error' => 'You do not have permission to modify this tradition.' };
222                         $c->detach( 'View::JSON' );
223                 } elsif( $c->request->method eq 'POST' ) {
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                         }
247                 
248                         delete $opts->{scope} unless $opts->{scope};
249                         delete $opts->{annotation} unless $opts->{annotation};
250                         $opts->{propagate} = 1;
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');
263                         my $scopewide = $c->request->param('scopewide') 
264                                 && $c->request->param('scopewide') eq 'true';
265                         try {
266                                 my @vectors = $collation->del_relationship( $node, $target, $scopewide );
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                         }       
273                 }
274         }
275         $c->forward('View::JSON');
276 }
277
278 =head2 readings
279
280  GET relation/$textid/readings
281
282 Returns the list of readings defined for this text along with their metadata.
283
284 =cut
285
286 my %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
295 sub _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.
299         my $struct = {};
300         map { $struct->{$_} = $reading->$_ if $reading->can( $_ ) } keys( %read_write_keys );
301         # Special case
302         $struct->{'lexemes'} = $reading->can( 'lexemes' ) ? [ $reading->lexemes ] : [];
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         };
308         # Now add the list data
309         $struct->{'variants'} = [ map { $_->text } $reading->related_readings( $sameword ) ];
310         $struct->{'witnesses'} = [ $reading->witnesses ];
311         return $struct;
312 }
313
314 sub readings :Chained('text') :PathPart :Args(0) {
315         my( $self, $c ) = @_;
316         my $tradition = delete $c->stash->{'tradition'};
317         my $ok = _check_permission( $c, $tradition );
318         return unless $ok;
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
335 Returns the list of readings defined for this text along with their metadata.
336
337  POST relation/$textid/reading/$id { request }
338  
339 Alters the reading according to the values in request. Returns 403 Forbidden if
340 the alteration isn't allowed.
341
342 =cut
343
344 sub reading :Chained('text') :PathPart :Args(1) {
345         my( $self, $c, $reading_id ) = @_;
346         my $tradition = delete $c->stash->{'tradition'};
347         my $collation = $tradition->collation;
348         my $rdg = $collation->reading( $reading_id );
349         my $m = $c->model('Directory');
350         if( $c->request->method eq 'GET' ) {
351                 $c->stash->{'result'} = $rdg ? _reading_struct( $rdg )
352                         : { 'error' => "No reading with ID $reading_id" };
353         } elsif ( $c->request->method eq 'POST' ) {
354                 if( $c->stash->{'permission'} ne 'full' ) {
355                         $c->response->status( '403' );
356                         $c->stash->{'result'} = { 
357                                 'error' => 'You do not have permission to modify this tradition.' };
358                         $c->detach('View::JSON');
359                         return;
360                 }
361                 my $errmsg;
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                                                         }
395                                                 }
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 );
400                                         }
401                                 }               
402                         }
403                         $m->save( $rdg );
404                 } else {
405                         $errmsg = "Reading does not exist or cannot be morphologized";
406                 }
407                 $c->stash->{'result'} = $errmsg ? { 'error' => $errmsg }
408                         : _reading_struct( $rdg );
409
410         }
411         $c->forward('View::JSON');
412
413 }
414
415 =head2 merge
416
417  POST relation/$textid/merge { data }
418  
419 Merges the requested readings, combining the witnesses of both readings into
420 the target reading. All non-conflicting source relationships are inherited by
421 the target relationship.
422
423 =cut
424
425 sub 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' };
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                                 }
475                         }
476                         $m->save( $collation );
477                 }
478                 $c->stash->{'result'} = $response;
479                 $c->forward('View::JSON');                      
480         }
481 }
482
483 =head2 duplicate
484
485  POST relation/$textid/duplicate { data }
486  
487 Duplicates the requested readings, detaching the witnesses specified in
488 the list to use the new reading(s) instead of the old. The data to be
489 passed should be a JSON structure:
490
491  { readings: rid1,rid2,rid3,...
492    witnesses: [ wit1, ... ] }
493
494 =cut
495
496 sub duplicate :Chained('text') :PathPart :Args(0) {
497         my( $self, $c ) = @_;
498         my $tradition = delete $c->stash->{'tradition'};
499         my $collation = $tradition->collation;
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 = {};
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                         }
523                         next unless $numwits; # Disregard readings with none of our witnesses
524                         if( $numwits < keys( %wits ) ) {
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;
563                 my @deleted_relations;
564                 foreach my $rank ( sort { $a <=> $b } keys %rdgranks ) {
565                         my $newrdg;
566                         my $reading_id = $rdgranks{$rank};
567                         my @delrels;
568                         try {
569                                 ( $newrdg, @delrels ) = 
570                                         $collation->duplicate_reading( $reading_id, @witlist );
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 ) {
580                                 my $data = _reading_struct( $newrdg );
581                                 $data->{'orig_rdg'} = $reading_id;
582                                 $response->{"$newrdg"} = $data;
583                                 push( @deleted_relations, @delrels );
584                         }
585                 } 
586                 if( $errmsg ) {
587                         $c->stash->{'result'} = { 'error' => $errmsg };
588                 } else {
589                         $m->save( $collation );
590                         $response->{'DELETED'} = \@deleted_relations;
591                         $c->stash->{'result'} = $response;
592                 }
593         }
594         $c->forward('View::JSON');
595 }
596
597
598
599 sub _check_permission {
600         my( $c, $tradition ) = @_;
601     my $user = $c->user_exists ? $c->user->get_object : undef;
602     # Does this user have access?
603     if( $user ) {
604                 if( $user->is_admin || 
605                         ( $tradition->has_user && $tradition->user->id eq $user->id ) ) {
606                         $c->stash->{'permission'} = 'full';
607                         return 1;
608                 }
609     } 
610     # Is it public?
611     if( $tradition->public ) {
612         $c->stash->{'permission'} = 'readonly';
613         return 1;
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;
620 }
621
622 sub _clean_booleans {
623         my( $obj, $param, $val ) = @_;
624         if( $obj->meta->get_attribute( $param )->type_constraint->name eq 'Bool' ) {
625                 $val = 1 if $val eq 'true';
626                 $val = undef if $val eq 'false';
627         } 
628         return $val;
629 }
630
631 =head2 end
632
633 Attempt to render a view, if needed.
634
635 =cut
636
637 sub end : ActionClass('RenderView') {}
638
639 =head1 AUTHOR
640
641 Tara L Andrews
642
643 =head1 LICENSE
644
645 This library is free software. You can redistribute it and/or modify
646 it under the same terms as Perl itself.
647
648 =cut
649
650 __PACKAGE__->meta->make_immutable;
651
652 1;