source: trunk/grails-app/services/nl/tno/metagenomics/integration/SynchronizationService.groovy @ 3

Last change on this file since 3 was 3, checked in by robert@…, 9 years ago

Externalized configuration; improved assay view (detail views of runs and samples); implemented uploading and parsing of FASTA and QUAL files

File size: 23.0 KB
Line 
1package nl.tno.metagenomics.integration
2
3import nl.tno.metagenomics.*
4import nl.tno.metagenomics.auth.*
5import org.codehaus.groovy.grails.commons.ConfigurationHolder
6
7class SynchronizationService {
8        def gscfService
9
10        String sessionToken = ""        // Session token to use for communication
11        User user = null                        // Currently logged in user. Must be set when synchronizing authorization
12        boolean eager = false           // When set to true, this method fetches data about all studies from GSCF. Otherwise, it will only look at the
13        // studies marked as dirty in the database. Defaults to false.
14
15        // Keeps track of the last time this module performed a full synchronization.
16        static Date lastFullSynchronization = null;
17
18        static transactional = true
19
20        /**
21         * Determines whether the synchronization should be performed or not. This can be entered
22         * in configuration, to avoid synchronization when developing.
23         * @return
24         */
25        protected performSynchronization() {
26                def conf = ConfigurationHolder.config.metagenomics.synchronization;
27
28                // If nothing is entered in configuration, return true (default value)
29                if( conf == null )
30                        return true
31
32                return conf
33        }
34       
35        /**
36         * Returns true iff a full synchronization should be performed
37         * @return
38         */
39        public boolean timeForFullSynchronization() {
40                if( SynchronizationService.lastFullSynchronization == null )
41                        return true
42                       
43                // Compute the time since the last full synchronization in  milliseconds
44                Date today = new Date();
45                long difference = SynchronizationService.lastFullSynchronization.getTime() - today.getTime()
46               
47                if( difference / 1000 > ConfigurationHolder.config.metagenomics.fullSynchronization )
48                        return true
49                else
50                        return false
51        }
52
53        /**
54         * Redirects to a temporary page to give the user a 'waiting' page while synchronizing
55         * @return
56         */
57        public String urlForFullSynchronization( def params ) {
58                def returnUrl = ConfigurationHolder.config.grails.serverURL
59                if (params.controller != null){
60                        returnUrl += "/${params.controller}"
61                        if (params.action != null){
62                                returnUrl += "/${params.action}"
63                                if (params.id != null){
64                                        returnUrl += "/${params.id}"
65                                }
66                        }
67                }
68                if( timeForFullSynchronization() ) {
69                        return ConfigurationHolder.config.grails.serverURL + "/synchronize/full?redirect=" + returnUrl.encodeAsURL()
70                } else {
71                        return returnUrl
72                }
73        }
74
75        /**
76         * Performs a full synchronization in order to retrieve all studies
77         * @return
78         */
79        public void fullSynchronization() {
80                if( !timeForFullSynchronization() )
81                        return
82
83                def previousEager = eager
84                eager = true
85                synchronizeStudies();
86                eager = previousEager
87               
88                SynchronizationService.lastFullSynchronization = new Date();
89        }
90
91        /**
92         * Synchronizes all studies with the data from GSCF.
93         * @return      ArrayList       List of studies or null if the synchronization has failed
94         */
95        public ArrayList<Study> synchronizeStudies() {
96                if( !performSynchronization() )
97                        return Study.findAll()
98
99                // When eager fetching is enabled, ask for all studies, otherwise only ask for studies marked dirty
100                // Synchronization is performed on all studies, not only the studies the user has access to. Otherwise
101                // we would never notice that a user was given read-access to a study.
102                def studies
103                if( eager ) {
104                        studies = Study.findAll()
105                } else {
106                        studies = Study.findAllWhere( [isDirty: true] );
107                }
108
109                // Perform no synchronization if no studies have to be synchronized
110                // Perform synchronization on only one study directly, because otherwise
111                // the getStudies method could throw a ResourceNotFoundException or NotAuthorizedException
112                // that can better be handled by synchronizeStudy
113                if( studies.size() == 0 ) {
114                        return []
115                } else if( studies.size() == 1 ) {
116                        println "Study: " + studies[ 0]
117                        def newStudy = synchronizeStudy( studies[0] );
118                        if( newStudy )
119                                return [ newStudy ];
120                        else
121                                return []
122                }
123
124                // Fetch all studies from GSCF
125                def newStudies
126                try {
127                        if( !eager ) {
128                                def studyTokens = studies.studyToken;
129
130                                if( studyTokens instanceof String ) {
131                                        studyTokens = [studyTokens];
132                                }
133                                println "Only updating studies with tokens " + studyTokens.join( ',' );
134                                newStudies = gscfService.getStudies(sessionToken, studyTokens)
135                        } else {
136                                newStudies = gscfService.getStudies(sessionToken)
137                        }
138                } catch( Exception e ) { // All exceptions are thrown.
139                        // Can't retrieve data. Maybe sessionToken has expired or invalid. Anyway, stop
140                        // synchronizing and return null
141                        log.error( "Exception occurred when fetching studies: " + e.getMessage() )
142                        throw new Exception( "Error while fetching studies", e)
143                }
144
145                synchronizeStudies( newStudies );
146                studies = handleDeletedStudies( studies, newStudies );
147
148                log.trace( "Returning " + studies.size() + " studies after synchronization" )
149
150                return studies
151        }
152
153        /**
154         * Synchronizes all studies given by 'newStudies' with existing studies in the database, and adds them
155         * if they don't exist
156         *
157         * @param newStudies            JSON object with studies as returned by GSCF
158         * @return
159         */
160        protected synchronizeStudies( def newStudies ) {
161                // Synchronize all studies that are returned. Studies that are not returned by GSCF might be removed
162                // but could also be invisible for the current user.
163                newStudies.each { gscfStudy ->
164                        if( gscfStudy.studyToken ) {
165                                log.trace( "Processing GSCF study " + gscfStudy.studyToken + ": " + gscfStudy )
166
167                                Study studyFound = Study.findByStudyToken( gscfStudy.studyToken as String )
168
169                                if(studyFound) {
170                                        log.trace( "Study found with name " + studyFound.name )
171
172                                        // Synchronize the study itself with the data retrieved
173                                        synchronizeStudy( studyFound, gscfStudy );
174                                } else {
175                                        log.trace( "Study not found. Creating a new one" )
176
177                                        // If it doesn't exist, create a new object
178                                        studyFound = new Study( studyToken: gscfStudy.studyToken, name: gscfStudy.title, isDirty: true );
179                                        studyFound.save();
180
181                                        // Synchronize authorization and study assays (since the study itself is already synchronized)
182                                        synchronizeAuthorization(studyFound);
183                                        synchronizeStudyAssays(studyFound);
184
185                                        // Mark the study as clean
186                                        studyFound.isDirty = false
187                                        studyFound.save();
188                                }
189                        }
190                }
191        }
192
193        /**
194         * Removes studies from the database that are expected but not found in the list from GSCF
195         * @param studies               List with existing studies in the database that were expected in the output of GSCF
196         * @param newStudies    JSON object with studies as returned by GSCF
197         * @return                              List of remaining studies
198         */
199        protected ArrayList<Study> handleDeletedStudies( def studies, def newStudies ) {
200                // If might also be that studies have been removed from the system. In that case, the studies
201                // should be deleted from this module as well. Looping backwards in order to avoid conflicts
202                // when removing elements from the list
203                def numStudies = studies.size();
204                for( int i = numStudies - 1; i >= 0; i-- ) {
205                        def existingStudy = studies[i];
206
207                        def studyFound = newStudies.find { it.studyToken == existingStudy.studyToken }
208
209                        if( !studyFound ) {
210                                log.trace( "Study " + existingStudy.studyToken + " not found. Check whether it is removed or the user just can't see it." )
211
212                                // Study was not given to us by GSCF. This might be because the study is removed, or because the study is not visible (anymore)
213                                // to the current user.
214                                // Synchronize authorization and see what is the case (it returns null if the study has been deleted)
215                                if( synchronizeAuthorization( existingStudy ) == null ) {
216                                        // Update studies variable to keep track of all existing studies
217                                        studies.remove( existingStudy )
218                                }
219                        }
220                }
221
222                return studies
223        }
224
225        /**
226         * Synchronizes the given study with the data from GSCF
227         * @param       study   Study to synchronize
228         * @return      Study   Synchronized study or null if the synchronization has failed
229         */
230        public Study synchronizeStudy(Study study ) {
231                if( !performSynchronization() )
232                        return study
233
234                if( study == null )
235                        return null
236
237                // If the study hasn't changed, don't update anything
238                if( !eager && !study.isDirty )
239                        return study;
240
241                // Retrieve the study from GSCF
242                def newStudy
243                try {
244                        newStudy = gscfService.getStudy(sessionToken, study.studyToken)
245                } catch( NotAuthorizedException e ) {
246                        // User is not authorized to access this study. Update the authorization within the module and return
247                        synchronizeAuthorization( study );
248                        return null
249                } catch( ResourceNotFoundException e ) {
250                        // Study can't be found within GSCF.
251                        // TODO: How to handle the data that remains
252                        study.delete()
253                        return null
254                } catch( Exception e ) { // All other exceptions
255                        // Can't retrieve data. Maybe sessionToken has expired or invalid. Anyway, stop
256                        // synchronizing and return null
257                        log.error( "Exception occurred when fetching study " + study.studyToken + ": " + e.getMessage() )
258                        throw new Exception( "Error while fetching study " + study.studyToken, e)
259                }
260
261                // If no study is returned, something went wrong.
262                if( newStudy.size() == 0 ) {
263                        throw new Exception( "No data returned for study " + study.studyToken + " but no error has occurred either. Please contact your system administrator" );
264                        return null;
265                }
266
267                return synchronizeStudy(study, newStudy);
268        }
269
270        /**
271         * Synchronizes the given study with the data from GSCF
272         * @param       study           Study to synchronize
273         * @param       newStudy        Data to synchronize the study with
274         * @return      Study           Synchronized study or null if the synchronization has failed
275         */
276        protected Study synchronizeStudy(Study study, def newStudy) {
277                if( !performSynchronization() )
278                        return study
279
280                if( study == null || newStudy == null)
281                        return null
282
283                // If the study hasn't changed, don't update anything
284                if( !eager && !study.isDirty ) {
285                        return study;
286                }
287
288                // If no study is returned, something went wrong.
289                if( newStudy.size() == 0 ) {
290                        return null;
291                }
292
293                // Mark study dirty to enable synchronization
294                study.isDirty = true;
295                synchronizeAuthorization( study );
296                synchronizeStudyAssays( study );
297
298                // Update properties and mark as clean
299                study.name = newStudy.title
300                study.isDirty = false;
301                study.save()
302
303                return study
304        }
305
306        /**
307         * Synchronizes the assays of the given study with the data from GSCF
308         * @param study         Study of which the assays should be synchronized
309         * @return      ArrayList       List of assays or null if the synchronization has failed
310         */
311        protected ArrayList<Assay> synchronizeStudyAssays( Study study ) {
312                if( !performSynchronization() )
313                        return study.assays.toList()
314
315                if( !eager && !study.isDirty )
316                        return study.assays as List
317
318                // Also update all assays, belonging to this study
319                // Retrieve the assays from GSCF
320                def newAssays
321                try {
322                        newAssays = gscfService.getAssays(sessionToken, study.studyToken)
323                } catch( Exception e ) { // All exceptions are thrown. If we get a NotAuthorized or NotFound Exception, something has changed in between the two requests. This will result in an error
324                        // Can't retrieve data. Maybe sessionToken has expired or invalid. Anyway, stop
325                        // synchronizing and return null
326                        log.error( "Exception occurred when fetching assays for study " + study.studyToken + ": " + e.getMessage() )
327                        throw new Exception( "Error while fetching samples for assay " + study.studyToken, e)
328                }
329
330                // If no assay is returned, we remove all assays
331                // from this study and return an empty list
332                if( newAssays.size() == 0 && study.assays != null ) {
333                        def studyAssays = study.assays.toArray();
334                        def numStudyAssays = study.assays.size();
335                        for( int i = numStudyAssays - 1; i >= 0; i-- ) {
336                                def existingAssay = studyAssays[i];
337
338                                existingAssay.delete()
339                                study.removeFromAssays( existingAssay );
340                        }
341
342                        return []
343                }
344
345                synchronizeStudyAssays( study, newAssays );
346                return handleDeletedAssays( study, newAssays );
347        }
348
349        /**
350         * Synchronizes the assays of a study with the given data from GSCF
351         * @param study         Study to synchronize
352         * @param newAssays     JSON object given by GSCF to synchronize the assays with
353         */
354        protected void synchronizeStudyAssays( Study study, def newAssays ) {
355                // Otherwise, we search for all assays in the new list, if they
356                // already exist in the list of assays
357                newAssays.each { gscfAssay ->
358                        if( gscfAssay.assayToken ) {
359                                log.trace( "Processing GSCF assay " + gscfAssay.assayToken + ": " + gscfAssay )
360
361                                Assay assayFound = study.assays.find { it.assayToken == gscfAssay.assayToken }
362
363                                if(assayFound) {
364                                        log.trace( "Assay found with name " + assayFound.name )
365
366                                        // Synchronize the assay itself with the data retrieved
367                                        synchronizeAssay( assayFound, gscfAssay );
368                                } else {
369                                        log.trace( "Assay not found in study. Creating a new one" )
370
371                                        // If it doesn't exist, create a new object
372                                        assayFound = new Assay( assayToken: gscfAssay.assayToken, name: gscfAssay.name, study: study );
373
374                                        log.trace( "Connecting assay to study" )
375                                        study.addToAssays( assayFound );
376                                        assayFound.save()
377
378                                        // Synchronize assay samples (since the assay itself is already synchronized)
379                                        synchronizeAssaySamples(assayFound)
380                                }
381                        }
382                }
383        }
384
385        /**
386         * Removes assays from the system that have been deleted from GSCF
387         * @param study         Study to synchronize
388         * @param newAssays     JSON object given by GSCF to synchronize the assays with
389         * @return      List with all assays from the study
390         */
391        protected ArrayList<Assay> handleDeletedAssays( Study study, def newAssays ) {
392                if( study.assays == null ) {
393                        return []
394                }
395
396                // If might also be that assays have been removed from this study. In that case, the removed assays
397                // should be deleted from this study in the module as well. Looping backwards in order to avoid conflicts
398                // when removing elements from the list
399                def assays = study.assays.toArray();
400                def numAssays = assays.size();
401                for( int i = numAssays - 1; i >= 0; i-- ) {
402                        def existingAssay = assays[i];
403
404                        Assay assayFound = newAssays.find { it.assayToken == existingAssay.assayToken }
405
406                        if( !assayFound ) {
407                                log.trace( "Assay " + existingAssay.assayToken + " not found. Removing it." )
408
409                                // The assay has been removed
410                                // TODO: What to do with the data associated with this Assay (e.g. sequences)? See also GSCF ticket #255
411                                existingAssay.delete();
412                                study.removeFromAssays( existingAssay );
413                        }
414                }
415
416                return study.assays.toList()
417        }
418
419        /**
420         * Retrieves the authorization for the currently logged in user
421         * Since GSCF only provides authorization information about the currently
422         * logged in user, we can not guarantee that the authorization information
423         * is synchronized for all users.
424         *
425         * Make sure synchronizationService.user is set beforehand
426         * 
427         * @param study Study to synchronize authorization for
428         * @return      Auth object for the given study and user
429         */
430        public Auth synchronizeAuthorization(Study study) {
431                // If the user is not set, we can't save anything to the database.
432                if( user == null ) {
433                        throw new Exception( "Property user of SynchronizationService must be set to the currently logged in user" );
434                }
435
436                // Only perform synchronization if needed
437                if( !eager && !study.isDirty )
438                        return Auth.findByUserAndStudy( user, study )
439
440                if( !performSynchronization() )
441                        return Auth.findByUserAndStudy( user, study )
442
443                def gscfAuthorization
444                try {
445                        gscfAuthorization = gscfService.getAuthorizationLevel( sessionToken, study.studyToken )
446                } catch( ResourceNotFoundException e ) {
447                        // Study has been deleted, remove all authorization on that study
448                        // TODO: handle deletion
449                        log.trace( "Study " + study.studyToken + " has been deleted. Remove all authorization on that study")
450
451                        Auth.findAllByStudy( study ).each {
452                                it.delete()
453                        }
454
455                        return null
456                }
457
458                // Update the authorization object, or create a new one
459                Auth a = Auth.authorization( study, user )
460
461                if( !a ) {
462                        log.trace( "Authorization not found for " + study.studyToken + " and " + user.username + ". Creating a new object" );
463
464                        a = Auth.createAuth( study, user );
465                }
466
467                // Copy properties from gscf object
468                a.canRead = gscfAuthorization.canRead
469                a.canWrite = gscfAuthorization.canWrite
470                a.isOwner = gscfAuthorization.isOwner
471
472                a.save()
473
474                return a
475        }
476
477        /**
478         * Synchronizes the given assay with the data from GSCF
479         * @param assay         Assay to synchronize
480         * @return      Assay   Synchronized assay or null if the synchronization has failed
481         */
482        public Assay synchronizeAssay(Assay assay) {
483                if( !performSynchronization() )
484                        return assay
485
486                if( assay == null )
487                        return null
488
489                // Only perform synchronization if needed
490                if( !eager && !assay.study.isDirty )
491                        return assay
492
493                // Retrieve the assay from GSCF
494                def newAssay
495                try {
496                        newAssay = gscfService.getAssay(sessionToken, assay.study.studyToken, assay.assayToken)
497                } catch( NotAuthorizedException e ) {
498                        // User is not authorized to access this study. Update the authorization within the module and return
499                        synchronizeAuthorization( assay.study );
500                        return null
501                } catch( ResourceNotFoundException e ) {
502                        // Assay can't be found within GSCF.
503                        // TODO: How to handle the data that remains
504                        assay.delete()
505                        return null
506                } catch( Exception e ) { // All other exceptions are thrown
507                        // Can't retrieve data. Maybe sessionToken has expired or invalid. Anyway, stop
508                        // synchronizing and return null
509                        log.error( "Exception occurred when fetching assay " + assay.assayToken + ": " + e.getMessage() )
510                        throw new Exception( "Error while fetching assay " + assay.assayToken, e)
511                }
512
513                // If new assay is empty, something went wrong
514                if( newAssay.size() == 0 ) {
515                        throw new Exception( "No data returned for assay " + assay.assayToken + " but no error has occurred either. Please contact your system administrator" );
516                        return null;
517                }
518
519                return synchronizeAssay( assay, newAssay );
520        }
521
522        /**
523         * Synchronizes the given assay with the data given
524         * @param assay         Assay to synchronize
525         * @param newAssay      New data for the assay, retrieved from GSCF
526         * @return      Assay   Synchronized assay or null if the synchronization has failed
527         */
528        protected Assay synchronizeAssay(Assay assay, def newAssay) {
529                if( !performSynchronization() )
530                        return assay
531
532                if( assay == null || newAssay == null )
533                        return null
534
535                // Only perform synchronization if needed
536                if( !eager && !assay.study.isDirty )
537                        return assay
538
539                // If new assay is empty, something went wrong
540                if( newAssay.size() == 0 ) {
541                        return null;
542                }
543
544                log.trace( "Assay is found in GSCF: " + assay.name + " / " + newAssay )
545                if( newAssay?.name ) {
546                        assay.name = newAssay.name
547                        assay.save()
548                }
549
550                // Synchronize samples
551                synchronizeAssaySamples(assay);
552
553                return assay
554        }
555
556        /**
557         * Synchronizes the samples of a given assay with the data from GSCF
558         * @param       assay   Assay to synchronize
559         * @return      Sample  List of samples or null if the synchronization failed
560         */
561        protected ArrayList<AssaySample> synchronizeAssaySamples(Assay assay) {
562                if( !performSynchronization() )
563                        return assay.assaySamples.toList()
564
565                // If no assay is given, return null
566                if( assay == null )
567                        return null
568
569                // Retrieve the assay from GSCF
570                def newSamples
571                try {
572                        newSamples = gscfService.getSamples(sessionToken, assay.assayToken)
573                } catch( NotAuthorizedException e ) {
574                        // User is not authorized to access this study. Update the authorization within the module and return
575                        synchronizeAuthorization( assay.study );
576                        return null
577                } catch( ResourceNotFoundException e ) {
578                        // Assay can't be found within GSCF. Samples will be removed
579                        // TODO: How to handle the data that remains
580                        assay.removeAssaySamples();
581
582                        return null
583                } catch( Exception e ) {
584                        // Can't retrieve data. Maybe sessionToken has expired or invalid. Anyway, stop
585                        // synchronizing and return null
586                        log.error( "Exception occurred when fetching samples for assay " + assay.assayToken + ": " + e.getMessage() )
587                        throw new Exception( "Error while fetching samples for assay " + assay.assayToken, e)
588                }
589
590                // If no sample is returned, we remove all samples from the list
591                if( newSamples.size() == 0 ) {
592                        assay.removeAssaySamples();
593                        return []
594                }
595
596
597                synchronizeAssaySamples( assay, newSamples );
598                return handleDeletedSamples( assay, newSamples );
599        }
600
601        /**
602         * Synchronize all samples for a given assay with the data from GSCF
603         * @param assay                 Assay to synchronize samples for
604         * @param newSamples    New samples in JSON object, as given by GSCF
605         */
606        protected void synchronizeAssaySamples( Assay assay, def newSamples ) {
607                // Otherwise, we search for all samples in the new list, if they
608                // already exist in the list of samples
609                newSamples.each { gscfSample ->
610                        log.trace( "Processing GSCF sample " + gscfSample.name + ": " + gscfSample )
611                        if( gscfSample.name ) {
612
613                                AssaySample assaySampleFound = assay.assaySamples.find { it.sample.sampleToken == gscfSample.name }
614                                Sample sampleFound
615
616                                if(assaySampleFound) {
617                                        sampleFound = assaySampleFound.sample
618                                        log.trace( "AssaySample found with sample " + sampleFound.name )
619
620                                        // Update the sample object if necessary
621                                        if( sampleFound.name != gscfSample.name ) {
622                                                sampleFound.name = gscfSample.name
623                                                sampleFound.save();
624                                        }
625                                } else {
626                                        log.trace( "AssaySample not found in assay" )
627
628                                        // Check if the sample already exists in the database.
629                                        sampleFound = Sample.findBySampleTokenAndStudy( gscfSample.name as String, assay.study )
630
631                                        if( sampleFound ){
632                                                log.trace( "Sample " + gscfSample.name + " is found in database. Updating if necessary" )
633
634                                                // Update the sample object if necessary
635                                                if( sampleFound.name != gscfSample.name ) {
636                                                        sampleFound.name =gscfSample.name
637                                                        sampleFound.save();
638                                                }
639                                        } else {
640                                                log.trace( "Sample " + gscfSample.name + " not found in database. Creating a new object." )
641
642                                                // If it doesn't exist, create a new object
643                                                sampleFound = new Sample( sampleToken: gscfSample.name, name: gscfSample.name, study: assay.study );
644                                                sampleFound.save();
645                                        }
646
647                                        // Create a new assay-sample combination
648                                        log.trace( "Connecting sample to assay" )
649                                        assaySampleFound = new AssaySample();
650
651                                        assay.addToAssaySamples( assaySampleFound );
652                                        sampleFound.addToAssaySamples( assaySampleFound );
653                                        assaySampleFound.save();
654                                }
655                        }
656                }
657        }
658
659        /**
660         * Removes samples from the system that have been removed from an assay in GSCF
661         * @param assay                 Assay to remove samples for
662         * @param newSamples    JSON object with all samples for this assay as given by GSCF
663         */
664        protected ArrayList<AssaySample> handleDeletedSamples( Assay assay, def newSamples ) {
665                // If might also be that samples have been removed from this assay. In that case, the removed samples
666                // should be deleted from this assay. Looping backwards in order to avoid conflicts when removing elements
667                // from the list
668                if( assay.assaySamples != null ) {
669                        def assaySamples = assay.assaySamples.toArray();
670                        def numAssaySamples = assay.assaySamples.size();
671                        for( int i = numAssaySamples - 1; i >= 0; i-- ) {
672                                def existingSample = assaySamples[i];
673
674                                AssaySample sampleFound = newSamples.find { it.name == existingSample.sample.sampleToken }
675
676                                if( !sampleFound ) {
677                                        log.trace( "Sample " + existingSample.sample.sampleToken + " not found. Removing it." )
678
679                                        // The sample has been removed
680                                        // TODO: What to do with the data associated with this AssaySample (e.g. sequences)? See also GSCF ticket #255
681                                        existingSample.delete();
682                                        assay.removeFromAssaySamples( existingSample );
683                                }
684                        }
685                }
686
687                // Create a list of samples to return
688                return assay.assaySamples.toList()
689
690        }
691}
Note: See TracBrowser for help on using the repository browser.