[Bio] / FigKernelPackages / ServerThing.pm Repository:
ViewVC logotype

Diff of /FigKernelPackages/ServerThing.pm

Parent Directory Parent Directory | Revision Log Revision Log | View Patch Patch

revision 1.3, Mon Aug 3 21:31:42 2009 UTC revision 1.61, Wed Jan 26 15:32:08 2011 UTC
# Line 5  Line 5 
5      use strict;      use strict;
6      use Tracer;      use Tracer;
7      use YAML;      use YAML;
8        use JSON::Any;
9      use ERDB;      use ERDB;
10      use TestUtils;      use TestUtils;
11      use Time::HiRes;      use Time::HiRes;
12      use ErrorDocument;      use File::Temp;
13        use ErrorMessage;
14      use CGI;      use CGI;
15        no warnings qw(once);
16    
17        # Maximum number of requests to run per invocation.
18        use constant MAX_REQUESTS => 50;
19    
20  =head1 General Server Helper  =head1 General Server Helper
21    
22  This package provides a method-- I<RunServer>-- that can be called from a CGI  This package provides a method-- I<RunServer>-- that can be called from a CGI
23  script to perform the duties of a FIG server. RunServer is called with two  script to perform the duties of a FIG server. RunServer is called with two
24  parameters: the name of the server package (e.g. C<SAP> for B<SAP.pm>) and  parameters: the name of the server package (e.g. C<SAP> for B<SAP.pm>) and
25  the first command-line parameter. This last is only used when the server  the first command-line parameter. The command-line parameter (if defined) will
26  script is being invoked from the debugging console.  be used as the tracing key, and also indicates that the script is being invoked
27    from the command line rather than over the web.
28    
29  =cut  =cut
30    
31  sub RunServer {  sub RunServer {
32      # Get the parameters.      # Get the parameters.
33      my ($serverName, $key) = @_;      my ($serverName, $key) = @_;
34      # Get the CGI parameters.      # Set up tracing. We never do CGI tracing here; the only question is whether
35      my $cgi;      # or not the caller passed in a tracing key. If he didn't, we use the server
36      if (! $key) {      # name.
37          # No tracing key, so presume we're a web service.      ETracing($key || $serverName, destType => 'APPEND', level => '0 ServerThing');
38          $cgi = CGI->new();      # Turn off YAML compression, which causes problems with some of our hash keys.
39          # Check for a source parameter. This gets used as the tracing key.      $YAML::CompressSeries = 0;
40          $key = $cgi->param('source');      # Create the server object.
41          if (! $key) {      Trace("Requiring $serverName for task $$.") if T(3);
             # No source parameter, so do normal setup.  
             ETracing($cgi);  
         } else {  
             # Set up tracing using the specified key.  
             ETracing($key);  
             # Trace the CGI parameters.  
             Tracer::TraceParms($cgi);  
         }  
     } else {  
         # We're being invoked from the command line. Use the tracing  
         # key to find the parm file and create the CGI object from that.  
         my $ih = Open(undef, "<$FIG_Config::temp/$key.parms");  
         $cgi = CGI->new($ih);  
         # Set up tracing using the specified key.  
         ETracing($key);  
         # Trace the CGI parameters.  
         Tracer::TraceParms($cgi);  
     }  
     Trace("Running $serverName server request.") if T(3);  
     # Get the function name.  
     my $function = $cgi->param('function') || "";  
     Trace("Server function is $function.") if T(3);  
     # Insure the function name is valid.  
     Die("Invalid function name.")  
         if $function =~ /\W/;  
     # The parameter structure will go in here.  
     my $args;  
     # Start the timer.  
     my $start = time();  
     # The output document goes in here.  
     my $document;  
     # The sapling database goes in here.  
     my $sapling;  
     # Protect from errors.  
     eval {  
         # Parse the arguments.  
         $args = YAML::Load($cgi->param('args'));  
     };  
     # Check to make sure we got everything.  
     if ($@) {  
         $document = ErrorDocument->new('<initialization>', $@);  
     } elsif (! $function) {  
         $document = ErrorDocument->new('<missing>', "No function specified.");  
     } else {  
         # We're okay, so load the server function object.  
         Trace("Requiring $serverName") if T(3);  
42          eval {          eval {
43              require "$serverName.pm";          my $output = $serverName;
44            $output =~ s/::/\//;
45            require "$output.pm";
46          };          };
47          # If we have an error, create an error document.          # If we have an error, create an error document.
48          if ($@) {          if ($@) {
49              $document = ErrorDocument->new($function, $@);          SendError($@, "Could not load server module.");
             Trace("Error loadin server module: $@") if T(2);  
50          } else {          } else {
51              # Having successfully loaded the server code, we create the object.              # Having successfully loaded the server code, we create the object.
52              my $serverThing = eval("$serverName" . '->new()');              my $serverThing = eval("$serverName" . '->new()');
53            Trace("$serverName object created for task $$.") if T(2);
54              # If we have an error, create an error document.              # If we have an error, create an error document.
55              if ($@) {              if ($@) {
56                  $document = ErrorDocument->new($function, $@);              SendError($@, "Could not start server.");
                 Trace("Error creating server function object: $@") if T(2);  
57              } else {              } else {
58                  # No error, so execute the server method.              # No error, so now we can process the request.
59                  Trace("Executing $function.") if T(2);              my $cgi;
60                  $document = eval("\$serverThing->$function(\$args)");              if (! defined $key) {
61                  # If we have an error, create an error document.                  # No tracing key, so presume we're a web service. Check for Fast CGI.
62                  if ($@) {                  if ($ENV{REQUEST_METHOD} eq '') {
63                      $document = ErrorDocument->new($function, $@);                      # Count the number of requests.
64                      Trace("Error encountered by service: $@") if T(2);                      my $requests = 0;
65                        Trace("Starting Fast CGI loop.") if T(3);
66                        # Loop through the fast CGI requests. If we have request throttling,
67                        # we exit after a maximum number of requests has been exceeded.
68                        require CGI::Fast;
69                        while ((MAX_REQUESTS == 0 || ++$requests < MAX_REQUESTS) &&
70                               ($cgi = new CGI::Fast())) {
71                            RunRequest($cgi, $serverThing);
72                            Trace("Request $requests complete in task $$.") if T(3);
73                        }
74                        Trace("Terminating FastCGI task $$ after $requests requests.") if T(2);
75                    } else {
76                        # Here we have a normal web service (non-Fast).
77                        my $cgi = CGI->new();
78                        # Check for a source parameter. This gets used as the tracing key.
79                        $key = $cgi->param('source');
80                        # Run this request.
81                        RunRequest($cgi, $serverThing);
82                  }                  }
83                } else {
84                    # We're being invoked from the command line. Use the tracing
85                    # key to find the parm file and create the CGI object from that.
86                    my $ih = Open(undef, "<$FIG_Config::temp/$key.parms");
87                    $cgi = CGI->new($ih);
88                    # Run this request.
89                    RunRequest($cgi, $serverThing);
90              }              }
91          }          }
92      }      }
     # Stop the timer.  
     my $duration = int(time() - $start + 0.5);  
     Trace("Function executed in $duration seconds.") if T(2);  
     # Output the YAML.  
     print $cgi->header(-type => 'text/plain');  
     print YAML::Dump($document);  
93  }  }
94    
95  =head2 Utility Methods  
96    =head2 Server Utility Methods
97    
98  The methods in this section are utilities of general use to the various  The methods in this section are utilities of general use to the various
99  server modules.  server modules.
100    
101    =head3 AddSubsystemFilter
102    
103        ServerThing::AddSubsystemFilter(\$filter, $args, $roles);
104    
105    Add subsystem filtering information to the specified query filter clause
106    based on data in the argument hash. The argument hash will be checked for
107    the C<-usable> parameter, which includes or excludes unusuable subsystems,
108    the C<-exclude> parameter, which lists types of subsystems that should be
109    excluded, and the C<-aux> parameter, which filters on auxiliary roles.
110    
111    =over 4
112    
113    =item filter
114    
115    Reference to the current filter string. If additional filtering is required,
116    this string will be updated.
117    
118    =item args
119    
120    Reference to the parameter hash for the current server call. This hash will
121    be examined for the C<-usable> and C<-exclude> parameters.
122    
123    =item roles
124    
125    If TRUE, role filtering will be applied. In this case, the default action
126    is to exclude auxiliary roles unless C<-aux> is TRUE.
127    
128    =back
129    
130    =cut
131    
132    use constant SS_TYPE_EXCLUDE_ITEMS => { 'cluster-based' => 1,
133                                             experimental   => 1,
134                                             private        => 1 };
135    
136    sub AddSubsystemFilter {
137        # Get the parameters.
138        my ($filter, $args, $roles) = @_;
139        # We'll put the new filter stuff in here.
140        my @newFilters;
141        # Unless unusable subsystems are desired, we must add a clause to the filter.
142        # The default is that only usable subsystems are included.
143        my $usable = 1;
144        # This default can be overridden by the "-usable" parameter.
145        if (exists $args->{-usable}) {
146            $usable = $args->{-usable};
147        }
148        # If we're restricting to usable subsystems, add a filter to that effect.
149        if ($usable) {
150            push @newFilters, "Subsystem(usable) = 1";
151        }
152        # Check for exclusion filters.
153        my $exclusions = ServerThing::GetIdList(-exclude => $args, 1);
154        for my $exclusion (@$exclusions) {
155            if (! SS_TYPE_EXCLUDE_ITEMS->{$exclusion}) {
156                Confess("Invalid exclusion type \"$exclusion\".");
157            } else {
158                # Here we have to exclude subsystems of the specified type.
159                push @newFilters, "Subsystem($exclusion) = 0";
160            }
161        }
162        # Check for role filtering.
163        if ($roles) {
164            # Here, we filter out auxiliary roles unless the user requests
165            # them.
166            if (! $args->{-aux}) {
167                push @newFilters, "Includes(auxiliary) = 0"
168            }
169        }
170        # Do we need to update the incoming filter?
171        if (@newFilters) {
172            # Yes. If the incoming filter is nonempty, push it onto the list
173            # so it gets included in the result.
174            if ($$filter) {
175                push @newFilters, $$filter;
176            }
177            # Put all the filters together to form the new filter.
178            $$filter = join(" AND ", @newFilters);
179            Trace("Subsystem filter is $$filter.") if T(ServerUtilities => 3);
180        }
181    }
182    
183    
184    
185  =head3 GetIdList  =head3 GetIdList
186    
187      my $ids = ServerThing::GetIdList($name => $args);      my $ids = ServerThing::GetIdList($name => $args, $optional);
188    
189  Get a named list of IDs from an argument structure. If the IDs are  Get a named list of IDs from an argument structure. If the IDs are
190  missing, or are not a list, an error will occur.  missing, or are not a list, an error will occur.
# Line 134  Line 199 
199    
200  Argument structure from which the ID list is to be extracted.  Argument structure from which the ID list is to be extracted.
201    
202    =item optional (optional)
203    
204    If TRUE, then a missing value will not generate an error. Instead, an empty list
205    will be returned. The default is FALSE.
206    
207  =item RETURN  =item RETURN
208    
209  Returns a reference to a list of IDs taken from the argument structure.  Returns a reference to a list of IDs taken from the argument structure.
# Line 144  Line 214 
214    
215  sub GetIdList {  sub GetIdList {
216      # Get the parameters.      # Get the parameters.
217      my ($name, $args) = @_;      my ($name, $args, $optional) = @_;
218      # Try to get the IDs from the argument structure.      # Declare the return variable.
219      my $retVal = $args->{$name};      my $retVal;
220      # Throw an error if no member was found.      # Check the argument format.
221      Confess("No '$name' parameter found.") if ! defined $retVal;      if (! defined $args && $optional) {
222      # Get the parameter type. We was a list reference. If it's a scalar, we'll          # Here there are no parameters, but the arguments are optional so it's
223      # convert it to a singleton list. If it's anything else, it's an error.          # okay.
224            $retVal = [];
225        } elsif (ref $args ne 'HASH') {
226            # Here we have an invalid parameter structure.
227            Confess("No '$name' parameter present.");
228        } else {
229            # Here we have a hash with potential parameters in it. Try to get the
230            # IDs from the argument structure.
231            $retVal = $args->{$name};
232            # Was a member found?
233            if (! defined $retVal) {
234                # No. If we're optional, return an empty list; otherwise throw an error.
235                if ($optional) {
236                    $retVal = [];
237                } else {
238                    Confess("No '$name' parameter found.");
239                }
240            } else {
241                # Here we found something. Get the parameter type. We want a list reference.
242                # If it's a scalar, we'll convert it to a singleton list. If it's anything
243                # else, it's an error.
244      my $type = ref $retVal;      my $type = ref $retVal;
245      if (! $type) {      if (! $type) {
246          $retVal = [$retVal];          $retVal = [$retVal];
247      } elsif ($type ne 'ARRAY') {      } elsif ($type ne 'ARRAY') {
248          Confess("The '$name' parameter must be a list.");          Confess("The '$name' parameter must be a list.");
249      }      }
250            }
251        }
252        # Return the result.
253        return $retVal;
254    }
255    
256    
257    =head3 RunTool
258    
259        ServerThing::RunTool($name => $cmd);
260    
261    Run a command-line tool. A non-zero return value from the tool will cause
262    a fatal error, and the tool's error log will be traced.
263    
264    =over 4
265    
266    =item name
267    
268    Name to give to the tool in the error output.
269    
270    =item cmd
271    
272    Command to use for running the tool. This should be the complete command line.
273    The command should not contain any fancy piping, though it may redirect the
274    standard input and output. The command will be modified by this method to
275    redirect the error output to a temporary file.
276    
277    =back
278    
279    =cut
280    
281    sub RunTool {
282        # Get the parameters.
283        my ($name, $cmd) = @_;
284        # Compute the log file name.
285        my $errorLog = "$FIG_Config::temp/errors$$.log";
286        # Execute the command.
287        Trace("Executing command: $cmd") if T(ServerUtilities => 3);
288        my $res = system("$cmd 2> $errorLog");
289        Trace("Return from $name tool is $res.") if T(ServerUtilities => 3);
290        # Check the result code.
291        if ($res != 0) {
292            # We have an error. If tracing is on, trace it.
293            if (T(ServerUtilities => 1)) {
294                TraceErrorLog($name, $errorLog);
295            }
296            # Delete the error log.
297            unlink $errorLog;
298            # Confess the error.
299            Confess("$name command failed with error code $res.");
300        } else {
301            # Everything worked. Trace the error log if necessary.
302            if (T(ServerUtilities => 3) && -s $errorLog) {
303                TraceErrorLog($name, $errorLog);
304            }
305            # Delete the error log if there is one.
306            unlink $errorLog;
307        }
308    }
309    
310    =head3 ReadCountVector
311    
312        my $vector = ServerThing::ReadCountVector($qh, $field, $rawFlag);
313    
314    Extract a count vector from a query. The query can contain zero or more results,
315    and the vectors in the specified result field of the query must be concatenated
316    together in order. This method is optimized for the case (expected to be most
317    common) where there is only one result.
318    
319    =over 4
320    
321    =item qh
322    
323    Handle for the query from which results are to be extracted.
324    
325    =item field
326    
327    Name of the field containing the count vectors.
328    
329    =item rawFlag
330    
331    TRUE if the vector is to be returned as a raw string, FALSE if it is to be returned
332    as reference to a list of numbers.
333    
334    =item RETURN
335    
336    Returns the desired vector, either encoded as a string or as a reference to a list
337    of numbers.
338    
339    =back
340    
341    =cut
342    
343    sub ReadCountVector {
344        # Get the parameters.
345        my ($qh, $field, $rawFlag) = @_;
346        # Declare the return variable.
347        my $retVal;
348        # Loop through the query results.
349        while (my $resultRow = $qh->Fetch()) {
350            # Get this vector.
351            my ($levelVector) = $resultRow->Value($field, $rawFlag);
352            # Is this the first result?
353            if (! defined $retVal) {
354                # Yes. Assign the result directly.
355                $retVal = $levelVector;
356            } elsif ($rawFlag) {
357                # This is a second result and the vectors are coded as strings.
358                $retVal .= $levelVector;
359            } else {
360                # This is a second result and the vectors are coded as array references.
361                push @$retVal, @$levelVector;
362            }
363        }
364        # Return the result.
365        return $retVal;
366    }
367    
368    =head3 ChangeDB
369    
370        ServerThing::ChangeDB($thing, $newDbName);
371    
372    Change the sapling database used by this server. The old database will be closed and a
373    new one attached.
374    
375    =over 4
376    
377    =item newDbName
378    
379    Name of the new Sapling database on which this server should operate. If omitted, the
380    default database will be used.
381    
382    =back
383    
384    =cut
385    
386    sub ChangeDB {
387        # Get the parameters.
388        my ($thing, $newDbName) = @_;
389        # Default the db-name if it's not specified.
390        if (! defined $newDbName) {
391            $newDbName = $FIG_Config::saplingDB;
392        }
393        # Check to see if we really need to change.
394        my $oldDB = $thing->{db};
395        if (! defined $oldDB || $oldDB->dbName() ne $newDbName) {
396            # We need a new sapling.
397            require Sapling;
398            my $newDB = Sapling->new(dbName => $newDbName);
399            $thing->{db} = $newDB;
400        }
401    }
402    
403    
404    =head2 Gene Correspondence File Methods
405    
406    These methods relate to gene correspondence files, which are generated by the
407    L<svr_corresponding_genes.pl> script. Correspondence files are cached in the
408    organism cache (I<$FIG_Config::orgCache>) directory. Eventually they will be
409    copied into the organism directories themselves. At that point, the code below
410    will be modified to check the organism directories first and use the cache
411    directory if no file is found there.
412    
413    A gene correspondence file contains correspondences from a source genome to a
414    target genome. Most such correspondences are bidirectional best hits. A unidirectional
415    best hit may exist from the source genome to the target genome or in the reverse
416    direction from the targtet genome to the source genome. The cache directory itself
417    is divided into subdirectories by organism. The subdirectory has the source genome
418    name and the files themselves are named by the target genome.
419    
420    Some of the files are invalid and will be erased when they are found. A file is
421    considered invalid if it has a non-numeric value in a numeric column or if it
422    does not have any unidirectional hits from the target genome to the source
423    genome.
424    
425    The process of managing the correspondence files is tricky and dangerous because
426    of the possibility of race conditions. It can take several minutes to generate a
427    file, and if two processes try to generate the same file at the same time we need
428    to make sure they don't step on each other.
429    
430    In stored files, the source genome ID is always lexically lower than the target
431    genome ID. If a correspondence in the reverse direction is desired, the converse
432    file is found and the contents flipped automatically as they are read. So, the
433    correspondence from B<360108.3> to B<100226.1> would be found in a file with the
434    name B<360108.3> in the directory for B<100226.1>. Since this file actually has
435    B<100226.1> as the source and B<360108.3> as the target, the columns are
436    re-ordered and the arrows reversed before the file contents are passed to the
437    caller.
438    
439    =head4 Gene Correspondence List
440    
441    A gene correspondence file contains 18 columns. These are usually packaged as
442    a reference to list of lists. Each sub-list has the following format.
443    
444    =over 4
445    
446    =item 0
447    
448    The ID of a PEG in genome 1.
449    
450    =item 1
451    
452    The ID of a PEG in genome 2 that is our best estimate of a "corresponding gene".
453    
454    =item 2
455    
456    Count of the number of pairs of matching genes were found in the context.
457    
458    =item 3
459    
460    Pairs of corresponding genes from the contexts.
461    
462    =item 4
463    
464    The function of the gene in genome 1.
465    
466    =item 5
467    
468    The function of the gene in genome 2.
469    
470    =item 6
471    
472    Comma-separated list of aliases for the gene in genome 1 (any protein with an
473    identical sequence is considered an alias, whether or not it is actually the
474    name of the same gene in the same genome).
475    
476    =item 7
477    
478    Comma-separated list of aliases for the gene in genome 2 (any protein with an
479    identical sequence is considered an alias, whether or not it is actually the
480    name of the same gene in the same genome).
481    
482    =item 8
483    
484    Bi-directional best hits will contain "<=>" in this column; otherwise, "->" will appear.
485    
486    =item 9
487    
488    Percent identity over the region of the detected match.
489    
490    =item 10
491    
492    The P-score for the detected match.
493    
494    =item 11
495    
496    Beginning match coordinate in the protein encoded by the gene in genome 1.
497    
498    =item 12
499    
500    Ending match coordinate in the protein encoded by the gene in genome 1.
501    
502    =item 13
503    
504    Length of the protein encoded by the gene in genome 1.
505    
506    =item 14
507    
508    Beginning match coordinate in the protein encoded by the gene in genome 2.
509    
510    =item 15
511    
512    Ending match coordinate in the protein encoded by the gene in genome 2.
513    
514    =item 16
515    
516    Length of the protein encoded by the gene in genome 2.
517    
518    =item 17
519    
520    Bit score for the match. Divide by the length of the longer PEG to get
521    what we often refer to as a "normalized bit score".
522    
523    =back
524    
525    In the actual files, there will also be reverse correspondences indicated by a
526    back-arrow ("<-") in item (8). The output returned by the servers, however,
527    is filtered so that only forward correspondences occur. If a converse file
528    is used, the columns are re-ordered and the arrows reversed so that it looks
529    correct.
530    
531    =cut
532    
533    # hash for reversing the arrows
534    use constant ARROW_FLIP => { '->' => '<-', '<=>' => '<=>', '<-' => '->' };
535    # list of columns that contain numeric values that need to be validated
536    use constant NUM_COLS => [2,9,10,11,12,13,14,15,16,17];
537    
538    =head3 CheckForGeneCorrespondenceFile
539    
540        my ($fileName, $converse) = ServerThing::CheckForGeneCorrespondenceFile($genome1, $genome2);
541    
542    Try to find a gene correspondence file for the specified genome pairing. If the
543    file exists, its name and an indication of whether or not it is in the correct
544    direction will be returned.
545    
546    =over 4
547    
548    =item genome1
549    
550    Source genome for the desired correspondence.
551    
552    =item genome2
553    
554    Target genome for the desired correspondence.
555    
556    =item RETURN
557    
558    Returns a two-element list. The first element is the name of the file containing the
559    correspondence, or C<undef> if the file does not exist. The second element is TRUE
560    if the correspondence would be forward or FALSE if the file needs to be flipped.
561    
562    =back
563    
564    =cut
565    
566    sub CheckForGeneCorrespondenceFile {
567        # Get the parameters.
568        my ($genome1, $genome2) = @_;
569        # Declare the return variables.
570        my ($fileName, $converse);
571        # Determine the ordering of the genome IDs.
572        my ($corrFileName, $genomeA, $genomeB) = ComputeCorrespondenceFileName($genome1, $genome2);
573        $converse = ($genomeA ne $genome1);
574        # Look for a file containing the desired correspondence. (The code to check for a
575        # pre-computed file in the organism directories is currently turned off, because
576        # these files are all currently invalid.)
577        my $testFileName = "$FIG_Config::organisms/$genomeA/CorrToReferenceGenomes/$genomeB";
578        if (0 && -f $testFileName) {
579            # Use the pre-computed file.
580            Trace("Using pre-computed file $fileName for genome correspondence.") if T(Corr => 3);
581            $fileName = $testFileName;
582        } elsif (-f $corrFileName) {
583            $fileName = $corrFileName;
584            Trace("Using cached file $fileName for genome correspondence.") if T(Corr => 3);
585        }
586      # Return the result.      # Return the result.
587        return ($fileName, $converse);
588    }
589    
590    
591    =head3 ComputeCorrespondenceFileName
592    
593        my ($fileName, $genomeA, $genomeB) = ServerThing::ComputeCorrespondenceFileName($genome1, $genome2);
594    
595    Compute the name to be given to a genome correspondence file in the organism cache
596    and return the source and target genomes that would be in it.
597    
598    =over 4
599    
600    =item genome1
601    
602    Source genome for the desired correspondence.
603    
604    =item genome2
605    
606    Target genome for the desired correspondence.
607    
608    =item RETURN
609    
610    Returns a three-element list. The first element is the name of the file to contain the
611    correspondence, the second element is the name of the genome that would act as the
612    source genome in the file, and the third element is the name of the genome that would
613    act as the target genome in the file.
614    
615    =back
616    
617    =cut
618    
619    sub ComputeCorrespondenceFileName {
620        # Get the parameters.
621        my ($genome1, $genome2) = @_;
622        # Declare the return variables.
623        my ($fileName, $genomeA, $genomeB);
624        # Determine the ordering of the genome IDs.
625        if (MustFlipGenomeIDs($genome1, $genome2)) {
626            ($genomeA, $genomeB) = ($genome2, $genome1);
627        } else {
628            ($genomeA, $genomeB) = ($genome1, $genome2);
629        }
630        # Insure the source organism has a subdirectory in the organism cache.
631        my $orgDir = ComputeCorrespondenceDirectory($genomeA);
632        # Compute the name of the correspondence file for the appropriate target genome.
633        $fileName = "$orgDir/$genomeB";
634        # Return the results.
635        return ($fileName, $genomeA, $genomeB);
636    }
637    
638    
639    =head3 ComputeCorresopndenceDirectory
640    
641        my $dirName = ServerThing::ComputeCorrespondenceDirectory($genome);
642    
643    Return the name of the directory that would contain the correspondence files
644    for the specified genome.
645    
646    =over 4
647    
648    =item genome
649    
650    ID of the genome whose correspondence file directory is desired.
651    
652    =item RETURN
653    
654    Returns the name of the directory of interest.
655    
656    =back
657    
658    =cut
659    
660    sub ComputeCorrespondenceDirectory {
661        # Get the parameters.
662        my ($genome) = @_;
663        # Insure the source organism has a subdirectory in the organism cache.
664        my $retVal = "$FIG_Config::orgCache/$genome";
665        Tracer::Insure($retVal, 0777);
666        # Return it.
667      return $retVal;      return $retVal;
668  }  }
669    
670    
671    =head3 CreateGeneCorrespondenceFile
672    
673        my ($fileName, $converse) = ServerThing::CheckForGeneCorrespondenceFile($genome1, $genome2);
674    
675    Create a new gene correspondence file in the organism cache for the specified
676    genome correspondence. The name of the new file will be returned along with
677    an indicator of whether or not it is in the correct direction.
678    
679    =over 4
680    
681    =item genome1
682    
683    Source genome for the desired correspondence.
684    
685    =item genome2
686    
687    Target genome for the desired correspondence.
688    
689    =item RETURN
690    
691    Returns a two-element list. The first element is the name of the file containing the
692    correspondence, or C<undef> if an error occurred. The second element is TRUE
693    if the correspondence would be forward or FALSE if the file needs to be flipped.
694    
695    =back
696    
697    =cut
698    
699    sub CreateGeneCorrespondenceFile {
700        # Get the parameters.
701        my ($genome1, $genome2) = @_;
702        # Declare the return variables.
703        my ($fileName, $converse);
704        # Compute the ultimate name for the correspondence file.
705        my ($corrFileName, $genomeA, $genomeB) = ComputeCorrespondenceFileName($genome1, $genome2);
706        $converse = ($genome1 ne $genomeA);
707        # Generate a temporary file name in the same directory. We'll build the temporary
708        # file and then rename it when we're done.
709        my $tempFileName = "$corrFileName.$$.tmp";
710        # This will be set to FALSE if we detect an error.
711        my $fileOK = 1;
712        # The file handles will be put in here.
713        my ($ih, $oh);
714        # Protect from errors.
715        eval {
716            # Open the temporary file for output.
717            $oh = Open(undef, ">$tempFileName");
718            # Open a pipe to get the correspondence data.
719            $ih = Open(undef, "$FIG_Config::bin/svr_corresponding_genes -u localhost $genomeA $genomeB |");
720            Trace("Creating correspondence file for $genomeA to $genomeB in temporary file $tempFileName.") if T(3);
721            # Copy the pipe date into the temporary file.
722            while (! eof $ih) {
723                my $line = <$ih>;
724                print $oh $line;
725            }
726            # Close both files. If the close fails we need to know: it means there was a pipe
727            # error.
728            $fileOK &&= close $ih;
729            $fileOK &&= close $oh;
730        };
731        if ($@) {
732            # Here a fatal error of some sort occurred. We need to force the files closed.
733            close $ih if $ih;
734            close $oh if $oh;
735        } elsif ($fileOK) {
736            # Here everything worked. Try to rename the temporary file to the real
737            # file name.
738            if (rename $tempFileName, $corrFileName) {
739                # Everything is ok, fix the permissions and return the file name.
740                chmod 0664, $corrFileName;
741                $fileName = $corrFileName;
742                Trace("Created correspondence file $fileName.") if T(Corr => 3);
743            }
744        }
745        # If the temporary file exists, delete it.
746        if (-f $tempFileName) {
747            unlink $tempFileName;
748        }
749        # Return the results.
750        return ($fileName, $converse);
751    }
752    
753    
754    =head3 MustFlipGenomeIDs
755    
756        my $converse = ServerThing::MustFlipGenomeIDs($genome1, $genome2);
757    
758    Return TRUE if the specified genome IDs are out of order. When genome IDs are out of
759    order, they are stored in the converse order in correspondence files on the server.
760    This is a simple method that allows the caller to check for the need to flip.
761    
762    =over 4
763    
764    =item genome1
765    
766    ID of the proposed source genome.
767    
768    =item genome2
769    
770    ID of the proposed target genome.
771    
772    =item RETURN
773    
774    Returns TRUE if the first genome would be stored on the server as a target, FALSE if
775    it would be stored as a source.
776    
777    =back
778    
779    =cut
780    
781    sub MustFlipGenomeIDs {
782        # Get the parameters.
783        my ($genome1, $genome2) = @_;
784        # Return an indication.
785        return ($genome1 gt $genome2);
786    }
787    
788    
789    =head3 ReadGeneCorrespondenceFile
790    
791        my $list = ServerThing::ReadGeneCorrespondenceFile($fileName, $converse, $all);
792    
793    Return the contents of the specified gene correspondence file in the form of
794    a list of lists, with backward correspondences filtered out. If the file is
795    for the converse of the desired correspondence, the columns will be reordered
796    automatically so that it looks as if the file were designed for the proper
797    direction.
798    
799    =over 4
800    
801    =item fileName
802    
803    The name of the gene correspondence file to read.
804    
805    =item converse (optional)
806    
807    TRUE if the file is for the converse of the desired correspondence, else FALSE.
808    If TRUE, the file columns will be reorderd automatically. The default is FALSE,
809    meaning we want to use the file as it appears on disk.
810    
811    =item all (optional)
812    
813    TRUE if backward unidirectional correspondences should be included in the output.
814    The default is FALSE, in which case only forward and bidirectional correspondences
815    are included.
816    
817    =item RETURN
818    
819    Returns a L</Gene Correspondence List> in the form of a reference to a list of lists.
820    If the file's contents are invalid or an error occurs, an undefined value will be
821    returned.
822    
823    =back
824    
825    =cut
826    
827    sub ReadGeneCorrespondenceFile {
828        # Get the parameters.
829        my ($fileName, $converse, $all) = @_;
830        # Declare the return variable. We will only put something in here if we are
831        # completely successful.
832        my $retVal;
833        # This value will be set to 1 if an error is detected.
834        my $error = 0;
835        # Try to open the file.
836        my $ih;
837        Trace("Reading correspondence file $fileName.") if T(3);
838        if (! open $ih, "<$fileName") {
839            # Here the open failed, so we have an error.
840            Trace("Failed to open gene correspondence file $fileName: $!") if T(Corr => 1);
841            $error = 1;
842        }
843        # The gene correspondence list will be built in here.
844        my @corrList;
845        # This variable will be set to TRUE if we find a reverse correspondence somewhere
846        # in the file. Not finding one is an error.
847        my $reverseFound = 0;
848        # Loop until we hit the end of the file or an error occurs. We must check the error
849        # first in case the file handle failed to open.
850        while (! $error && ! eof $ih) {
851            # Get the current line.
852            my @row = Tracer::GetLine($ih);
853            # Get the correspondence direction and check for a reverse arrow.
854            $reverseFound = 1 if ($row[8] eq '<-');
855            # If we're in converse mode, reformat the line.
856            if ($converse) {
857                ReverseGeneCorrespondenceRow(\@row);
858            }
859            # Validate the row.
860            if (ValidateGeneCorrespondenceRow(\@row)) {
861                Trace("Invalid row $. found in correspondence file $fileName.") if T(Corr => 1);
862                $error = 1;
863            }
864            # If this row is in the correct direction, keep it.
865            if ($all || $row[8] ne '<-') {
866                push @corrList, \@row;
867            }
868        }
869        # Close the input file.
870        close $ih;
871        # If we have no errors, keep the result.
872        if (! $error) {
873            $retVal = \@corrList;
874        }
875        # Return the result (if any).
876        return $retVal;
877    }
878    
879    =head3 ReverseGeneCorrespondenceRow
880    
881        ServerThing::ReverseGeneCorrespondenceRow($row)
882    
883    Convert a gene correspondence row to represent the converse correspondence. The
884    elements in the row will be reordered to represent a correspondence from the
885    target genome to the source genome.
886    
887    =over 4
888    
889    =item row
890    
891    Reference to a list containing a single row from a L</Gene Correspondence List>.
892    
893    =back
894    
895    =cut
896    
897    sub ReverseGeneCorrespondenceRow {
898        # Get the parameters.
899        my ($row) = @_;
900        # Flip the row in place.
901        ($row->[1], $row->[0], $row->[2], $row->[3], $row->[5], $row->[4], $row->[7],
902         $row->[6], $row->[8], $row->[9], $row->[10], $row->[14],
903         $row->[15], $row->[16], $row->[11], $row->[12], $row->[13], $row->[17]) = @$row;
904        # Flip the arrow.
905        $row->[8] = ARROW_FLIP->{$row->[8]};
906        # Flip the pairs.
907        my @elements = split /,/, $row->[3];
908        $row->[3] = join(",", map { join(":", reverse split /:/, $_) } @elements);
909    }
910    
911    =head3 ValidateGeneCorrespondenceRow
912    
913        my $errorCount = ServerThing::ValidateGeneCorrespondenceRow($row);
914    
915    Validate a gene correspondence row. The numeric fields are checked to insure they
916    are numeric and the source and target gene IDs are validated. The return value will
917    indicate the number of errors found.
918    
919    =over 4
920    
921    =item row
922    
923    Reference to a list containing a single row from a L</Gene Correspondence List>.
924    
925    =item RETURN
926    
927    Returns the number of errors found in the row. A return of C<0> indicates the row
928    is valid.
929    
930    =back
931    
932    =cut
933    
934    sub ValidateGeneCorrespondenceRow {
935        # Get the parameters.
936        my ($row, $genome1, $genome2) = @_;
937        # Denote no errors have been found so far.
938        my $retVal = 0;
939        # Check for non-numeric values in the number columns.
940        for my $col (@{NUM_COLS()}) {
941            unless ($row->[$col] =~ /^-?\d+\.?\d*(?:e[+-]?\d+)?$/) {
942                Trace("Gene correspondence error. \"$row->[$col]\" not numeric.") if T(Corr => 2);
943                $retVal++;
944            }
945        }
946        # Check the gene IDs.
947        for my $col (0, 1) {
948            unless ($row->[$col] =~ /^fig\|\d+\.\d+\.\w+\.\d+$/) {
949                Trace("Gene correspondence error. \"$row->[$col]\" not a gene ID.") if T(Corr => 2);
950                $retVal++;
951            }
952        }
953        # Verify the arrow.
954        unless (exists ARROW_FLIP->{$row->[8]}) {
955            Trace("Gene correspondence error. \"$row->[8]\" not an arrow.") if T(Corr => 2);
956            $retVal++;
957        }
958        # Return the error count.
959        return $retVal;
960    }
961    
962    =head3 GetCorrespondenceData
963    
964        my $corrList = ServerThing::GetCorrespondenceData($genome1, $genome2, $passive, $full);
965    
966    Return the L</Gene Correspondence List> for the specified source and target genomes. If the
967    list is in a file, it will be read. If the file does not exist, it may be created.
968    
969    =over 4
970    
971    =item genome1
972    
973    ID of the source genome.
974    
975    =item genome2
976    
977    ID of the target genome.
978    
979    =item passive
980    
981    If TRUE, then the correspondence file will not be created if it does not exist.
982    
983    =item full
984    
985    If TRUE, then both directions of the correspondence will be represented; otherwise, only
986    correspondences from the source to the target (including bidirectional corresopndences)
987    will be included.
988    
989    =item RETURN
990    
991    Returns a L</Gene Correspondence List> in the form of a reference to a list of lists, or an
992    undefined value if an error occurs or no file exists and passive mode was specified.
993    
994    =back
995    
996    =cut
997    
998    sub GetCorrespondenceData {
999        # Get the parameters.
1000        my ($genome1, $genome2, $passive, $full) = @_;
1001        # Declare the return variable.
1002        my $retVal;
1003        # Check for a gene correspondence file.
1004        my ($fileName, $converse) = ServerThing::CheckForGeneCorrespondenceFile($genome1, $genome2);
1005        if ($fileName) {
1006            # Here we found one, so read it in.
1007            $retVal = ServerThing::ReadGeneCorrespondenceFile($fileName, $converse, $full);
1008        }
1009        # Were we successful?
1010        if (! defined $retVal) {
1011            # Here we either don't have a correspondence file, or the one that's there is
1012            # invalid. If we are NOT in passive mode, then this means we need to create
1013            # the file.
1014            if (! $passive) {
1015                ($fileName, $converse) = ServerThing::CreateGeneCorrespondenceFile($genome1, $genome2);
1016                # Now try reading the new file.
1017                if (defined $fileName) {
1018                    $retVal = ServerThing::ReadGeneCorrespondenceFile($fileName, $converse);
1019                }
1020            }
1021        }
1022        # Return the result.
1023        return $retVal;
1024    
1025    }
1026    
1027    
1028    =head2 Internal Utility Methods
1029    
1030    The methods in this section are used internally by this package.
1031    
1032    =head3 RunRequest
1033    
1034        ServerThing::RunRequest($cgi, $serverName);
1035    
1036    Run a request from the specified server using the incoming CGI parameter
1037    object for the parameters.
1038    
1039    =over 4
1040    
1041    =item cgi
1042    
1043    CGI query object containing the parameters from the web service request. The
1044    significant parameters are as follows.
1045    
1046    =over 8
1047    
1048    =item function
1049    
1050    Name of the function to run.
1051    
1052    =item args
1053    
1054    Parameters for the function.
1055    
1056    =item encoding
1057    
1058    Encoding scheme for the function parameters, either C<yaml> (the default) or C<json> (used
1059    by the Java interface).
1060    
1061    =back
1062    
1063    Certain unusual requests can come in outside of the standard function interface.
1064    These are indicated by special parameters that override all the others.
1065    
1066    =over 8
1067    
1068    =item pod
1069    
1070    Display a POD documentation module.
1071    
1072    =item code
1073    
1074    Display an example code file.
1075    
1076    =item file
1077    
1078    Transfer a file (not implemented).
1079    
1080    =back
1081    
1082    =item serverThing
1083    
1084    Server object against which to run the request.
1085    
1086    =back
1087    
1088    =cut
1089    
1090    sub RunRequest {
1091        # Get the parameters.
1092        my ($cgi, $serverThing, $docURL) = @_;
1093        # Determine the request type.
1094        my $module = $cgi->param('pod');
1095        if ($module) {
1096            # Here we have a documentation request.
1097            if ($module eq 'ServerScripts') {
1098                # Here we list the server scripts.
1099                require ListServerScripts;
1100                ListServerScripts::main();
1101            } else {
1102                # In this case, we produce POD HTML.
1103                ProducePod($cgi->param('pod'));
1104            }
1105        } elsif ($cgi->param('code')) {
1106            # Here the user wants to see the code for one of our scripts.
1107            LineNumberize($cgi->param('code'));
1108        } elsif ($cgi->param('file')) {
1109            # Here we have a file request. Process according to the type.
1110            my $type = $cgi->param('file');
1111            if ($type eq 'open') {
1112                OpenFile($cgi->param('name'));
1113            } elsif ($type eq 'create') {
1114                CreateFile();
1115            } elsif ($type eq 'read') {
1116                ReadChunk($cgi->param('name'), $cgi->param('location'), $cgi->param('size'));
1117            } elsif ($type eq 'write') {
1118                WriteChunk($cgi->param('name'), $cgi->param('data'));
1119            } else {
1120                Die("Invalid file function \"$type\".");
1121            }
1122        } else {
1123            # The default is a function request. Get the function name.
1124            my $function = $cgi->param('function') || "";
1125            Trace("Server function for task $$ is $function.") if T(3);
1126            # Insure the function name is valid.
1127            Die("Invalid function name.")
1128                if $function =~ /\W/;
1129            # Determing the encoding scheme. The default is YAML.
1130            my $encoding = $cgi->param('encoding') || 'yaml';
1131            # Optional callback for json encoded documents
1132            my $callback = $cgi->param('callback');
1133            # The parameter structure will go in here.
1134            my $args = {};
1135            # Start the timer.
1136            my $start = time();
1137            # The output document goes in here.
1138            my $document;
1139            # Protect from errors.
1140            eval {
1141                # Here we parse the arguments. This is affected by the encoding parameter.
1142                # Get the argument string.
1143                my $argString = $cgi->param('args');
1144                # Only proceed if we found one.
1145                if ($argString) {
1146                    if ($encoding eq 'yaml') {
1147                        # Parse the arguments using YAML.
1148                        $args = YAML::Load($argString);
1149                    } elsif ($encoding eq 'json') {
1150                        # Parse the arguments using JSON.
1151                        Trace("Incoming string is:\n$argString") if T(3);
1152                        $args = JSON::Any->jsonToObj($argString);
1153                    } else {
1154                        Die("Invalid encoding type $encoding.");
1155                    }
1156                }
1157            };
1158            # Check to make sure we got everything.
1159            if ($@) {
1160                SendError($@, "Error formatting parameters.");
1161            } elsif (! $function) {
1162                SendError("No function specified.", "No function specified.");
1163            } else {
1164                # Insure we're connected to the correct database.
1165                my $dbName = $cgi->param('dbName');
1166                if ($dbName && exists $serverThing->{db}) {
1167                    ChangeDB($serverThing, $dbName);
1168                }
1169                # Run the request.
1170                $document = eval { $serverThing->$function($args) };
1171                # If we have an error, create an error document.
1172                if ($@) {
1173                    SendError($@, "Error detected by service.");
1174                    Trace("Error encountered by service: $@") if T(0);
1175                } else {
1176                    # No error, so we output the result. Start with an HTML header.
1177                    if ($encoding eq 'yaml') {
1178                        print $cgi->header(-type => 'text/plain');
1179                    } else {
1180                        print $cgi->header(-type => 'text/javascript');
1181                    }
1182                    # The nature of the output depends on the encoding type.
1183                    my $string;
1184                    if ($encoding eq 'yaml') {
1185                        $string = YAML::Dump($document);
1186                    } elsif(defined($callback)) {
1187                        $string = $callback . "(".JSON::Any->objToJson($document).")";
1188                    } else {
1189                        $string = JSON::Any->objToJson($document);
1190                    }
1191                    print $string;
1192                    MemTrace(length($string) . " bytes returned from $function by task $$.") if T(Memory => 3);
1193                }
1194            }
1195            # Stop the timer.
1196            my $duration = int(time() - $start + 0.5);
1197            Trace("Function $function executed in $duration seconds by task $$.") if T(2);
1198        }
1199    }
1200    
1201    =head3 CreateFile
1202    
1203        ServerThing::CreateFile();
1204    
1205    Create a new, empty temporary file and send its name back to the client.
1206    
1207    =cut
1208    
1209    sub CreateFile {
1210        ##TODO: Code
1211    }
1212    
1213    =head3 OpenFile
1214    
1215        ServerThing::OpenFile($name);
1216    
1217    Send the length of the named file back to the client.
1218    
1219    =over 4
1220    
1221    =item name
1222    
1223    ##TODO: name description
1224    
1225    =back
1226    
1227    =cut
1228    
1229    sub OpenFile {
1230        # Get the parameters.
1231        my ($name) = @_;
1232        ##TODO: Code
1233    }
1234    
1235    =head3 ReadChunk
1236    
1237        ServerThing::ReadChunk($name, $location, $size);
1238    
1239    Read the indicated number of bytes from the specified location of the
1240    named file and send them back to the client.
1241    
1242    =over 4
1243    
1244    =item name
1245    
1246    ##TODO: name description
1247    
1248    =item location
1249    
1250    ##TODO: location description
1251    
1252    =item size
1253    
1254    ##TODO: size description
1255    
1256    =back
1257    
1258    =cut
1259    
1260    sub ReadChunk {
1261        # Get the parameters.
1262        my ($name, $location, $size) = @_;
1263        ##TODO: Code
1264    }
1265    
1266    =head3 WriteChunk
1267    
1268        ServerThing::WriteChunk($name, $data);
1269    
1270    Write the specified data to the named file.
1271    
1272    =over 4
1273    
1274    =item name
1275    
1276    ##TODO: name description
1277    
1278    =item data
1279    
1280    ##TODO: data description
1281    
1282    =back
1283    
1284    =cut
1285    
1286    sub WriteChunk {
1287        # Get the parameters.
1288        my ($name, $data) = @_;
1289        ##TODO: Code
1290    }
1291    
1292    
1293    =head3 LineNumberize
1294    
1295        ServerThing::LineNumberize($module);
1296    
1297    Output the module line by line with line numbers
1298    
1299    =over 4
1300    
1301    =item module
1302    
1303    Name of the module to line numberized
1304    
1305    =back
1306    
1307    =cut
1308    
1309    sub LineNumberize {
1310        # Get the parameters.
1311        my ($module) = @_;
1312        my $fks_path = "$FIG_Config::fig_disk/dist/releases/current/FigKernelScripts/$module";
1313        # Start the output page.
1314        print CGI::header();
1315        print CGI::start_html(-title => 'Documentation Page',
1316                              -style => { src => "http://servers.nmpdr.org/sapling/Html/css/ERDB.css" });
1317        # Protect from errors.
1318        eval {
1319            if (-e $fks_path) {
1320                print "<pre>\n";
1321                my $i = 1;
1322                foreach my $line (`cat $fks_path`) {
1323                    print "$i.\t$line";
1324                    $i++;
1325                }
1326                print "</pre>\n";
1327            } else {
1328                print "File $fks_path not found";
1329            }
1330        };
1331        # Process any error.
1332        if ($@) {
1333            print CGI::blockquote({ class => 'error' }, $@);
1334        }
1335        # Close off the page.
1336        print CGI::end_html();
1337    
1338    }
1339    
1340    =head3 ProducePod
1341    
1342        ServerThing::ProducePod($module);
1343    
1344    Output the POD documentation for the specified module.
1345    
1346    =over 4
1347    
1348    =item module
1349    
1350    Name of the module whose POD document is to be displayed.
1351    
1352    =back
1353    
1354    =cut
1355    
1356    sub ProducePod {
1357        # Get the parameters.
1358        my ($module) = @_;
1359        # Start the output page.
1360        print CGI::header();
1361        print CGI::start_html(-title => "$module Documentation Page",
1362                              -style => { src => "http://servers.nmpdr.org/sapling/Html/css/ERDB.css" });
1363        # Protect from errors.
1364        eval {
1365            # We'll format the HTML text in here.
1366            require DocUtils;
1367            my $html = DocUtils::ShowPod($module, "http://servers.nmpdr.org/sapling/server.cgi?pod=");
1368            # Output the POD HTML.
1369            print $html;
1370        };
1371        # Process any error.
1372        if ($@) {
1373            print CGI::blockquote({ class => 'error' }, $@);
1374        }
1375        # Close off the page.
1376        print CGI::end_html();
1377    
1378    }
1379    
1380    =head3 TraceErrorLog
1381    
1382        ServerThing::TraceErrorLog($name, $errorLog);
1383    
1384    Trace the specified error log file. This is a very dinky routine that
1385    performs a task required by L</RunTool> in multiple places.
1386    
1387    =over 4
1388    
1389    =item name
1390    
1391    Name of the tool relevant to the log file.
1392    
1393    =item errorLog
1394    
1395    Name of the log file.
1396    
1397    =back
1398    
1399    =cut
1400    
1401    sub TraceErrorLog {
1402        my ($name, $errorLog) = @_;
1403        my $errorData = Tracer::GetFile($errorLog);
1404        Trace("$name error log:\n$errorData");
1405    }
1406    
1407    =head3 SendError
1408    
1409        ServerThing::SendError($message, $status);
1410    
1411    Fail an HTTP request with the specified error message and the specified
1412    status message.
1413    
1414    =over 4
1415    
1416    =item message
1417    
1418    Detailed error message. This is sent as the page content.
1419    
1420    =item status
1421    
1422    Status message. This is sent as part of the status code.
1423    
1424    =back
1425    
1426    =cut
1427    
1428    sub SendError {
1429        # Get the parameters.
1430        my ($message, $status) = @_;
1431        Trace("Error \"$status\" $message") if T(2);
1432        # Check for a DBserver error. These can be retried and get a special status
1433        # code.
1434        my $realStatus;
1435        if ($message =~ /DBServer Error:\s+/) {
1436            $realStatus = "503 $status";
1437        } else {
1438            $realStatus = "500 $status";
1439        }
1440        # Print the header and the status message.
1441        print CGI::header(-type => 'text/plain',
1442                          -status => $realStatus);
1443        # Print the detailed message.
1444        print $message;
1445    }
1446    
1447    
1448  1;  1;

Legend:
Removed from v.1.3  
changed lines
  Added in v.1.61

MCS Webmaster
ViewVC Help
Powered by ViewVC 1.0.3