source: AnnotationManagerN4J/src/main/java/de/mpiwg/itgroup/annotations/neo4j/AnnotationStore.java @ 22:b1fb0d117877

Last change on this file since 22:b1fb0d117877 was 22:b1fb0d117877, checked in by casties, 12 years ago

adding and listing groups via html works now.
no editing of group membership yet.
no authentication yet.

File size: 25.5 KB
Line 
1/**
2 *
3 */
4package de.mpiwg.itgroup.annotations.neo4j;
5
6import java.util.ArrayList;
7import java.util.Calendar;
8import java.util.HashSet;
9import java.util.List;
10import java.util.Set;
11
12import org.apache.log4j.Logger;
13import org.neo4j.graphdb.Direction;
14import org.neo4j.graphdb.GraphDatabaseService;
15import org.neo4j.graphdb.Node;
16import org.neo4j.graphdb.Relationship;
17import org.neo4j.graphdb.RelationshipType;
18import org.neo4j.graphdb.Transaction;
19import org.neo4j.graphdb.index.Index;
20import org.neo4j.graphdb.index.IndexHits;
21
22import de.mpiwg.itgroup.annotations.Actor;
23import de.mpiwg.itgroup.annotations.Annotation;
24import de.mpiwg.itgroup.annotations.Annotation.FragmentTypes;
25import de.mpiwg.itgroup.annotations.Group;
26import de.mpiwg.itgroup.annotations.Person;
27
28/**
29 * @author casties
30 *
31 */
32public class AnnotationStore {
33
34    protected static Logger logger = Logger.getLogger(AnnotationStore.class);
35
36    protected GraphDatabaseService graphDb;
37
38    public static enum NodeTypes {
39        ANNOTATION, PERSON, TARGET, GROUP, TAG
40    }
41
42    protected List<Index<Node>> nodeIndexes;
43
44    public static enum RelationTypes implements RelationshipType {
45        ANNOTATES, CREATED, PERMITS_ADMIN, PERMITS_DELETE, PERMITS_UPDATE, PERMITS_READ, MEMBER_OF, HAS_TAG
46    }
47
48    public static String ANNOTATION_URI_BASE = "http://entities.mpiwg-berlin.mpg.de/annotations/";
49
50    public AnnotationStore(GraphDatabaseService graphDb) {
51        super();
52        this.graphDb = graphDb;
53        nodeIndexes = new ArrayList<Index<Node>>(5);
54        // List.set(enum.ordinal(), val) seems not to work.
55        nodeIndexes.add(NodeTypes.ANNOTATION.ordinal(), graphDb.index().forNodes("annotations"));
56        nodeIndexes.add(NodeTypes.PERSON.ordinal(), graphDb.index().forNodes("persons"));
57        nodeIndexes.add(NodeTypes.TARGET.ordinal(), graphDb.index().forNodes("targets"));
58        nodeIndexes.add(NodeTypes.GROUP.ordinal(), graphDb.index().forNodes("groups"));
59        nodeIndexes.add(NodeTypes.TAG.ordinal(), graphDb.index().forNodes("tags"));
60    }
61
62    protected Index<Node> getNodeIndex(NodeTypes type) {
63        return nodeIndexes.get(type.ordinal());
64    }
65
66    /**
67     * @param userUri
68     * @return
69     */
70    public Node getPersonNodeByUri(String userUri) {
71        if (userUri == null) return null;
72        Node person = getNodeIndex(NodeTypes.PERSON).get("uri", userUri).getSingle();
73        return person;
74    }
75
76    /**
77     * Returns List of Groups.
78     * Key has to be indexed.
79     *
80     * @param key
81     * @param query
82     * @return
83     */
84    public List<Group> getGroups(String key, String query) {
85        ArrayList<Group> groups = new ArrayList<Group>();
86        Index<Node> idx = getNodeIndex(NodeTypes.GROUP);
87        if (key == null) {
88            key = "uri";
89            query = "*";
90        }
91        IndexHits<Node> groupNodes = idx.query(key, query);
92        for (Node groupNode : groupNodes) {
93            Actor group = createActorFromNode(groupNode);
94            groups.add((Group) group);
95        }
96        return groups;
97    }
98
99    /**
100     * Returns List of Groups the person is member of.
101     *
102     * @param person
103     * @return
104     */
105    public List<Group> getGroupsForPersonNode(Node person) {
106        ArrayList<Group> groups = new ArrayList<Group>();
107        Iterable<Relationship> rels = person.getRelationships(RelationTypes.MEMBER_OF);
108        for (Relationship rel : rels) {
109            Node groupNode = rel.getEndNode();
110            Actor group = createActorFromNode(groupNode);
111            // make sure we're getting a group
112            if (!(group instanceof Group)) {
113                logger.error("target of MEMBER_OF is not GROUP! rel=" + rel);
114                continue;
115            }
116            groups.add((Group) group);
117        }
118        return groups;
119    }
120
121    /**
122     * Returns if person with uri is in Group group.
123     *
124     * @param person
125     * @param group
126     * @return
127     */
128    public boolean isPersonInGroup(Person person, Group group) {
129        Node pn = getPersonNodeByUri(person.getUriString());
130        if (pn == null) return false;
131        // optimized version of getGroupsForPersonNode
132        Iterable<Relationship> rels = pn.getRelationships(RelationTypes.MEMBER_OF);
133        for (Relationship rel : rels) {
134            Node gn = rel.getEndNode();
135            if (gn.getProperty("uri", "").equals(group.getUriString()) || gn.getProperty("id", "").equals(group.getId())) {
136                return true;
137            }
138        }
139        return false;
140    }
141
142    /**
143     * Returns the members of the group.
144     *
145     * @param group
146     * @return
147     */
148    public List<Person> getMembersOfGroup(Group group) {
149        ArrayList<Person> members = new ArrayList<Person>();
150        Node gn = getActorNode(group);
151        Iterable<Relationship> rels = gn.getRelationships(RelationTypes.MEMBER_OF);
152        for (Relationship rel : rels) {
153            Node memberNode = rel.getStartNode();
154            Actor member = createActorFromNode(memberNode);
155            // make sure we're getting a group
156            if (!(member instanceof Person)) {
157                logger.error("source of MEMBER_OF is not PERSON! rel=" + rel);
158                continue;
159            }
160            members.add((Person) member);
161        }
162        return members;
163    }
164   
165    /**
166     * Returns the stored Actor matching the given one.
167     *
168     * @param actor
169     * @return
170     */
171    public Actor getActor(Actor actor) {
172        Node actorNode = getActorNode(actor);
173        Actor storedActor = createActorFromNode(actorNode);
174        return storedActor;
175    }
176   
177    /**
178     * Stores an Actor (Person or Group). Creates a new actor Node or returns an existing one.
179     *
180     * @param actor
181     * @return
182     */
183    public Actor storeActor(Actor actor) {
184        Node actorNode = getOrCreateActorNode(actor);
185        Actor storedActor = createActorFromNode(actorNode);
186        return storedActor;
187    }
188   
189    /**
190     * Returns the Annotation with the given id.
191     *
192     * @param id
193     * @return
194     */
195    public Annotation getAnnotationById(String id) {
196        Node annotNode = getNodeIndex(NodeTypes.ANNOTATION).get("id", id).getSingle();
197        Annotation annot = createAnnotationFromNode(annotNode);
198        return annot;
199    }
200
201    /**
202     * Returns an Annotation object from an annotation-Node.
203     *
204     * @param annotNode
205     * @return
206     */
207    public Annotation createAnnotationFromNode(Node annotNode) {
208        Annotation annot = new Annotation();
209        annot.setUri((String) annotNode.getProperty("id", null));
210        annot.setBodyText((String) annotNode.getProperty("bodyText", null));
211        annot.setBodyUri((String) annotNode.getProperty("bodyUri", null));
212        /*
213         * get annotation target from relation
214         */
215        Relationship targetRel = getRelation(annotNode, RelationTypes.ANNOTATES, null);
216        if (targetRel != null) {
217            Node target = targetRel.getEndNode();
218            annot.setTargetBaseUri((String) target.getProperty("uri", null));
219        } else {
220            logger.error("annotation " + annotNode + " has no target node!");
221        }
222        annot.setTargetFragment((String) annotNode.getProperty("targetFragment", null));
223        String ft = (String) annotNode.getProperty("fragmentType", null);
224        if (ft != null) {
225            annot.setFragmentType(FragmentTypes.valueOf(ft));
226        }
227        /*
228         * get creator from relation
229         */
230        Relationship creatorRel = getRelation(annotNode, RelationTypes.CREATED, null);
231        if (creatorRel != null) {
232            Node creatorNode = creatorRel.getStartNode();
233            Actor creator = createActorFromNode(creatorNode);
234            annot.setCreator(creator);
235        } else {
236            logger.error("annotation " + annotNode + " has no creator node!");
237        }
238        /*
239         * get creation date
240         */
241        annot.setCreated((String) annotNode.getProperty("created", null));
242        /*
243         * get permissions
244         */
245        Relationship adminRel = getRelation(annotNode, RelationTypes.PERMITS_ADMIN, null);
246        if (adminRel != null) {
247            Node adminNode = adminRel.getEndNode();
248            Actor admin = createActorFromNode(adminNode);
249            annot.setAdminPermission(admin);
250        }
251        Relationship deleteRel = getRelation(annotNode, RelationTypes.PERMITS_DELETE, null);
252        if (deleteRel != null) {
253            Node deleteNode = deleteRel.getEndNode();
254            Actor delete = createActorFromNode(deleteNode);
255            annot.setDeletePermission(delete);
256        }
257        Relationship updateRel = getRelation(annotNode, RelationTypes.PERMITS_UPDATE, null);
258        if (updateRel != null) {
259            Node updateNode = updateRel.getEndNode();
260            Actor update = createActorFromNode(updateNode);
261            annot.setUpdatePermission(update);
262        }
263        Relationship readRel = getRelation(annotNode, RelationTypes.PERMITS_READ, null);
264        if (readRel != null) {
265            Node readNode = readRel.getEndNode();
266            Actor read = createActorFromNode(readNode);
267            annot.setReadPermission(read);
268        }
269        /*
270         * get tags
271         */
272        Set<String> tags = new HashSet<String>();
273        for (Relationship rel : annotNode.getRelationships(RelationTypes.HAS_TAG)) {
274            String tag = (String) rel.getEndNode().getProperty("name", null);
275            if (tag != null) {
276                tags.add(tag);
277            }
278        }
279        annot.setTags(tags);
280
281        return annot;
282    }
283
284    /**
285     * Returns an Actor object from a node.
286     *
287     * @param actorNode
288     * @return
289     */
290    protected Actor createActorFromNode(Node actorNode) {
291        String id = (String) actorNode.getProperty("id", null);
292        String uri = (String) actorNode.getProperty("uri", null);
293        String name = (String) actorNode.getProperty("name", null);
294        String type = (String) actorNode.getProperty("TYPE", null);
295        if (type != null && type.equals("PERSON")) {
296            return new Person(id, uri, name);
297        } else if (type != null && type.equals("GROUP")) {
298            return new Group(id, uri, name);
299        }
300        return null;
301    }
302
303    /**
304     * Store a new annotation in the store or update an existing one. Returns
305     * the stored annotation.
306     *
307     * @param annot
308     * @return
309     */
310    public Annotation storeAnnotation(Annotation annot) {
311        Node annotNode = null;
312        Transaction tx = graphDb.beginTx();
313        try {
314            /*
315             * create or get the annotation
316             */
317            String id = annot.getUri();
318            if (id == null) {
319                id = createRessourceURI("annot:");
320            }
321            annotNode = getOrCreateAnnotationNode(id);
322
323            /*
324             * the annotation body
325             */
326            String bodyText = annot.getBodyText();
327            if (bodyText != null) {
328                annotNode.setProperty("bodyText", bodyText);
329            }
330            String bodyUri = annot.getBodyUri();
331            if (bodyUri != null) {
332                annotNode.setProperty("bodyUri", bodyUri);
333            }
334
335            /*
336             * the annotation target
337             */
338            String targetBaseUri = annot.getTargetBaseUri();
339            if (targetBaseUri != null) {
340                Node target = getOrCreateTargetNode(targetBaseUri);
341                getOrCreateRelation(annotNode, RelationTypes.ANNOTATES, target);
342            }
343
344            /*
345             * The fragment part of the annotation target.
346             */
347            String targetFragment = annot.getTargetFragment();
348            FragmentTypes fragmentType = annot.getFragmentType();
349            if (targetFragment != null) {
350                annotNode.setProperty("targetFragment", targetFragment);
351                annotNode.setProperty("fragmentType", fragmentType.name());
352            }
353
354            /*
355             * The creator of this annotation.
356             */
357            Actor creator = annot.getCreator();
358            if (creator != null) {
359                Node creatorNode = getOrCreateActorNode(creator);
360                getOrCreateRelation(creatorNode, RelationTypes.CREATED, annotNode);
361            }
362
363            /*
364             * The creation date of this annotation.
365             */
366            String created = annot.getCreated();
367            if (created != null) {
368                annotNode.setProperty("created", created);
369            }
370
371            /*
372             * Permissions for this annotation.
373             */
374            setPermissionRelation(annotNode, RelationTypes.PERMITS_ADMIN, annot.getAdminPermission());
375            setPermissionRelation(annotNode, RelationTypes.PERMITS_DELETE, annot.getDeletePermission());
376            setPermissionRelation(annotNode, RelationTypes.PERMITS_UPDATE, annot.getUpdatePermission());
377            setPermissionRelation(annotNode, RelationTypes.PERMITS_READ, annot.getReadPermission());
378
379            /*
380             * Tags on this annotation.
381             */
382            Set<String> newTags = annot.getTags();
383            // we ignore existing tags if tags == null
384            if (newTags != null) {
385                List<Relationship> oldHasTags = new ArrayList<Relationship>();
386                for (Relationship rel : annotNode.getRelationships(RelationTypes.HAS_TAG)) {
387                    oldHasTags.add(rel);
388                }
389                // adjust to new tags
390                if (newTags.isEmpty()) {
391                    // remove old tags
392                    if (!oldHasTags.isEmpty()) {
393                        for (Relationship rel : oldHasTags) {
394                            rel.delete();
395                            // TODO: should we delete orphan nodes too?
396                        }
397                    }
398                } else {
399                    if (!oldHasTags.isEmpty()) {
400                        // adjust old tags
401                        for (Relationship rel : oldHasTags) {
402                            String oldTag = (String) rel.getEndNode().getProperty("name", null);
403                            if (newTags.contains(oldTag)) {
404                                // tag exists
405                                newTags.remove(oldTag);
406                            } else {
407                                // tag exists no longer
408                                rel.delete();
409                                // TODO: should we delete orphan nodes too?
410                            }
411                        }
412                    }
413                    if (!newTags.isEmpty()) {
414                        // still tags to add
415                        for (String tag : newTags) {
416                            // create new tag
417                            Node tagNode = getOrCreateTagNode(tag);
418                            getOrCreateRelation(annotNode, RelationTypes.HAS_TAG, tagNode);
419                        }
420                    }
421
422                }
423            }
424            tx.success();
425        } finally {
426            tx.finish();
427        }
428
429        // re-read and return annotation
430        Annotation storedAnnot = createAnnotationFromNode(annotNode);
431        return storedAnnot;
432    }
433
434    /**
435     * Deletes the annotation with the given id.
436     *
437     * @param id
438     */
439    public void deleteById(String id) {
440        Node annotNode = getNodeIndex(NodeTypes.ANNOTATION).get("id", id).getSingle();
441        if (annotNode != null) {
442            // delete related objects
443            Transaction tx = graphDb.beginTx();
444            try {
445                for (Relationship rel : annotNode.getRelationships()) {
446                    // delete relation and the related node if it has no other
447                    // relations
448                    Node other = rel.getOtherNode(annotNode);
449                    rel.delete();
450                    if (!other.hasRelationship()) {
451                        deleteNode(other);
452                    }
453                }
454                if (!annotNode.hasRelationship()) {
455                    deleteNode(annotNode);
456                } else {
457                    logger.error("deleteById: unable to delete: Node still has relations.");
458                }
459                tx.success();
460            } finally {
461                tx.finish();
462            }
463        }
464    }
465
466    /**
467     * Returns all annotations with the given uri and/or user.
468     *
469     * @param uri
470     * @param userUri
471     * @param limit
472     * @param offset
473     * @return
474     */
475    public List<Annotation> searchByUriUser(String targetUri, String userUri, String limit, String offset) {
476        List<Annotation> annotations = new ArrayList<Annotation>();
477        if (targetUri != null) {
478            // there should be only one
479            Node target = getNodeIndex(NodeTypes.TARGET).get("uri", targetUri).getSingle();
480            if (target != null) {
481                Iterable<Relationship> relations = target.getRelationships(RelationTypes.ANNOTATES);
482                for (Relationship relation : relations) {
483                    Node ann = relation.getStartNode();
484                    if (ann.getProperty("TYPE", "").equals("ANNOTATION")) {
485                        Annotation annot = createAnnotationFromNode(ann);
486                        annotations.add(annot);
487                    } else {
488                        logger.error("ANNOTATES relation does not start with ANNOTATION: " + ann);
489                    }
490                }
491            }
492        }
493        if (userUri != null) {
494            // there should be only one
495            Node person = getPersonNodeByUri(userUri);
496            if (person != null) {
497                Iterable<Relationship> relations = person.getRelationships(RelationTypes.CREATED);
498                for (Relationship relation : relations) {
499                    Node ann = relation.getEndNode();
500                    if (ann.getProperty("TYPE", "").equals("ANNOTATION")) {
501                        Annotation annot = createAnnotationFromNode(ann);
502                        annotations.add(annot);
503                    } else {
504                        logger.error("CREATED relation does not end with ANNOTATION: " + ann);
505                    }
506                }
507            }
508        }
509        // TODO: if both uri and user are given we should intersect
510        return annotations;
511    }
512
513    /**
514     * Returns Relationship of type from Node start to Node end. Creates one if
515     * it doesn't exist.
516     *
517     * @param start
518     * @param type
519     * @param end
520     * @return
521     */
522    protected Relationship getOrCreateRelation(Node start, RelationshipType type, Node end) {
523        if (start.hasRelationship()) {
524            // there are relations
525            Iterable<Relationship> rels = start.getRelationships(type, Direction.OUTGOING);
526            for (Relationship rel : rels) {
527                if (rel.getEndNode().equals(end)) {
528                    // relation exists
529                    return rel;
530                }
531            }
532        }
533        // create new one
534        Relationship rel;
535        Transaction tx = graphDb.beginTx();
536        try {
537            rel = start.createRelationshipTo(end, type);
538            tx.success();
539        } finally {
540            tx.finish();
541        }
542        return rel;
543    }
544
545    protected Node getOrCreateAnnotationNode(String id) {
546        Index<Node> idx = getNodeIndex(NodeTypes.ANNOTATION);
547        IndexHits<Node> annotations = idx.get("id", id);
548        Node annotation = annotations.getSingle();
549        if (annotation == null) {
550            // does not exist yet
551            Transaction tx = graphDb.beginTx();
552            try {
553                annotation = graphDb.createNode();
554                annotation.setProperty("TYPE", NodeTypes.ANNOTATION.name());
555                annotation.setProperty("id", id);
556                idx.add(annotation, "id", id);
557                tx.success();
558            } finally {
559                tx.finish();
560            }
561        }
562        return annotation;
563    }
564
565    protected Node getOrCreateTargetNode(String uri) {
566        Index<Node> idx = getNodeIndex(NodeTypes.TARGET);
567        IndexHits<Node> targets = idx.get("uri", uri);
568        Node target = targets.getSingle();
569        if (target == null) {
570            // does not exist yet
571            Transaction tx = graphDb.beginTx();
572            try {
573                target = graphDb.createNode();
574                target.setProperty("TYPE", NodeTypes.TARGET.name());
575                target.setProperty("uri", uri);
576                idx.add(target, "uri", uri);
577                tx.success();
578            } finally {
579                tx.finish();
580            }
581        }
582        return target;
583    }
584
585    protected Node getActorNode(Actor actor) {
586        // Person/Group is identified by URI or id
587        String uri = actor.getUriString();
588        Index<Node> idx;
589        if (actor.isGroup()) {
590            idx = getNodeIndex(NodeTypes.GROUP);
591        } else {
592            idx = getNodeIndex(NodeTypes.PERSON);
593        }
594        IndexHits<Node> persons = idx.get("uri", uri);
595        Node person = persons.getSingle();
596        return person;
597    }
598   
599    protected Node getOrCreateActorNode(Actor actor) {
600        // Person/Group is identified by URI or id
601        String uri = actor.getUriString();
602        String name = actor.getName();
603        String id = actor.getId();
604        Index<Node> idx;
605        if (actor.isGroup()) {
606            idx = getNodeIndex(NodeTypes.GROUP);
607        } else {
608            idx = getNodeIndex(NodeTypes.PERSON);
609        }
610        IndexHits<Node> persons = idx.get("uri", uri);
611        Node person = persons.getSingle();
612        if (person == null) {
613            // does not exist yet
614            Transaction tx = graphDb.beginTx();
615            try {
616                person = graphDb.createNode();
617                if (actor.isGroup()) {
618                    person.setProperty("TYPE", NodeTypes.GROUP.name());
619                } else {
620                    person.setProperty("TYPE", NodeTypes.PERSON.name());
621                }
622                person.setProperty("uri", uri);
623                idx.add(person, "uri", uri);
624                if (name != null) {
625                    person.setProperty("name", name);
626                }
627                if (id != null) {
628                    person.setProperty("id", id);
629                }
630                tx.success();
631            } finally {
632                tx.finish();
633            }
634        }
635        return person;
636    }
637
638    protected Node getOrCreateTagNode(String tagname) {
639        Index<Node> idx = getNodeIndex(NodeTypes.TAG);
640        IndexHits<Node> tags = idx.get("name", tagname);
641        Node tag = tags.getSingle();
642        if (tag == null) {
643            // does not exist yet
644            Transaction tx = graphDb.beginTx();
645            try {
646                tag = graphDb.createNode();
647                tag.setProperty("TYPE", NodeTypes.TAG.name());
648                tag.setProperty("name", tagname);
649                idx.add(tag, "name", tagname);
650                tx.success();
651            } finally {
652                tx.finish();
653            }
654        }
655        return tag;
656    }
657
658    /**
659     * Create or update permissions relations for an annotation.
660     *
661     * @param annotNode
662     * @param type
663     * @param annot
664     */
665    protected void setPermissionRelation(Node annotNode, RelationTypes type, Actor actor) {
666        Node newActorNode = null;
667        if (actor != null) {
668            newActorNode = getOrCreateActorNode(actor);
669        }
670        Relationship rel = getRelation(annotNode, type, null);
671        if (rel != null) {
672            // relation exists
673            Node oldActorNode = rel.getEndNode();
674            if (!oldActorNode.equals(newActorNode)) {
675                // new admin is different
676                rel.delete();
677                if (newActorNode != null) {
678                    rel = getOrCreateRelation(annotNode, type, newActorNode);
679                }
680            }
681        } else {
682            // no relation yet
683            if (newActorNode != null) {
684                rel = getOrCreateRelation(annotNode, type, newActorNode);
685            }
686        }
687    }
688
689    /**
690     * Unindexes and deletes given Node if it has no relations.
691     *
692     * @param node
693     */
694    protected void deleteNode(Node node) {
695        Transaction tx = graphDb.beginTx();
696        try {
697            if (node.hasRelationship()) {
698                logger.error("deleteNode: unable to delete: Node still has relations.");
699            } else {
700                String ts = (String) node.getProperty("TYPE", null);
701                try {
702                    NodeTypes type = NodeTypes.valueOf(ts);
703                    getNodeIndex(type).remove(node);
704                } catch (Exception e) {
705                    logger.error("deleteNode: unable to get TYPE of node: " + node);
706                }
707                node.delete();
708            }
709            tx.success();
710        } finally {
711            tx.finish();
712        }
713    }
714
715    /**
716     * returns the (first) Relationship of RelationTypes type from Node start.
717     *
718     * @param start
719     * @param type
720     * @param direction
721     * @return
722     */
723    protected Relationship getRelation(Node start, RelationTypes type, Direction direction) {
724        Iterable<Relationship> rels;
725        if (direction == null) {
726            // ignore direction
727            rels = start.getRelationships(type);
728        } else {
729            rels = start.getRelationships(type, direction);
730        }
731        for (Relationship rel : rels) {
732            // just the first one
733            return rel;
734        }
735        return null;
736    }
737
738    /**
739     * Erzeuge eine urn aus der aktuellen Zeit in millis
740     *
741     * @return
742     */
743    private String createRessourceURI(String prefix) {
744
745        Calendar cal = Calendar.getInstance();
746
747        long time = cal.getTimeInMillis();
748
749        return String.format("%s%s%s", ANNOTATION_URI_BASE, prefix, time);
750
751    }
752
753}
Note: See TracBrowser for help on using the repository browser.