[Bio] / Sprout / CustomAttributes.pm Repository:
ViewVC logotype

Diff of /Sprout/CustomAttributes.pm

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

revision 1.10, Tue Nov 28 01:00:08 2006 UTC revision 1.19, Fri Feb 9 22:59:18 2007 UTC
# Line 8  Line 8 
8      use strict;      use strict;
9      use Tracer;      use Tracer;
10      use ERDBLoad;      use ERDBLoad;
11        use Stats;
12    
13  =head1 Custom SEED Attribute Manager  =head1 Custom SEED Attribute Manager
14    
# Line 22  Line 23 
23  however, to the attribute database only the ID matters. This will create  however, to the attribute database only the ID matters. This will create
24  a problem if we have a single ID that applies to two objects of different  a problem if we have a single ID that applies to two objects of different
25  types, but it is more consistent with the original attribute implementation  types, but it is more consistent with the original attribute implementation
26  in the SEED (which this implementation replaces.  in the SEED (which this implementation replaces).
27    
28  An I<assignment> relates a specific attribute key to a specific object.  The actual attribute values are stored as a relationship between the attribute
29  Each assignment contains one or more values.  keys and the objects. There can be multiple values for a single key/object pair.
30    
31    =head3 Object IDs
32    
33    The object ID is normally represented as
34    
35        I<type>:I<id>
36    
37    where I<type> is the object type (C<Role>, C<Coupling>, etc.) and I<id> is
38    the actual object ID. Note that the object type must consist of only upper- and
39    lower-case letters! Thus, C<GenomeGroup> is a valid object type, but
40    C<genome_group> is not. Given that restriction, the object ID
41    
42        Family:aclame|cluster10
43    
44    would represent the FIG family C<aclame|cluster10>. For historical reasons,
45    there are three exceptions: subsystems, genomes, and features do not need
46    a type. So, for PEG 3361 of Streptomyces coelicolor A3(2), you simply code
47    
48        fig|100226.1.peg.3361
49    
50    The methods L</ParseID> and L</FormID> can be used to make this all seem
51    more consistent. Given any object ID string, L</ParseID> will convert it to an
52    object type and ID, and given any object type and ID, L</FormID> will
53    convert it to an object ID string. The attribute database is pretty
54    freewheeling about what it will allow for an ID; however, for best
55    results, the type should match an entity type from a Sprout genetics
56    database. If this rule is followed, then the database object
57    corresponding to an ID in the attribute database could be retrieved using
58    L</GetTargetObject> method.
59    
60        my $object = CustomAttributes::GetTargetObject($sprout, $idValue);
61    
62    =head3 Retrieval and Logging
63    
64  The full suite of ERDB retrieval capabilities is provided. In addition,  The full suite of ERDB retrieval capabilities is provided. In addition,
65  custom methods are provided specific to this application. To get all  custom methods are provided specific to this application. To get all
# Line 39  Line 73 
73  New attribute keys must be defined before they can be used. A web interface  New attribute keys must be defined before they can be used. A web interface
74  is provided for this purpose.  is provided for this purpose.
75    
76    Major attribute activity is recorded in a log (C<attributes.log>) in the
77    C<$FIG_Config::var> directory. The log reports the user name, time, and
78    the details of the operation. The user name will almost always be unknown,
79    except when it is specified in this object's constructor (see L</new>).
80    
81  =head2 FIG_Config Parameters  =head2 FIG_Config Parameters
82    
83  The following configuration parameters are used to manage custom attributes.  The following configuration parameters are used to manage custom attributes.
# Line 87  Line 126 
126    
127  =head3 new  =head3 new
128    
129  C<< my $attrDB = CustomAttributes->new($splitter); >>  C<< my $attrDB = CustomAttributes->new(%options); >>
130    
131  Construct a new CustomAttributes object.  Construct a new CustomAttributes object. The following options are
132    supported.
133    
134  =over 4  =over 4
135    
136  =item splitter  =item splitter
137    
138  Value to be used to split attribute values into sections in the  Value to be used to split attribute values into sections in the
139  L</Fig Replacement Methods>. The default is a double colon C<::>.  L</Fig Replacement Methods>. The default is a double colon C<::>,
140  If you do not use the replacement methods, you do not need to  and should only be overridden in extreme circumstances.
141  worry about this parameter.  
142    =item user
143    
144    Name of the current user. This will appear in the attribute log.
145    
146  =back  =back
147    
# Line 106  Line 149 
149    
150  sub new {  sub new {
151      # Get the parameters.      # Get the parameters.
152      my ($class, $splitter) = @_;      my ($class, %options) = @_;
153      # Connect to the database.      # Connect to the database.
154      my $dbh = DBKernel->new($FIG_Config::attrDbms, $FIG_Config::attrDbName,      my $dbh = DBKernel->new($FIG_Config::attrDbms, $FIG_Config::attrDbName,
155                              $FIG_Config::attrUser, $FIG_Config::attrPass,                              $FIG_Config::attrUser, $FIG_Config::attrPass,
# Line 116  Line 159 
159      my $xmlFileName = $FIG_Config::attrDBD;      my $xmlFileName = $FIG_Config::attrDBD;
160      my $retVal = ERDB::new($class, $dbh, $xmlFileName);      my $retVal = ERDB::new($class, $dbh, $xmlFileName);
161      # Store the splitter value.      # Store the splitter value.
162      $retVal->{splitter} = (defined($splitter) ? $splitter : '::');      $retVal->{splitter} = $options{splitter} || '::';
163      # Return the result.      # Store the user name.
164      return $retVal;      $retVal->{user} = $options{user} || '<unknown>';
165  }      Trace("User $retVal->{user} selected for attribute object.") if T(3);
   
 =head3 AssignmentKey  
   
 C<< my $hashedValue = $attrDB->AssignmentKey($id, $keyName); >>  
   
 Return the hashed key used in the assignment table for the specified object ID and  
 key name.  
   
 =over 4  
   
 =item id  
   
 ID of the object relevant to the assignment.  
   
 =item keyName  
   
 Name of the key being assigned values.  
   
 =item RETURN  
   
 Returns the ID that would be used for an B<Assignment> instance representing this  
 key/id pair.  
   
 =back  
   
 =cut  
   
 sub AssignmentKey {  
     # Get the parameters.  
     my ($self, $id, $keyName) = @_;  
     # Compute the result.  
     my $retVal = $self->DigestKey("$keyName=$id");  
     # Return the result.  
     return $retVal;  
 }  
   
 =head3 GetAssignment  
   
 C<< my $assign = $attrDB->GetAssignment($id, $keyName); >>  
   
 Check for an assignment between the specified attribute key and the specified object ID.  
 If an assignment exists, a B<DBObject> for it will be returned. If it does not exist, an  
 undefined value will be returned.  
   
 =over 4  
   
 =item id  
   
 ID of the object relevant to the assignment.  
   
 =item keyName  
   
 Attribute key name for the attribute to which the assignment is to be made.  
   
 =item RETURN  
   
 Returns a B<DBObject> for the indicated assignment, or C<undef> if the assignment  
 does not exist.  
   
 =back  
   
 =cut  
   
 sub GetAssignment {  
     # Get the parameters.  
     my ($self, $id, $keyName) = @_;  
     # Compute the assignment key.  
     my $hashKey = $self->AssignmentKey($id, $keyName);  
     # Check for an assignment.  
     my $retVal = $self->GetEntity('Assignment', $hashKey);  
166      # Return the result.      # Return the result.
167      return $retVal;      return $retVal;
168  }  }
# Line 240  Line 213 
213      } elsif (! exists $types{$type}) {      } elsif (! exists $types{$type}) {
214          Confess("Invalid data type \"$type\" for $attributeName.");          Confess("Invalid data type \"$type\" for $attributeName.");
215      } else {      } else {
216            # Create a variable to hold the action to be displayed for the log (Add or Update).
217            my $action;
218          # Okay, we're ready to begin. See if this key exists.          # Okay, we're ready to begin. See if this key exists.
219          my $attribute = $self->GetEntity('AttributeKey', $attributeName);          my $attribute = $self->GetEntity('AttributeKey', $attributeName);
220          if (defined($attribute)) {          if (defined($attribute)) {
221              # It does, so we do an update.              # It does, so we do an update.
222                $action = "Update Key";
223              $self->UpdateEntity('AttributeKey', $attributeName,              $self->UpdateEntity('AttributeKey', $attributeName,
224                                  { description => $notes, 'data-type' => $type });                                  { description => $notes, 'data-type' => $type });
225              # Detach the key from its current groups.              # Detach the key from its current groups.
226              $self->Disconnect('IsInGroup', 'AttributeKey', $attributeName);              $self->Disconnect('IsInGroup', 'AttributeKey', $attributeName);
227          } else {          } else {
228              # It doesn't, so we do an insert.              # It doesn't, so we do an insert.
229                $action = "Insert Key";
230              $self->InsertObject('AttributeKey', { id => $attributeName,              $self->InsertObject('AttributeKey', { id => $attributeName,
231                                  description => $notes, 'data-type' => $type });                                  description => $notes, 'data-type' => $type });
232          }          }
# Line 259  Line 236 
236              $self->InsertObject('IsInGroup', { 'from-link' => $attributeName,              $self->InsertObject('IsInGroup', { 'from-link' => $attributeName,
237                                                 'to-link'   => $group });                                                 'to-link'   => $group });
238          }          }
239            # Log the operation.
240            $self->LogOperation($action, $attributeName, "Group list is " . join(" ", @{$groups}));
241      }      }
242  }  }
243    
# Line 270  Line 249 
249  tab-delimited file with internal tab and new-line characters escaped. This is  tab-delimited file with internal tab and new-line characters escaped. This is
250  the typical TBL-style file used by most FIG applications. One of the columns  the typical TBL-style file used by most FIG applications. One of the columns
251  in the input file must contain the appropriate object id value and the other the  in the input file must contain the appropriate object id value and the other the
252  corresponding attribute value.  corresponding attribute value. The current contents of the attribute database will
253    be erased before loading, unless the options are used to override that behavior.
254    
255  =over 4  =over 4
256    
# Line 305  Line 285 
285    
286  =over 4  =over 4
287    
288  =item erase  =item keep
289    
290    If specified, the existing attribute values will not be erased.
291    
292  If TRUE, the key's values will all be erased before loading. (Doing so  =item archive
293  makes for a faster load.)  
294    If specified, the name of a file into which the incoming file should be saved.
295    
296  =back  =back
297    
# Line 318  Line 301 
301      # Get the parameters.      # Get the parameters.
302      my ($self, $keyName, $fh, $idCol, $dataCol, %options) = @_;      my ($self, $keyName, $fh, $idCol, $dataCol, %options) = @_;
303      # Create the return variable.      # Create the return variable.
304      my $retVal = Stats->new("lineIn", "shortLine", "lineUsed");      my $retVal = Stats->new("lineIn", "shortLine");
305      # Compute the minimum number of fields required in each input line.      # Compute the minimum number of fields required in each input line. The user specifies two
306      my $minCols = ($idCol < $dataCol ? $idCol : $idCol) + 1;      # columns, and we need to make sure both columns are in every record.
307        my $minCols = ($idCol < $dataCol ? $dataCol : $idCol) + 1;
308        Trace("Minimum column count is $minCols.") if T(3);
309        #
310      # Insure the attribute key exists.      # Insure the attribute key exists.
311      my $found = $self->GetEntity('AttributeKey', $keyName);      my $found = $self->GetEntity('AttributeKey', $keyName);
312      if (! defined $found) {      if (! defined $found) {
313          Confess("Attribute key \"$keyName\" not found in database.");          Confess("Attribute key \"$keyName\" not found in database.");
314      } else {      } else {
315          # We need three load files: one for "IsKeyOf", one for "Assignment", and          # Erase the key's current values (unless, of course, the caller specified the "keep" option.
316          # one for "AssignmentValue".          if (! $options{keep}) {
317          my $isKeyOfFileName = "$FIG_Config::temp/IsKeyOf$$.dtx";              $self->EraseAttribute($keyName);
318          my $isKeyOfH = Open(undef, ">$isKeyOfFileName");          }
319          my $assignmentFileName = "$FIG_Config::temp/Assignment.dtx";          # Check for a save file. In the main loop, we'll know a save file is needed if $sh is
320          my $assignmentH = Open(undef, ">$assignmentFileName");          # defined.
321          my $assignmentValueFileName = "$FIG_Config::temp/Assignment.dtx";          my $sh;
322          my $assignmentValueH = Open(undef, ">$assignmentValueFileName");          if ($options{archive}) {
323          # We also need a hash to track the assignments we find.              $sh = Open(undef, ">$options{archive}");
324          my %assignHash = ();              Trace("Attribute $keyName upload saved in $options{archive}.") if T(2);
325          # Find out if we intend to erase the key before loading.          }
326          my $erasing = $options{erase} || 0;          # Save a list of the object IDs we need to add.
327            my %objectIDs = ();
328          # Loop through the input file.          # Loop through the input file.
329          while (! eof $fh) {          while (! eof $fh) {
330              # Get the next line of the file.              # Get the next line of the file.
331              my @fields = Tracer::GetLine($fh);              my @fields = Tracer::GetLine($fh);
332              $retVal->Add(lineIn => 1);              $retVal->Add(lineIn => 1);
333              # Now we need to validate the line.              my $count = scalar @fields;
334              if (scalar(@fields) < $minCols) {              Trace("Field count is $count. First field is \"$fields[0]\".") if T(4);
335                # Archive it if necessary.
336                if (defined $sh) {
337                    Tracer::PutLine($sh, \@fields);
338                }
339                # Now we need to check for comments and validate the line.
340                if ($fields[0] =~ /^\s*$/) {
341                    # Blank line: skip it.
342                    $retVal->Add(blank => 1);
343                } elsif (substr($fields[0],0,1) eq '#') {
344                    # Comment line: skip it.
345                    $retVal->Add(comment => 1);
346                } elsif ($count < $minCols) {
347                    # Line is too short: we have an error.
348                  $retVal->Add(shortLine => 1);                  $retVal->Add(shortLine => 1);
349              } else {              } else {
350                  # It's valid, so get the ID and value.                  # It's valid, so get the ID and value.
351                  my ($id, $value) = ($fields[$idCol], $fields[$dataCol]);                  my ($id, $value) = ($fields[$idCol], $fields[$dataCol]);
352                  # Denote we're using this input line.                  # Denote we're using this input line.
353                  $retVal->Add(lineUsed => 1);                  $retVal->Add(lineUsed => 1);
354                  # Now the fun begins. Find out if we need an assignment for this object ID.                  # Now we insert the attribute.
355                  my $assignKey = "$keyName=$id";                  $self->InsertObject('HasValueFor', { 'from-link' => $keyName,
356                  my $assignValue = $assignHash{$assignKey};                                                       'to-link' => $id,
357                  if (! defined $assignValue) {                                                       value => $value });
358                      # Here we have a new assignment. If we are using an erased key,                  $retVal->Add(newValue => 1);
359                      # we will create an assignment object for it. Otherwise, we have              }
360                      # to check the database. First, we get the digested value.          }
361                      $assignValue = $self->AssignmentKey($id, $keyName);          # Log this operation.
362                      # If we're erasing, we always need to create an assignment, but if          $self->LogOperation("Load Key", $keyName, $retVal->Display());
363                      # we're not erasing we need to check the keys.          # If there's an archive, close it.
364                      if ($erasing || ! $self->Exists('Assignment', $assignValue)) {          if (defined $sh) {
365                          # Here we need to create the assignment.              close $sh;
                         Tracer::PutLine($assignmentH, [$assignValue, $id]);  
                         Tracer::PutLine($isKeyOfH, [$keyName, $assignValue]);  
                         # Save the assignment key in the hash.  
                         $assignHash{$assignKey} = $assignValue;  
                         # Update the counter.  
                         $retVal->Add(newAssignment => 1);  
                     }  
                 }  
                 # Now we have the assignment ID, so we can attach the new value to the  
                 # assignment.  
                 Tracer::PutLine($assignmentValueH, [$assignValue, $value]);  
             }  
         }  
         # Close all the files.  
         close $assignmentH;  
         close $assignmentValueH;  
         close $isKeyOfH;  
         # If we are erasing, erase the old key values.  
         if ($erasing) {  
             $self->EraseAttribute($keyName);  
366          }          }
         # If there are new assignments, load them.  
         if ($retVal->Ask("newAssignment") > 0) {  
             my $ikoStats = $self->LoadTable($isKeyOfFileName, "IsKeyOf", 0);  
             $retVal->Accumulate($ikoStats);  
             my $aStats = $self->LoadTable($assignmentFileName, "Assignment", 0);  
             $retVal->Accumulate($aStats);  
         }  
         # Finally, load the values.  
         my $avStats = $self->LoadTable($assignmentValueFileName, "AssignmentValue", 0);  
         $retVal->Accumulate($avStats);  
367      }      }
368      # Return the statistics.      # Return the statistics.
369      return $retVal;      return $retVal;
# Line 425  Line 395 
395      my ($self, $attributeName) = @_;      my ($self, $attributeName) = @_;
396      # Delete the attribute key.      # Delete the attribute key.
397      my $retVal = $self->Delete('AttributeKey', $attributeName);      my $retVal = $self->Delete('AttributeKey', $attributeName);
398        # Log this operation.
399        $self->LogOperation("Delete Key", $attributeName, "Key will no longer be available for use by anyone.");
400      # Return the result.      # Return the result.
401      return $retVal;      return $retVal;
402    
# Line 552  Line 524 
524      return join("\n", @retVal, "");      return join("\n", @retVal, "");
525  }  }
526    
527    =head3 LoadAttributesFrom
528    
529    C<< my $stats = $attrDB->LoadAttributesFrom($fileName, %options); >>
530    
531    Load attributes from the specified tab-delimited file. Each line of the file must
532    contain an object ID in the first column, an attribute key name in the second
533    column, and attribute values in the remaining columns. The attribute values will
534    be assembled into a single value using the splitter code.
535    
536    =over 4
537    
538    =item fileName
539    
540    Name of the file from which to load the attributes.
541    
542    =item options
543    
544    Hash of options for modifying the load process.
545    
546    =item RETURN
547    
548    Returns a statistics object describing the load.
549    
550    =back
551    
552    Permissible option values are as follows.
553    
554    =over 4
555    
556    =item append
557    
558    If TRUE, then the attributes will be appended to existing data; otherwise, the
559    first time a key name is encountered, it will be erased.
560    
561    =back
562    
563    =cut
564    
565    sub LoadAttributesFrom {
566        # Get the parameters.
567        my ($self, $fileName, %options) = @_;
568        # Declare the return variable.
569        my $retVal = Stats->new('keys', 'values');
570        # Check for append mode.
571        my $append = ($options{append} ? 1 : 0);
572        # Create a hash of key names found.
573        my %keyHash = ();
574        # Open the file for input.
575        my $fh = Open(undef, "<$fileName");
576        # Loop through the file.
577        while (! eof $fh) {
578            my ($id, $key, @values) = Tracer::GetLine($fh);
579            $retVal->Add(linesIn => 1);
580            # Do some validation.
581            if (! defined($id)) {
582                # We ignore blank lines.
583                $retVal->Add(blankLines => 1);
584            } elsif (! defined($key)) {
585                # An ID without a key is a serious error.
586                my $lines = $retVal->Ask('linesIn');
587                Confess("Line $lines in $fileName has no attribute key.");
588            } else {
589                # Now we need to check for a new key.
590                if (! exists $keyHash{$key}) {
591                    # This is a new key. Verify that it exists.
592                    if (! $self->Exists('AttributeKey', $key)) {
593                        my $line = $retVal->Ask('linesIn');
594                        Confess("Attribute \"$key\" on line $line of $fileName not found in database.");
595                    } else {
596                        # Make sure we know this is no longer a new key.
597                        $keyHash{$key} = 1;
598                        $retVal->Add(keys => 1);
599                        # If this is NOT append mode, erase the key.
600                        if (! $append) {
601                            $self->EraseAttribute($key);
602                        }
603                    }
604                    Trace("Key $key found.") if T(3);
605                }
606                # Now we know the key is valid. Add this value.
607                $self->AddAttribute($id, $key, @values);
608                my $progress = $retVal->Add(values => 1);
609                Trace("$progress values loaded.") if T(3) && ($progress % 1000 == 0);
610    
611            }
612        }
613        # Return the result.
614        return $retVal;
615    }
616    
617    =head3 BackupKeys
618    
619    C<< my $stats = $attrDB->BackupKeys($fileName, %options); >>
620    
621    Backup the attribute key information from the attribute database.
622    
623    =over 4
624    
625    =item fileName
626    
627    Name of the output file.
628    
629    =item options
630    
631    Options for modifying the backup process.
632    
633    =item RETURN
634    
635    Returns a statistics object for the backup.
636    
637    =back
638    
639    Currently there are no options. The backup is straight to a text file in
640    tab-delimited format. Each key is backup up to two lines. The first line
641    is all of the data from the B<AttributeKey> table. The second is a
642    tab-delimited list of all the groups.
643    
644    =cut
645    
646    sub BackupKeys {
647        # Get the parameters.
648        my ($self, $fileName, %options) = @_;
649        # Declare the return variable.
650        my $retVal = Stats->new();
651        # Open the output file.
652        my $fh = Open(undef, ">$fileName");
653        # Set up to read the keys.
654        my $keyQuery = $self->Get(['AttributeKey'], "", []);
655        # Loop through the keys.
656        while (my $keyData = $keyQuery->Fetch()) {
657            $retVal->Add(key => 1);
658            # Get the fields.
659            my ($id, $type, $description) = $keyData->Values(['AttributeKey(id)', 'AttributeKey(data-type)',
660                                                              'AttributeKey(description)']);
661            # Escape any tabs or new-lines in the description.
662            my $escapedDescription = Tracer::Escape($description);
663            # Write the key data to the output.
664            Tracer::PutLine($fh, [$id, $type, $escapedDescription]);
665            # Get the key's groups.
666            my @groups = $self->GetFlat(['IsInGroup'], "IsInGroup(from-link) = ?", [$id],
667                                        'IsInGroup(to-link)');
668            $retVal->Add(memberships => scalar(@groups));
669            # Write them to the output. Note we put a marker at the beginning to insure the line
670            # is nonempty.
671            Tracer::PutLine($fh, ['#GROUPS', @groups]);
672        }
673        # Log the operation.
674        $self->LogOperation("Backup Keys", $fileName, $retVal->Display());
675        # Return the result.
676        return $retVal;
677    }
678    
679    =head3 RestoreKeys
680    
681    C<< my $stats = $attrDB->RestoreKeys($fileName, %options); >>
682    
683    Restore the attribute keys and groups from a backup file.
684    
685    =over 4
686    
687    =item fileName
688    
689    Name of the file containing the backed-up keys. Each key has a pair of lines,
690    one containing the key data and one listing its groups.
691    
692    =back
693    
694    =cut
695    
696    sub RestoreKeys {
697        # Get the parameters.
698        my ($self, $fileName, %options) = @_;
699        # Declare the return variable.
700        my $retVal = Stats->new();
701        # Set up a hash to hold the group IDs.
702        my %groups = ();
703        # Open the file.
704        my $fh = Open(undef, "<$fileName");
705        # Loop until we're done.
706        while (! eof $fh) {
707            # Get a key record.
708            my ($id, $dataType, $description) = Tracer::GetLine($fh);
709            if ($id eq '#GROUPS') {
710                Confess("Group record found when key record expected.");
711            } elsif (! defined($description)) {
712                Confess("Invalid format found for key record.");
713            } else {
714                $retVal->Add("keyIn" => 1);
715                # Add this key to the database.
716                $self->InsertObject('AttributeKey', { id => $id, 'data-type' => $dataType,
717                                                      description => Tracer::UnEscape($description) });
718                Trace("Attribute $id stored.") if T(3);
719                # Get the group line.
720                my ($marker, @groups) = Tracer::GetLine($fh);
721                if (! defined($marker)) {
722                    Confess("End of file found where group record expected.");
723                } elsif ($marker ne '#GROUPS') {
724                    Confess("Group record not found after key record.");
725                } else {
726                    $retVal->Add(memberships => scalar(@groups));
727                    # Connect the groups.
728                    for my $group (@groups) {
729                        # Find out if this is a new group.
730                        if (! $groups{$group}) {
731                            $retVal->Add(newGroup => 1);
732                            # Add the group.
733                            $self->InsertObject('AttributeGroup', { id => $group });
734                            Trace("Group $group created.") if T(3);
735                            # Make sure we know it's not new.
736                            $groups{$group} = 1;
737                        }
738                        # Connect the group to our key.
739                        $self->InsertObject('IsInGroup', { 'from-link' => $id, 'to-link' => $group });
740                    }
741                    Trace("$id added to " . scalar(@groups) . " groups.") if T(3);
742                }
743            }
744        }
745        # Log the operation.
746        $self->LogOperation("Backup Keys", $fileName, $retVal->Display());
747        # Return the result.
748        return $retVal;
749    }
750    
751    
752    =head3 BackupAllAttributes
753    
754    C<< my $stats = $attrDB->BackupAllAttributes($fileName, %options); >>
755    
756    Backup all of the attributes to a file. The attributes will be stored in a
757    tab-delimited file suitable for reloading via L</LoadAttributesFrom>.
758    
759    =over 4
760    
761    =item fileName
762    
763    Name of the file to which the attribute data should be backed up.
764    
765    =item options
766    
767    Hash of options for the backup.
768    
769    =item RETURN
770    
771    Returns a statistics object describing the backup.
772    
773    =back
774    
775    Currently there are no options defined.
776    
777    =cut
778    
779    sub BackupAllAttributes {
780        # Get the parameters.
781        my ($self, $fileName, %options) = @_;
782        # Declare the return variable.
783        my $retVal = Stats->new();
784        # Get a list of the keys.
785        my @keys = $self->GetFlat(['AttributeKey'], "", [], 'AttributeKey(id)');
786        Trace(scalar(@keys) . " keys found during backup.") if T(2);
787        # Open the file for output.
788        my $fh = Open(undef, ">$fileName");
789        # Loop through the keys.
790        for my $key (@keys) {
791            Trace("Backing up attribute $key.") if T(3);
792            $retVal->Add(keys => 1);
793            # Loop through this key's values.
794            my $query = $self->Get(['HasValueFor'], "HasValueFor(from-link) = ?", [$key]);
795            my $valuesFound = 0;
796            while (my $line = $query->Fetch()) {
797                $valuesFound++;
798                # Get this row's data.
799                my @row = $line->Values(['HasValueFor(to-link)', 'HasValueFor(from-link)',
800                                         'HasValueFor(value)']);
801                # Write it to the file.
802                Tracer::PutLine($fh, \@row);
803            }
804            Trace("$valuesFound values backed up for key $key.") if T(3);
805            $retVal->Add(values => $valuesFound);
806        }
807        # Log the operation.
808        $self->LogOperation("Backup Data", $fileName, $retVal->Display());
809        # Return the result.
810        return $retVal;
811    }
812    
813  =head3 FieldMenu  =head3 FieldMenu
814    
815  C<< my $menuHtml = $attrDB->FieldMenu($cgi, $height, $name, $keys, %options); >>  C<< my $menuHtml = $attrDB->FieldMenu($cgi, $height, $name, $keys, %options); >>
# Line 812  Line 1070 
1070      return %retVal;      return %retVal;
1071  }  }
1072    
1073    =head3 LogOperation
1074    
1075    C<< $ca->LogOperation($action, $target, $description); >>
1076    
1077    Write an operation description to the attribute activity log (C<$FIG_Config::var/attributes.log>).
1078    
1079    =over 4
1080    
1081    =item action
1082    
1083    Action being logged (e.g. C<Delete Group> or C<Load Key>).
1084    
1085    =item target
1086    
1087    ID of the key or group affected.
1088    
1089    =item description
1090    
1091    Short description of the action.
1092    
1093    =back
1094    
1095    =cut
1096    
1097    sub LogOperation {
1098        # Get the parameters.
1099        my ($self, $action, $target, $description) = @_;
1100        # Get the user ID.
1101        my $user = $self->{user};
1102        # Get a timestamp.
1103        my $timeString = Tracer::Now();
1104        # Open the log file for appending.
1105        my $oh = Open(undef, ">>$FIG_Config::var/attributes.log");
1106        # Write the data to it.
1107        Tracer::PutLine($oh, [$timeString, $user, $action, $target, $description]);
1108        # Close the log file.
1109        close $oh;
1110    }
1111    
1112    =head2 Internal Utility Methods
1113    
1114    =head3 _KeywordString
1115    
1116    C<< my $keywordString = $ca->_KeywordString($key, $value); >>
1117    
1118    Compute the keyword string for a specified key/value pair. This consists of the
1119    key name and value converted to lower case with underscores translated to spaces.
1120    
1121    This method is for internal use only. It is called whenever we need to update or
1122    insert a B<HasValueFor> record.
1123    
1124    =over 4
1125    
1126    =item key
1127    
1128    Name of the relevant attribute key.
1129    
1130    =item target
1131    
1132    ID of the target object to which this key/value pair will be associated.
1133    
1134    =item value
1135    
1136    The value to store for this key/object combination.
1137    
1138    =item RETURN
1139    
1140    Returns the value that should be stored as the keyword string for the specified
1141    key/value pair.
1142    
1143    =back
1144    
1145    =cut
1146    
1147    sub _KeywordString {
1148        # Get the parameters.
1149        my ($self, $key, $value) = @_;
1150        # Get a copy of the key name and convert underscores to spaces.
1151        my $keywordString = $key;
1152        $keywordString =~ s/_/ /g;
1153        # Add the value convert it all to lower case.
1154        my $retVal = lc "$keywordString $value";
1155        # Return the result.
1156        return $retVal;
1157    }
1158    
1159    =head3 _QueryResults
1160    
1161    C<< my @attributeList = $attrDB->_QueryResults($query, @values); >>
1162    
1163    Match the results of a B<HasValueFor> query against value criteria and return
1164    the results. This is an internal method that splits the values coming back
1165    and matches the sections against the specified section patterns. It serves
1166    as the back end to L</GetAttributes> and L</FindAttributes>.
1167    
1168    =over 4
1169    
1170    =item query
1171    
1172    A query object that will return the desired B<HasValueFor> records.
1173    
1174    =item values
1175    
1176    List of the desired attribute values, section by section. If C<undef>
1177    or an empty string is specified, all values in that section will match. A
1178    generic match can be requested by placing a percent sign (C<%>) at the end.
1179    In that case, all values that match up to and not including the percent sign
1180    will match. You may also specify a regular expression enclosed
1181    in slashes. All values that match the regular expression will be returned. For
1182    performance reasons, only values have this extra capability.
1183    
1184    =item RETURN
1185    
1186    Returns a list of tuples. The first element in the tuple is an object ID, the
1187    second is an attribute key, and the remaining elements are the sections of
1188    the attribute value. All of the tuples will match the criteria set forth in
1189    the parameter list.
1190    
1191    =back
1192    
1193    =cut
1194    
1195    sub _QueryResults {
1196        # Get the parameters.
1197        my ($self, $query, @values) = @_;
1198        # Declare the return value.
1199        my @retVal = ();
1200        # Get the number of value sections we have to match.
1201        my $sectionCount = scalar(@values);
1202        # Loop through the assignments found.
1203        while (my $row = $query->Fetch()) {
1204            # Get the current row's data.
1205            my ($id, $key, $valueString) = $row->Values(['HasValueFor(to-link)', 'HasValueFor(from-link)',
1206                                                          'HasValueFor(value)']);
1207            # Break the value into sections.
1208            my @sections = split($self->{splitter}, $valueString);
1209            # Match each section against the incoming values. We'll assume we're
1210            # okay unless we learn otherwise.
1211            my $matching = 1;
1212            for (my $i = 0; $i < $sectionCount && $matching; $i++) {
1213                # We need to check to see if this section is generic.
1214                my $value = $values[$i];
1215                Trace("Current value pattern is \"$value\".") if T(4);
1216                if (substr($value, -1, 1) eq '%') {
1217                    Trace("Generic match used.") if T(4);
1218                    # Here we have a generic match.
1219                    my $matchLen = length($values[$i] - 1);
1220                    $matching = substr($sections[$i], 0, $matchLen) eq
1221                                substr($values[$i], 0, $matchLen);
1222                } elsif ($value =~ m#^/(.+)/[a-z]*$#) {
1223                    Trace("Regular expression detected.") if T(4);
1224                    # Here we have a regular expression match.
1225                    my $section = $sections[$i];
1226                    $matching = eval("\$section =~ $value");
1227                } else {
1228                    # Here we have a strict match.
1229                    Trace("Strict match used.") if T(4);
1230                    $matching = ($sections[$i] eq $values[$i]);
1231                }
1232            }
1233            # If we match, output this row to the return list.
1234            if ($matching) {
1235                push @retVal, [$id, $key, @sections];
1236            }
1237        }
1238        # Return the rows found.
1239        return @retVal;
1240    }
1241    
1242  =head2 FIG Method Replacements  =head2 FIG Method Replacements
1243    
1244  The following methods are used by B<FIG.pm> to replace the previous attribute functionality.  The following methods are used by B<FIG.pm> to replace the previous attribute functionality.
# Line 906  Line 1333 
1333  or an empty string is specified, all values in that section will match. A  or an empty string is specified, all values in that section will match. A
1334  generic match can be requested by placing a percent sign (C<%>) at the end.  generic match can be requested by placing a percent sign (C<%>) at the end.
1335  In that case, all values that match up to and not including the percent sign  In that case, all values that match up to and not including the percent sign
1336  will match.  will match. You may also specify a regular expression enclosed
1337    in slashes. All values that match the regular expression will be returned. For
1338    performance reasons, only values have this extra capability.
1339    
1340  =item RETURN  =item RETURN
1341    
# Line 924  Line 1353 
1353      my ($self, $objectID, $key, @values) = @_;      my ($self, $objectID, $key, @values) = @_;
1354      # We will create one big honking query. The following hash will build the filter      # We will create one big honking query. The following hash will build the filter
1355      # clause and a parameter list.      # clause and a parameter list.
1356      my %data = ('IsKeyOf(from-link)' => $key, 'Assignment(object-id)' => $objectID);      my %data = ('HasValueFor(from-link)' => $key, 'HasValueFor(to-link)' => $objectID);
1357      my @filter = ();      my @filter = ();
1358      my @parms = ();      my @parms = ();
1359      # This next loop goes through the different fields that can be specified in the      # This next loop goes through the different fields that can be specified in the
# Line 960  Line 1389 
1389                          # filter the field to this value pattern.                          # filter the field to this value pattern.
1390                          push @fieldFilter, "$field LIKE ?";                          push @fieldFilter, "$field LIKE ?";
1391                          # We must convert the pattern value to an SQL match pattern. First                          # We must convert the pattern value to an SQL match pattern. First
1392                          # we chop off the percent sign. (Note that I eschew chop because I                          # we get a copy of it.
1393                          # want a copy of the string.                          my $actualPattern = $pattern;
                         my $actualPattern = substr($pattern, 0, -1);  
