source: trunk/quest-plugin-jfc/src/main/java/de/ugoe/cs/quest/plugin/jfc/JFCTraceCorrector.java @ 833

Last change on this file since 833 was 833, checked in by pharms, 12 years ago
  • added performance improvement
File size: 28.6 KB
Line 
1package de.ugoe.cs.quest.plugin.jfc;
2
3import java.io.BufferedOutputStream;
4import java.io.File;
5import java.io.FileInputStream;
6import java.io.FileNotFoundException;
7import java.io.FileOutputStream;
8import java.io.IOException;
9import java.io.InputStreamReader;
10import java.io.PrintStream;
11import java.io.UnsupportedEncodingException;
12import java.util.ArrayList;
13import java.util.HashMap;
14import java.util.List;
15import java.util.Map;
16
17import javax.xml.parsers.ParserConfigurationException;
18import javax.xml.parsers.SAXParser;
19import javax.xml.parsers.SAXParserFactory;
20
21import org.xml.sax.Attributes;
22import org.xml.sax.InputSource;
23import org.xml.sax.SAXException;
24import org.xml.sax.SAXParseException;
25import org.xml.sax.helpers.DefaultHandler;
26
27import de.ugoe.cs.util.StringTools;
28import de.ugoe.cs.util.console.Console;
29
30/**
31 * <p>
32 * corrects older JFC log files which sometimes do not contain correct source specifications for
33 * events. It parses the file and adds component specifications to the sources, that do not have
34 * them. For each invalid source it checks, if there is another source with the same
35 * <code>toString</code> parameter but a complete list of components. If one is found, it is reused
36 * as source for the event with the wrong source. If none is found, a new component list is
37 * generated. This contains as parent components the components of the source of the previous event.
38 * The leaf component is parsed from the <code>toString</code> parameter that is provided with the
39 * source specifications. The resulting leaf nodes are not fully correct. They may pretend to
40 * be equal although they are not. Furthermore they may resist at a position in the GUI tree where
41 * they are not in reality. But more correctness is not achievable based on the
42 * <code>toString</code> parameter.
43 * </p>
44 *
45 * @version $Revision: $ $Date: 05.09.2012$
46 * @author 2012, last modified by $Author: pharms$
47 */
48public class JFCTraceCorrector  extends DefaultHandler {
49
50    /**
51     * <p>
52     * the file to write the result into
53     * </p>
54     */
55    private PrintStream outFile;
56   
57    /**
58     * <p>
59     * the currently parsed event
60     * </p>
61     */
62    private Event currentEvent;
63
64    /**
65     * <p>
66     * the currently parsed source of the currently parsed event
67     * </p>
68     */
69    private Source currentSource;
70
71    /**
72     * <p>
73     * the list of all sources parsed in a file identified through their <code>toString</code>
74     * representation
75     * </p>
76     */
77    private Map<String, List<Source>> allSources = new HashMap<String, List<Source>>();
78
79    /**
80     * <p>
81     * the currently parsed component of the currently parsed source of the currently parsed event
82     * </p>
83     */
84    private Component currentComponent;
85
86    /**
87     * <p>
88     * the currently parsed session
89     * </p>
90     */
91    private Session currentSession;
92
93    /**
94     * <p>
95     * corrects the given file and returns the name of the file into which the result was written
96     * </p>
97     *
98     * @param filename the name of the file to be corrected
99     *
100     * @return the name of the file with the corrected logfile
101     *
102     * @throws IllegalArgumentException if the filename is null
103     */
104    public String correctFile(String filename) throws IllegalArgumentException {
105        if (filename == null) {
106            throw new IllegalArgumentException("filename must not be null");
107        }
108
109        return correctFile(new File(filename)).getAbsolutePath();
110    }
111
112    /**
113     * <p>
114     * corrects the given file, stores the result in the second provided file and returns the
115     * name of the file into which the result was written
116     * </p>
117     *
118     * @param filename   the name of the file to be corrected
119     * @param resultFile the name of the file into which the corrected log shall be written
120     *
121     * @return the name of the file with the corrected logfile
122     *
123     * @throws IllegalArgumentException if the filename or resultFile is null
124     */
125    public String correctFile(String filename, String resultFile) throws IllegalArgumentException {
126        if ((filename == null) | (resultFile == null)) {
127            throw new IllegalArgumentException("filename and resultFile must not be null");
128        }
129
130        return correctFile(new File(filename), new File(resultFile)).getAbsolutePath();
131    }
132
133    /**
134     * <p>
135     * corrects the given file and returns the file into which the result was written. The name
136     * of the resulting file is contains the suffix "_corrected" before the dot.
137     * </p>
138     *
139     * @param file the file to be corrected
140     *
141     * @return the file containing the corrected logfile
142     *
143     * @throws IllegalArgumentException if the file is null
144     */
145    public File correctFile(File file) throws IllegalArgumentException {
146        if (file == null) {
147            throw new IllegalArgumentException("file must not be null");
148        }
149
150        int index = file.getName().lastIndexOf('.');
151        String fileName =
152            file.getName().substring(0, index) + "_corrected" + file.getName().substring(index);
153
154        File resultFile = new File(file.getParentFile(), fileName);
155
156        return correctFile(file, resultFile);
157    }
158
159    /**
160     * <p>
161     * corrects the given file, stores the result in the second provided file and returns the
162     * file into which the result was written
163     * </p>
164     *
165     * @param file       the file to be corrected
166     * @param resultFile the file into which the corrected log shall be written
167     *
168     * @return the file with the corrected logfile
169     *
170     * @throws IllegalArgumentException if the file or resultFile is null or if they are equal
171     */
172    public File correctFile(File file, File resultFile) throws IllegalArgumentException {
173        if ((file == null) || (resultFile == null)) {
174            throw new IllegalArgumentException("file and result file must not be null");
175        }
176       
177        if (file.getAbsolutePath().equals(resultFile.getAbsolutePath())) {
178            throw new IllegalArgumentException("file and result file must not be equal");
179        }
180       
181        try {
182            outFile = new PrintStream(new BufferedOutputStream(new FileOutputStream(resultFile)));
183            outFile.println("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");
184        }
185        catch (FileNotFoundException e1) {
186            throw new IllegalArgumentException("could not create a corrected file name " +
187                                               resultFile + " next to " + file);
188        }
189       
190
191        SAXParserFactory spf = SAXParserFactory.newInstance();
192        spf.setValidating(true);
193
194        SAXParser saxParser = null;
195        InputSource inputSource = null;
196        try {
197            saxParser = spf.newSAXParser();
198            inputSource =
199                new InputSource(new InputStreamReader(new FileInputStream(file), "UTF-8"));
200        }
201        catch (UnsupportedEncodingException e) {
202            Console.printerr("Error parsing file + " + file.getName());
203            Console.logException(e);
204            return null;
205        }
206        catch (ParserConfigurationException e) {
207            Console.printerr("Error parsing file + " + file.getName());
208            Console.logException(e);
209            return null;
210        }
211        catch (SAXException e) {
212            Console.printerr("Error parsing file + " + file.getName());
213            Console.logException(e);
214            return null;
215        }
216        catch (FileNotFoundException e) {
217            Console.printerr("Error parsing file + " + file.getName());
218            Console.logException(e);
219            return null;
220        }
221        if (inputSource != null) {
222            inputSource.setSystemId("file://" + file.getAbsolutePath());
223            try {
224                if (saxParser == null) {
225                    throw new RuntimeException("SAXParser creation failed");
226                }
227                saxParser.parse(inputSource, this);
228            }
229            catch (SAXParseException e) {
230                Console.printerrln("Failure parsing file in line " + e.getLineNumber() +
231                    ", column " + e.getColumnNumber() + ".");
232                Console.logException(e);
233                return null;
234            }
235            catch (SAXException e) {
236                Console.printerr("Error parsing file + " + file.getName());
237                Console.logException(e);
238                return null;
239            }
240            catch (IOException e) {
241                Console.printerr("Error parsing file + " + file.getName());
242                Console.logException(e);
243                return null;
244            }
245        }
246       
247        if (outFile != null) {
248            outFile.close();
249        }
250       
251        return resultFile;
252    }
253
254    /*
255     * (non-Javadoc)
256     *
257     * @see org.xml.sax.helpers.DefaultHandler#startElement(java.lang.String, java.lang.String,
258     * java.lang.String, org.xml.sax.Attributes)
259     */
260    public void startElement(String uri, String localName, String qName, Attributes atts)
261        throws SAXException
262    {
263        if (qName.equals("sessions")) {
264            currentSession = new Session();
265            currentSession.type = "sessions";
266        }
267        else if (qName.equals("newsession")) {
268            if (currentSession != null) {
269                currentSession.dump(outFile);
270            }
271           
272            currentSession = new Session();
273            currentSession.type = "newsession";
274        }
275        else if (qName.equals("event")) {
276            currentEvent = new Event();
277            currentEvent.id = atts.getValue("id");
278        }
279        else if (qName.equals("source")) {
280            currentSource = new Source();
281        }
282        else if (qName.equals("component")) {
283            currentComponent = new Component();
284        }
285        else if (qName.equals("param")) {
286            if (currentComponent != null) {
287                currentComponent.params.add
288                    (new String[] {atts.getValue("name"), atts.getValue("value") });
289            }
290            else if (currentSource != null) {
291                currentSource.params.add
292                    (new String[] {atts.getValue("name"), atts.getValue("value") });
293            }
294            else if (currentEvent != null) {
295                currentEvent.params.add
296                    (new String[] {atts.getValue("name"), atts.getValue("value") });
297            }
298            else {
299                throw new SAXException("parameter occurred at an unexpected place");
300            }
301        }
302        else {
303            throw new SAXException("unexpected tag " + qName);
304        }
305    }
306
307    /*
308     * (non-Javadoc)
309     *
310     * @see org.xml.sax.helpers.DefaultHandler#endElement(java.lang.String, java.lang.String,
311     * java.lang.String)
312     */
313    @Override
314    public void endElement(String uri, String localName, String qName) throws SAXException {
315        if (qName.equals("sessions")) {
316            correctSources(currentSession);
317
318            currentSession.dump(outFile);
319            currentSession = null;
320        }
321        else if (qName.equals("newsession")) {
322            correctSources(currentSession);
323           
324            currentSession.dump(outFile);
325            currentSession = null;
326        }
327        else if (qName.equals("event")) {
328            currentSession.events.add(currentEvent);
329            currentEvent = null;
330        }
331        else if (qName.equals("source")) {
332            rememberSource(currentSource);
333            currentEvent.source = currentSource;
334            currentSource = null;
335        }
336        else if (qName.equals("component")) {
337            currentSource.components.add(currentComponent);
338            currentComponent = null;
339        }
340        else if (!qName.equals("param")) {
341            throw new SAXException("unexpected closing tag " + qName);
342        }
343
344    }
345
346    /**
347     * <p>
348     * stores a parsed source for later correction or reuse
349     * </p>
350     *
351     * @param source the source to store
352     */
353    private void rememberSource(Source source) {
354        String toStringValue = getToStringParam(source);
355       
356        List<Source> sources = allSources.get(toStringValue);
357       
358        if (sources == null) {
359            sources = new ArrayList<Source>();
360            allSources.put(toStringValue, sources);
361        }
362       
363        sources.add(source);
364    }
365
366    /**
367     * <p>
368     * convenience method to find a source based on its <code>toString</code> parameter value.
369     * The method only returns sources, which match the provided <code>toString</code>
370     * representation and which have a valid list of components.
371     * </p>
372     *
373     * @param toStringValue the value of the <code>toString</code> parameter the source to find
374     *                      must have
375     *
376     * @return the source matching the parameter and having a valid list of components or null if
377     *         none is found
378     */
379    private Source findSource(String toStringValue) {
380        Source existingSource = null;
381       
382        List<Source> candidates = allSources.get(toStringValue);
383       
384        if (candidates != null) {
385            for (Source candidate : candidates) {
386                if (toStringValue.equals(getToStringParam(candidate)) &&
387                        (candidate.components != null) && (candidate.components.size() > 0))
388                {
389                    existingSource = candidate;
390                }
391            }
392        }
393       
394        return existingSource;
395    }
396
397    /**
398     * <p>
399     * corrects all wrong sources in the events of the session. For each wrong resource, the
400     * {@link #correctEventSource(Event, Source)} method is called.
401     * </p>
402     *
403     * @param session the session of which the events shall be corrected
404     */
405    private void correctSources(Session session) {
406        Source previousSource = null;
407        for (Event event : session.events) {
408            if ((event.source == null) || (event.source.components == null) ||
409                (event.source.components.size() == 0))
410            {
411                correctEventSource(event, previousSource);
412            }
413           
414            previousSource = event.source;
415        }
416    }
417
418    /**
419     * <p>
420     * corrects the source of an event. It first searches for a correct source with an equal
421     * <code>toString</code> parameter. If there is any, this is reused. Otherwise it creates a
422     * new correct source. For this, it copies all parameters of the source of the provided previous
423     * event if they are not included in the source already. Furthermore, it adds all components
424     * of the source of the previous event. At last, it adds a further component based on the
425     * information found in the <code>toString</code> parameter of the source.
426     * </p>
427     *
428     * @param event          the event of which the source must be corrected
429     * @param previousSource the source of the previous event to be potentially reused partially
430     */
431    private void correctEventSource(Event event, Source previousSource) {
432        String toStringValue = null;
433       
434        if ((event.source != null) && (event.source.params != null)) {
435            toStringValue = getToStringParam(event.source);
436        }
437       
438        Source existingSource = null;
439       
440        if (toStringValue != null) {
441            existingSource = findSource(toStringValue);
442        }
443       
444        if (existingSource != null) {
445            event.source = existingSource;
446        }
447        else {
448            if (previousSource != null) {
449                for (String[] parameterOfPreviousSource : previousSource.params) {
450                    boolean foundParameter = false;
451                    for (String[] parameter : event.source.params) {
452                        if (parameter[0].equals(parameterOfPreviousSource[0])) {
453                            foundParameter = true;
454                            break;
455                        }
456                    }
457
458                    if (!foundParameter) {
459                        event.source.params.add(parameterOfPreviousSource);
460                    }
461                }
462   
463                for (Component component : previousSource.components) {
464                    if (!(component instanceof ComponentFromToString)) {
465                        event.source.components.add(component);
466                    }
467                }
468            }
469
470            event.source.components.add(getComponentFromToString(toStringValue));
471        }
472    }
473
474    /**
475     * <p>
476     * retrieves the value of the <code>toString</code> parameter of the provided source.
477     * </p>
478     *
479     * @param source the source to read the parameter of
480     * @return the value of the parameter
481     */
482    private String getToStringParam(Source source) {
483        String value = null;
484       
485        for (String[] param : source.params) {
486            if (("toString".equals(param[0])) && (param[1] != null) && (!"".equals(param[1]))) {
487                value = param[1];
488                break;
489            }
490        }
491       
492        return value;
493    }
494
495    /**
496     * <p>
497     * determines a component based on the <code>toString</code> parameter of a source.
498     * For this, it parses the parameter value and tries to determine several infos such as the
499     * type and the name of it. The resulting components are not always distinguishable. This is,
500     * because the <code>toString</code> parameter does not contain sufficient information for
501     * correct identification.
502     * </p>
503     *
504     * @param toStringValue the <code>toString</code> parameter of a source
505     *
506     * @return the component parsed from the <code>toString</code> parameter
507     */
508    private Component getComponentFromToString(String toStringValue) {
509        ComponentFromToString component = new ComponentFromToString();
510       
511        // search for the beginning of the parameters section. Up to this position we find the class
512        int start = toStringValue.indexOf('[');
513        String clazz = toStringValue.substring(0, start);
514       
515        // the first parameters are x and y coordinate as well as the size. The size is one
516        // parameter, where with and height are separated with an 'x'
517        start = toStringValue.indexOf(',', start) + 1;
518        int end = toStringValue.indexOf(',', start);
519       
520        component.x = Integer.parseInt(toStringValue.substring(start, end));
521       
522        start = end + 1;
523        end = toStringValue.indexOf(',', start);
524
525        component.y = Integer.parseInt(toStringValue.substring(start, end));
526
527        start = end + 1;
528        end = toStringValue.indexOf('x', start);
529
530        component.width = Integer.parseInt(toStringValue.substring(start, end));
531
532        start = end + 1;
533        end = toStringValue.indexOf(',', start);
534
535        component.height = Integer.parseInt(toStringValue.substring(start, end));
536
537        // no start parsing the rest of the parameters and extract those having a key and a
538        // value and whose key is text, defaultIcon, or an alignment
539        int intermediate;
540        start = end + 1;
541
542        String title = null;
543        String icon = null;
544        String alignment = null;
545       
546        do {
547            end = toStringValue.indexOf(',', start);
548            intermediate = toStringValue.indexOf('[', start);
549           
550            if ((intermediate >= 0) && (intermediate < end)) {
551                // the value of the parameter itself contains brackets. So try to determine the
552                // real end of the parameter
553                end = toStringValue.indexOf(']', intermediate);
554                end = toStringValue.indexOf(',', end);
555            }
556           
557            if (end < 0) {
558                //we reached the end of the stream. So the the end to the "end"
559                end = toStringValue.lastIndexOf(']');
560            }
561           
562            intermediate = toStringValue.indexOf('=', start);
563           
564            if ((intermediate >= 0) && (intermediate < end)) {
565                // this is a key value pair, so store the the parameter
566                String key = toStringValue.substring(start, intermediate);
567                String value = toStringValue.substring(intermediate + 1, end);
568               
569                if ("text".equals(key)) {
570                    title = value;
571                }
572                else if ("defaultIcon".equals(key)) {
573                    icon = value;
574                }
575                else if ("alignmentX".equals(key) || "alignmentY".equals(key)) {
576                    if (alignment == null) {
577                        alignment = value;
578                    }
579                    else {
580                        alignment += "/" + value;
581                    }
582                }
583            }
584            /*else {
585                // this is a simple value, for now simply ignore it
586                String key = toStringValue.substring(start, end);
587                if (!"invalid".equals(key)) {
588                    componentHash += key.hashCode();
589                    component.params.add(new String[] { key, "true" });
590                }
591            }*/
592           
593            start = end + 1;
594        }
595        while (start < toStringValue.lastIndexOf(']'));
596       
597        // finish the component specification by setting the parameters
598        if ((title == null) || "".equals(title) || "null".equals(title)) {
599            if ((icon == null) || "".equals(icon) || "null".equals(icon)) {
600                title = clazz.substring(clazz.lastIndexOf('.') + 1) + "(";
601               
602                // to be able to distinguish some elements, that usually have no name and icon, try
603                // to include some of their specific identifying information in their name.
604                if ("org.tigris.gef.presentation.FigTextEditor".equals(clazz) ||
605                    "org.argouml.core.propertypanels.ui.UMLTextField".equals(clazz))
606                {
607                    title += "height " + component.height + ", ";
608                }
609                else if ("org.argouml.core.propertypanels.ui.UMLLinkedList".equals(clazz) ||
610                         "org.argouml.core.propertypanels.ui.LabelledComponent".equals(clazz))
611                {
612                    title += "position " + component.x + "/" + component.y + ", ";
613                }
614               
615                title += "alignment " + alignment + ")";
616            }
617            else {
618                title = icon;
619            }
620        }
621       
622        component.params.add(new String[] { "title", title } );
623        component.params.add(new String[] { "class", clazz } );
624        component.params.add(new String[] { "icon", icon } );
625        component.params.add(new String[] { "index", "-1" });
626       
627        int hashCode = clazz.hashCode() + title.hashCode();
628       
629        if (hashCode < 0) {
630            hashCode = -hashCode;
631        }
632       
633        component.params.add(new String[] { "hash", Integer.toString(hashCode, 16) });
634
635        return component;
636    }   
637
638    /**
639     * <p>
640     * used to dump a list of parameters to the provided print stream
641     * </p>
642     */
643    private void dumpParams(PrintStream out, List<String[]> params, String indent) {
644        for (String[] param : params) {
645            out.print(indent);
646            out.print("<param name=\"");
647            out.print(StringTools.xmlEntityReplacement(param[0]));
648            out.print("\" value=\"");
649            out.print(StringTools.xmlEntityReplacement(param[1]));
650            out.println("\" />");
651        }
652       
653    }
654   
655    /**
656     * <p>
657     * used to carry all events of a session and to dump it to the output file
658     * </p>
659     */
660    private class Session {
661        private String type;
662        private List<Event> events = new ArrayList<Event>();
663       
664        public void dump(PrintStream out) {
665            out.print("<");
666            out.print(type);
667            out.println(">");
668
669            for (Event event : events) {
670                event.dump(out);
671            }
672           
673            out.print("</");
674            out.print(type);
675            out.println(">");
676        }
677    }
678
679    /**
680     * <p>
681     * used to carry all information about an event and to dump it to the output file
682     * </p>
683     */
684    private class Event {
685        private String id;
686        private List<String[]> params = new ArrayList<String[]>();
687        private Source source;
688       
689        private void dump(PrintStream out) {
690            out.print("<event id=\"");
691            out.print(StringTools.xmlEntityReplacement(id));
692            out.println("\">");
693           
694            dumpParams(out, params, " ");
695            source.dump(out);
696           
697            out.println("</event>");
698        }
699    }
700
701    /**
702     * <p>
703     * used to carry all information about a source of an event and to dump it to the output file
704     * </p>
705     */
706    private class Source {
707        private List<String[]> params = new ArrayList<String[]>();
708        private List<Component> components = new ArrayList<Component>();
709
710        private void dump(PrintStream out) {
711            out.println(" <source>");
712           
713            dumpParams(out, params, "  ");
714           
715            for (Component component : components) {
716                component.dump(out);
717            }
718           
719            out.println(" </source>");
720        }
721    }
722   
723    /**
724     * <p>
725     * used to carry all information about a component of a source and to dump it to the output file
726     * </p>
727     */
728    private class Component {
729        protected List<String[]> params = new ArrayList<String[]>();
730       
731        protected void dump(PrintStream out) {
732            out.println("  <component>");
733            dumpParams(out, params, "   ");
734            out.println("  </component>");
735        }
736
737        public boolean equals(Object other) {
738            if (this == other) {
739                return true;
740            }
741           
742            if (!(other instanceof Component)) {
743                return false;
744            }
745           
746            Component otherComp = (Component) other;
747           
748            boolean allParamsEqual = (params.size() == otherComp.params.size());
749           
750            if (allParamsEqual) {
751                for (int i = 0; i < params.size(); i++) {
752                    if (!params.get(i)[0].equals(otherComp.params.get(i)[0]) &&
753                        !params.get(i)[1].equals(otherComp.params.get(i)[1]))
754                    {
755                        allParamsEqual = false;
756                        break;
757                    }
758                }
759            }
760           
761            return allParamsEqual;
762        }
763    }
764
765    /**
766     * <p>
767     * represents a specific component, which was read from the toString parameter of a source
768     * </p>
769     */
770    private class ComponentFromToString extends Component {
771        private int x;
772        private int y;
773        private int width;
774        private int height;
775       
776        @Override
777        protected void dump(PrintStream out) {
778            out.println("  <component>");
779           
780            out.print("   ");
781            out.print("<param name=\"x\" value=\"");
782            out.print(x);
783            out.println("\" />");
784
785            out.print("   ");
786            out.print("<param name=\"y\" value=\"");
787            out.print(y);
788            out.println("\" />");
789
790            out.print("   ");
791            out.print("<param name=\"width\" value=\"");
792            out.print(width);
793            out.println("\" />");
794
795            out.print("   ");
796            out.print("<param name=\"height\" value=\"");
797            out.print(height);
798            out.println("\" />");
799
800            dumpParams(out, params, "   ");
801            out.println("  </component>");
802        }
803       
804        /*public boolean equals(Object other) {
805            if (this == other) {
806                return true;
807            }
808           
809            if (!(other instanceof ComponentFromToString)) {
810                return false;
811            }
812           
813            ComponentFromToString otherComp = (ComponentFromToString) other;
814           
815            // ignore the coordinates and the width as it may change over time
816            boolean allParamsEqual =
817                (height == otherComp.height) && (params.size() == otherComp.params.size());
818           
819            if (allParamsEqual) {
820                for (int i = 0; i < params.size(); i++) {
821                    if (!"x".equals(params.get(i)[0]) && !"y".equals(params.get(i)[0]) &&
822                        !"width".equals(params.get(i)[0]) && !"height".equals(params.get(i)[0]) &&
823                        !"index".equals(params.get(i)[0]) && !"hash".equals(params.get(i)[0]) &&
824                        !params.get(i)[0].equals(otherComp.params.get(i)[0]) &&
825                        !params.get(i)[1].equals(otherComp.params.get(i)[1]))
826                    {
827                        allParamsEqual = false;
828                        break;
829                    }
830                }
831            }
832           
833            return allParamsEqual;
834        }*/
835    }
836
837}
Note: See TracBrowser for help on using the repository browser.