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