1394                          # Now we escape the underscores. Underscores are an SQL wild card                          # Now we escape the underscores. Underscores are an SQL wild card
1395                          # character, but they are used frequently in key names and object IDs.                          # character, but they are used frequently in key names and object IDs.
1396                          $actualPattern = s/_/\\_/g;                          $actualPattern =~ s/_/\\_/g;
1397                          # Add the escaped pattern to the bound parameter list.                          # Add the escaped pattern to the bound parameter list.
1398                          push @parms, $actualPattern;                          push @parms, $actualPattern;
1399                      }                      }
# Line 979  Line 1407 
1407      # Now @filter contains one or more filter strings and @parms contains the parameter      # Now @filter contains one or more filter strings and @parms contains the parameter
1408      # values to bind to them.      # values to bind to them.
1409      my $actualFilter = join(" AND ", @filter);      my $actualFilter = join(" AND ", @filter);
     # Declare the return variable.  
     my @retVal = ();  
     # Get the number of value sections we have to match.  
     my $sectionCount = scalar(@values);  
1410      # Now we're ready to make our query.      # Now we're ready to make our query.
1411      my $query = $self->Get(['IsKeyOf', 'Assignment'], $actualFilter, \@parms);      my $query = $self->Get(['HasValueFor'], $actualFilter, \@parms);
1412      # Loop through the assignments found.      # Format the results.
1413      while (my $row = $query->Fetch()) {      my @retVal = $self->_QueryResults($query, @values);
         # Get the current row's data.  
         my ($id, $key, @valueStrings) = $row->Values(['Assignment(object-id)', 'IsKeyOf(from-link)',  
                                                       'Assignment(value)']);  
         # Process each value string individually.  
         for my $valueString (@valueStrings) {  
             # Break the value into sections.  
             my @sections = split($self->{splitter}, $valueString);  
             # Match each section against the incoming values. We'll assume we're  
             # okay unless we learn otherwise.  
             my $matching = 1;  
             for (my $i = 0; $i < $sectionCount && $matching; $i++) {  
                 # We need to check to see if this section is generic.  
                 if (substr($values[$i], -1, 1) eq '%') {  
                     my $matchLen = length($values[$i] - 1);  
                     $matching = substr($sections[$i], 0, $matchLen) eq  
                                 substr($values[$i], 0, $matchLen);  
                 } else {  
                     $matching = ($sections[$i] eq $values[$i]);  
                 }  
             }  
             # If we match, output this row to the return list.  
             if ($matching) {  
                 push @retVal, [$id, $key, @sections];  
             }  
         }  
     }  
1414      # Return the rows found.      # Return the rows found.
1415      return @retVal;      return @retVal;
1416  }  }
# Line 1055  Line 1453 
1453      } elsif (! @values) {      } elsif (! @values) {
1454          Confess("No values specified in AddAttribute call for key $key.");          Confess("No values specified in AddAttribute call for key $key.");
1455      } else {      } else {
1456          # Okay, now we have some reason to believe we can do this. Get the key for          # Okay, now we have some reason to believe we can do this. Form the values
1457          # the relevant assignment.          # into a scalar.
         my $assignKey = $self->AssignmentKey($objectID, $key);  
         # Form the values into a scalar.  
1458          my $valueString = join($self->{splitter}, @values);          my $valueString = join($self->{splitter}, @values);
1459          # See if the assignment exists.          # Connect the object to the key.
1460          my $found = $self->Exists('Assignment', $assignKey);          $self->InsertObject('HasValueFor', { 'from-link' => $key,
1461          if (! $found) {                                               'to-link'   => $objectID,
1462              # Here we have a new assignment. Insure that the key is valid.                                               'value'     => $valueString,
             $found = $self->Exists('AttributeKey', $key);  
             if (! $found) {  
                 Confess("Attribute key \"$key\" not found in database.");  
             } else {  
                 # The key is valid, so we can create a new assignment.  
                 $self->InsertObject('Assignment', { id => $assignKey,  
                                                     'object-id' => $objectID,  
                                                     value => [$valueString],  
1463                                                    });                                                    });
                 # Connect the assignment to the key.  
                 $self->InsertObject('IsKeyOf', { 'from-link' => $key,  
                                                  'to-link' => $assignKey,  
                                                });  
             }  
         } else {  
             # An assignment already exists. Add the new value to it.  
             $self->InsertValue($assignKey, 'Assignment(value)', $valueString);  
         }  
1464      }      }
1465      # Return a one, indicating success. We do this for backward compatability.      # Return a one, indicating success. We do this for backward compatability.
1466      return 1;      return 1;
# Line 1120  Line 1499 
1499          Confess("No object ID specified for DeleteAttribute call.");          Confess("No object ID specified for DeleteAttribute call.");
1500      } elsif (! defined($key)) {      } elsif (! defined($key)) {
1501          Confess("No attribute key specified for DeleteAttribute call.");          Confess("No attribute key specified for DeleteAttribute call.");
1502        } elsif (scalar(@values) == 0) {
1503            # Here we erase the entire key for this object.
1504            $self->DeleteRow('HasValueFor', $key, $objectID);
1505      } else {      } else {
1506          # Get the assignment key for this object/attribute pair.          # Here we erase the matching values.
         my $assignKey = $self->AssignmentKey($objectID, $key);  
         # Only proceed if it exists.  
         my $found = $self->Exists('Assignment', $assignKey);  
         if ($found && ! @values) {  
             # Here the caller wants to delete the entire assignment.  
             $self->Delete('Assignment', $assignKey);  
         } else {  
             # Here we're looking to delete only the one value. First, we get all  
             # the values currently present.  
             my @currentValues = $self->GetFlat(['Assignment'], "Assignment(id) = ?",  
                                                [$assignKey], 'Assignment(value)');  
             # Find our value amongst them.  
1507              my $valueString = join($self->{splitter}, @values);              my $valueString = join($self->{splitter}, @values);
1508              my @matches = grep { $_ eq $valueString } @currentValues;          $self->DeleteRow('HasValueFor', $key, $objectID, { value => $valueString });
             # Only proceed if we found it.  
             if (@matches) {  
                 # Find out if it's the only value.  
                 if (scalar(@matches) == scalar(@currentValues)) {  
                     # It is, so delete the assignment.  
                     $self->Delete('Assignment', $assignKey);  
                 } else {  
                     # It's not, so only delete the value itself.  
                     $self->DeleteValue('Assignment', $assignKey, 'value', $valueString);  
                 }  
             }  
         }  
1509      }      }
1510      # Return a one. This is for backward compatability.      # Return a one. This is for backward compatability.
1511      return 1;      return 1;
1512  }  }
1513    
1514    =head3 DeleteMatchingAttributes
1515    
1516    C<< my @deleted = $attrDB->DeleteMatchingAttributes($objectID, $key, @values); >>
1517    
1518    Delete all attributes that match the specified criteria. This is equivalent to
1519    calling L</GetAttributes> and then invoking L</DeleteAttribute> for each
1520    row found.
1521    
1522    =over 4
1523    
1524    =item objectID
1525    
1526    ID of object whose attributes are to be deleted. If the attributes for multiple
1527    objects are to be deleted, this parameter can be specified as a list reference. If
1528    attributes are to be deleted for all objects, specify C<undef> or an empty string.
1529    Finally, you can delete attributes for a range of object IDs by putting a percent
1530    sign (C<%>) at the end.
1531    
1532    =item key
1533    
1534    Attribute key name. A value of C<undef> or an empty string will match all
1535    attribute keys. If the values are to be deletedfor multiple keys, this parameter can be
1536    specified as a list reference. Finally, you can delete attributes for a range of
1537    keys by putting a percent sign (C<%>) at the end.
1538    
1539    =item values
1540    
1541    List of the desired attribute values, section by section. If C<undef>
1542    or an empty string is specified, all values in that section will match. A
1543    generic match can be requested by placing a percent sign (C<%>) at the end.
1544    In that case, all values that match up to and not including the percent sign
1545    will match. You may also specify a regular expression enclosed
1546    in slashes. All values that match the regular expression will be deleted. For
1547    performance reasons, only values have this extra capability.
1548    
1549    =item RETURN
1550    
1551    Returns a list of tuples for the attributes that were deleted, in the
1552    same form as L</GetAttributes>.
1553    
1554    =back
1555    
1556    =cut
1557    
1558    sub DeleteMatchingAttributes {
1559        # Get the parameters.
1560        my ($self, $objectID, $key, @values) = @_;
1561        # Get the matching attributes.
1562        my @retVal = $self->GetAttributes($objectID, $key, @values);
1563        # Loop through the attributes, deleting them.
1564        for my $tuple (@retVal) {
1565            $self->DeleteAttribute(@{$tuple});
1566        }
1567        # Log this operation.
1568        my $count = @retVal;
1569        $self->LogOperation("Mass Delete", $key, "$count matching attributes deleted.");
1570        # Return the deleted attributes.
1571        return @retVal;
1572    }
1573    
1574  =head3 ChangeAttribute  =head3 ChangeAttribute
1575    
1576  C<< $attrDB->ChangeAttribute($objectID, $key, \@oldValues, \@newValues); >>  C<< $attrDB->ChangeAttribute($objectID, $key, \@oldValues, \@newValues); >>
# Line 1207  Line 1625 
1625    
1626  =head3 EraseAttribute  =head3 EraseAttribute
1627    
1628  C<< $attrDB->EraseAttribute($entityName, $key); >>  C<< $attrDB->EraseAttribute($key); >>
1629    
1630  Erase all values for the specified attribute key. This does not remove the  Erase all values for the specified attribute key. This does not remove the
1631  key from the database; it merely removes all the values.  key from the database; it merely removes all the values.
# Line 1225  Line 1643 
1643  sub EraseAttribute {  sub EraseAttribute {
1644      # Get the parameters.      # Get the parameters.
1645      my ($self, $key) = @_;      my ($self, $key) = @_;
1646      # Delete everything connected to the key. The "keepRoot" option keeps the key in the      # Delete everything connected to the key.
1647      # datanase while deleting everything attached to it.      $self->Disconnect('HasValueFor', 'AttributeKey', $key);
1648      $self->Delete('AttributeKey', $key, keepRoot => 1);      # Log the operation.
1649        $self->LogOperation("Erase Data", $key);
1650      # Return a 1, for backward compatability.      # Return a 1, for backward compatability.
1651      return 1;      return 1;
1652  }  }
# Line 1262  Line 1681 
1681      return sort @groups;      return sort @groups;
1682  }  }
1683    
1684    =head3 ParseID
1685    
1686    C<< my ($type, $id) = CustomAttributes::ParseID($idValue); >>
1687    
1688    Determine the type and object ID corresponding to an ID value from the attribute database.
1689    Most ID values consist of a type name and an ID, separated by a colon (e.g. C<Family:aclame|cluster10>);
1690    however, Genomes, Features, and Subsystems are not stored with a type name, so we need to
1691    deduce the type from the ID value structure.
1692    
1693    The theory here is that you can plug the ID and type directly into a Sprout database method, as
1694    follows
1695    
1696        my ($type, $id) = CustomAttributes::ParseID($attrList[$num]->[0]);
1697        my $target = $sprout->GetEntity($type, $id);
1698    
1699    =over 4
1700    
1701    =item idValue
1702    
1703    ID value taken from the attribute database.
1704    
1705    =item RETURN
1706    
1707    Returns a two-element list. The first element is the type of object indicated by the ID value,
1708    and the second element is the actual object ID.
1709    
1710    =back
1711    
1712    =cut
1713    
1714    sub ParseID {
1715        # Get the parameters.
1716        my ($idValue) = @_;
1717        # Declare the return variables.
1718        my ($type, $id);
1719        # Parse the incoming ID. We first check for the presence of an entity name. Entity names
1720        # can only contain letters, which helps to insure typed object IDs don't collide with
1721        # subsystem names (which are untyped).
1722        if ($idValue =~ /^([A-Za-z]+):(.+)/) {
1723            # Here we have a typed ID.
1724            ($type, $id) = ($1, $2);
1725        } elsif ($idValue =~ /fig\|/) {
1726            # Here we have a feature ID.
1727            ($type, $id) = (Feature => $idValue);
1728        } elsif ($idValue =~ /\d+\.\d+/) {
1729            # Here we have a genome ID.
1730            ($type, $id) = (Genome => $idValue);
1731        } else {
1732            # The default is a subsystem ID.
1733            ($type, $id) = (Subsystem => $idValue);
1734        }
1735        # Return the results.
1736        return ($type, $id);
1737    }
1738    
1739    =head3 FormID
1740    
1741    C<< my $idValue = CustomAttributes::FormID($type, $id); >>
1742    
1743    Convert an object type and ID pair into an object ID string for the attribute system. Subsystems,
1744    genomes, and features are stored in the database without type information, but all other object IDs
1745    must be prefixed with the object type.
1746    
1747    =over 4
1748    
1749    =item type
1750    
1751    Relevant object type.
1752    
1753    =item id
1754    
1755    ID of the object in question.
1756    
1757    =item RETURN
1758    
1759    Returns a string that will be recognized as an object ID in the attribute database.
1760    
1761    =back
1762    
1763    =cut
1764    
1765    sub FormID {
1766        # Get the parameters.
1767        my ($type, $id) = @_;
1768        # Declare the return variable.
1769        my $retVal;
1770        # Compute the ID string from the type.
1771        if (grep { $type eq $_ } qw(Feature Genome Subsystem)) {
1772            $retVal = $id;
1773        } else {
1774            $retVal = "$type:$id";
1775        }
1776        # Return the result.
1777        return $retVal;
1778    }
1779    
1780    =head3 GetTargetObject
1781    
1782    C<< my $object = CustomAttributes::GetTargetObject($erdb, $idValue); >>
1783    
1784    Return the database object corresponding to the specified attribute object ID. The
1785    object type associated with the ID value must correspond to an entity name in the
1786    specified database.
1787    
1788    =over 4
1789    
1790    =item erdb
1791    
1792    B<ERDB> object for accessing the target database.
1793    
1794    =item idValue
1795    
1796    ID value retrieved from the attribute database.
1797    
1798    =item RETURN
1799    
1800    Returns a B<DBObject> for the attribute value's target object.
1801    
1802    =back
1803    
1804    =cut
1805    
1806    sub GetTargetObject {
1807        # Get the parameters.
1808        my ($erdb, $idValue) = @_;
1809        # Declare the return variable.
1810        my $retVal;
1811        # Get the type and ID for the target object.
1812        my ($type, $id) = ParseID($idValue);
1813        # Plug them into the GetEntity method.
1814        $retVal = $erdb->GetEntity($type, $id);
1815        # Return the resulting object.
1816        return $retVal;
1817    }
1818    
1819  1;  1;

Legend:
Removed from v.1.10  
changed lines
  Added in v.1.19

MCS Webmaster
ViewVC Help
Powered by ViewVC 1.0.3