source: josm/trunk/src/org/openstreetmap/josm/actions/search/SearchCompiler.java@ 2962

Last change on this file since 2962 was 2962, checked in by bastiK, 14 years ago

fixed #4499 - Search is broken

  • Property svn:eol-style set to native
File size: 27.1 KB
Line 
1// License: GPL. Copyright 2007 by Immanuel Scholz and others
2package org.openstreetmap.josm.actions.search;
3
4import static org.openstreetmap.josm.tools.I18n.marktr;
5import static org.openstreetmap.josm.tools.I18n.tr;
6
7import java.io.PushbackReader;
8import java.io.StringReader;
9import java.util.regex.Matcher;
10import java.util.regex.Pattern;
11import java.util.regex.PatternSyntaxException;
12
13import org.openstreetmap.josm.Main;
14import org.openstreetmap.josm.actions.search.PushbackTokenizer.Token;
15import org.openstreetmap.josm.data.osm.Node;
16import org.openstreetmap.josm.data.osm.OsmPrimitive;
17import org.openstreetmap.josm.data.osm.OsmUtils;
18import org.openstreetmap.josm.data.osm.Relation;
19import org.openstreetmap.josm.data.osm.RelationMember;
20import org.openstreetmap.josm.data.osm.Way;
21import org.openstreetmap.josm.tools.DateUtils;
22
23/**
24 Implements a google-like search.
25 <br>
26 Grammar:
27<pre>
28expression =
29 fact | expression
30 fact expression
31 fact
32
33fact =
34 ( expression )
35 -fact
36 term?
37 term=term
38 term:term
39 term
40 </pre>
41
42 @author Imi
43 */
44public class SearchCompiler {
45
46 private boolean caseSensitive = false;
47 private boolean regexSearch = false;
48 private static String rxErrorMsg = marktr("The regex \"{0}\" had a parse error at offset {1}, full error:\n\n{2}");
49 private PushbackTokenizer tokenizer;
50
51 public SearchCompiler(boolean caseSensitive, boolean regexSearch, PushbackTokenizer tokenizer) {
52 this.caseSensitive = caseSensitive;
53 this.regexSearch = regexSearch;
54 this.tokenizer = tokenizer;
55 }
56
57 abstract public static class Match {
58 abstract public boolean match(OsmPrimitive osm);
59 }
60
61 public static class Always extends Match {
62 public static Always INSTANCE = new Always();
63 @Override public boolean match(OsmPrimitive osm) {
64 return true;
65 }
66 }
67
68 public static class Never extends Match {
69 @Override
70 public boolean match(OsmPrimitive osm) {
71 return false;
72 }
73 }
74
75 private static class Not extends Match {
76 private final Match match;
77 public Not(Match match) {this.match = match;}
78 @Override public boolean match(OsmPrimitive osm) {
79 return !match.match(osm);
80 }
81 @Override public String toString() {return "!"+match;}
82 }
83
84 private static class BooleanMatch extends Match {
85 private final String key;
86 private final boolean defaultValue;
87
88 public BooleanMatch(String key, boolean defaultValue) {
89 this.key = key;
90 this.defaultValue = defaultValue;
91 }
92 @Override
93 public boolean match(OsmPrimitive osm) {
94 Boolean ret = OsmUtils.getOsmBoolean(osm.get(key));
95 if (ret == null)
96 return defaultValue;
97 else
98 return ret;
99 }
100 }
101
102 private static class And extends Match {
103 private Match lhs;
104 private Match rhs;
105 public And(Match lhs, Match rhs) {this.lhs = lhs; this.rhs = rhs;}
106 @Override public boolean match(OsmPrimitive osm) {
107 return lhs.match(osm) && rhs.match(osm);
108 }
109 @Override public String toString() {return lhs+" && "+rhs;}
110 }
111
112 private static class Or extends Match {
113 private Match lhs;
114 private Match rhs;
115 public Or(Match lhs, Match rhs) {this.lhs = lhs; this.rhs = rhs;}
116 @Override public boolean match(OsmPrimitive osm) {
117 return lhs.match(osm) || rhs.match(osm);
118 }
119 @Override public String toString() {return lhs+" || "+rhs;}
120 }
121
122 private static class Id extends Match {
123 private long id;
124 public Id(long id) {this.id = id;}
125 @Override public boolean match(OsmPrimitive osm) {
126 return osm.getId() == id;
127 }
128 @Override public String toString() {return "id="+id;}
129 }
130
131 private static class ChangesetId extends Match {
132 private long changesetid;
133 public ChangesetId(long changesetid) {this.changesetid = changesetid;}
134 @Override public boolean match(OsmPrimitive osm) {
135 return osm.getChangesetId() == changesetid;
136 }
137 @Override public String toString() {return "changeset="+changesetid;}
138 }
139
140 private static class Version extends Match {
141 private long version;
142 public Version(long version) {this.version = version;}
143 @Override public boolean match(OsmPrimitive osm) {
144 return osm.getVersion() == version;
145 }
146 @Override public String toString() {return "version="+version;}
147 }
148
149 private static class KeyValue extends Match {
150 private final String key;
151 private final Pattern keyPattern;
152 private final String value;
153 private final Pattern valuePattern;
154 private final boolean caseSensitive;
155
156 public KeyValue(String key, String value, boolean regexSearch, boolean caseSensitive) throws ParseError {
157 this.caseSensitive = caseSensitive;
158 if (regexSearch) {
159 int searchFlags = regexFlags(caseSensitive);
160
161 try {
162 this.keyPattern = Pattern.compile(key, searchFlags);
163 this.valuePattern = Pattern.compile(value, searchFlags);
164 } catch (PatternSyntaxException e) {
165 throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()));
166 }
167 this.key = key;
168 this.value = value;
169
170 } else if (caseSensitive) {
171 this.key = key;
172 this.value = value;
173 this.keyPattern = null;
174 this.valuePattern = null;
175 } else {
176 this.key = key.toLowerCase();
177 this.value = value;
178 this.keyPattern = null;
179 this.valuePattern = null;
180 }
181 }
182
183 @Override public boolean match(OsmPrimitive osm) {
184
185 if (keyPattern != null) {
186 if (!osm.hasKeys())
187 return false;
188
189 /* The string search will just get a key like
190 * 'highway' and look that up as osm.get(key). But
191 * since we're doing a regex match we'll have to loop
192 * over all the keys to see if they match our regex,
193 * and only then try to match against the value
194 */
195
196 for (String k: osm.keySet()) {
197 String v = osm.get(k);
198
199 Matcher matcherKey = keyPattern.matcher(k);
200 boolean matchedKey = matcherKey.find();
201
202 if (matchedKey) {
203 Matcher matcherValue = valuePattern.matcher(v);
204 boolean matchedValue = matcherValue.find();
205
206 if (matchedValue)
207 return true;
208 }
209 }
210 } else {
211 String mv = null;
212
213 if (key.equals("timestamp")) {
214 mv = DateUtils.fromDate(osm.getTimestamp());
215 } else {
216 mv = osm.get(key);
217 }
218
219 if (mv == null)
220 return false;
221
222 String v1 = caseSensitive ? mv : mv.toLowerCase();
223 String v2 = caseSensitive ? value : value.toLowerCase();
224
225 // is not Java 1.5
226 //v1 = java.text.Normalizer.normalize(v1, java.text.Normalizer.Form.NFC);
227 //v2 = java.text.Normalizer.normalize(v2, java.text.Normalizer.Form.NFC);
228 return v1.indexOf(v2) != -1;
229 }
230
231 return false;
232 }
233 @Override public String toString() {return key+"="+value;}
234 }
235
236 public static class ExactKeyValue extends Match {
237
238 private enum Mode {
239 ANY, ANY_KEY, ANY_VALUE, EXACT, NONE, MISSING_KEY,
240 ANY_KEY_REGEXP, ANY_VALUE_REGEXP, EXACT_REGEXP, MISSING_KEY_REGEXP;
241 }
242
243 private final String key;
244 private final String value;
245 private final Pattern keyPattern;
246 private final Pattern valuePattern;
247 private final Mode mode;
248
249 public ExactKeyValue(boolean regexp, String key, String value) throws ParseError {
250 if (key == "")
251 throw new ParseError(tr("Key cannot be empty when tag operator is used. Sample use: key=value"));
252 this.key = key;
253 this.value = value == null?"":value;
254 if ("".equals(value) && "*".equals(key)) {
255 mode = Mode.NONE;
256 } else if ("".equals(value)) {
257 if (regexp) {
258 mode = Mode.MISSING_KEY_REGEXP;
259 } else {
260 mode = Mode.MISSING_KEY;
261 }
262 } else if ("*".equals(key) && "*".equals(value)) {
263 mode = Mode.ANY;
264 } else if ("*".equals(key)) {
265 if (regexp) {
266 mode = Mode.ANY_KEY_REGEXP;
267 } else {
268 mode = Mode.ANY_KEY;
269 }
270 } else if ("*".equals(value)) {
271 if (regexp) {
272 mode = Mode.ANY_VALUE_REGEXP;
273 } else {
274 mode = Mode.ANY_VALUE;
275 }
276 } else {
277 if (regexp) {
278 mode = Mode.EXACT_REGEXP;
279 } else {
280 mode = Mode.EXACT;
281 }
282 }
283
284 if (regexp && key.length() > 0 && !key.equals("*")) {
285 keyPattern = Pattern.compile(key);
286 } else {
287 keyPattern = null;
288 }
289 if (regexp && value.length() > 0 && !value.equals("*")) {
290 try {
291 valuePattern = Pattern.compile(value);
292 } catch (PatternSyntaxException e) {
293 throw new ParseError(tr("Pattern Syntax Error: Pattern {0} in {1} is illegal!", e.getPattern(), value));
294 }
295 } else {
296 valuePattern = null;
297 }
298 }
299
300 @Override
301 public boolean match(OsmPrimitive osm) {
302
303 if (!osm.hasKeys())
304 return mode == Mode.NONE;
305
306 switch (mode) {
307 case NONE:
308 return false;
309 case MISSING_KEY:
310 return osm.get(key) == null;
311 case ANY:
312 return true;
313 case ANY_VALUE:
314 return osm.get(key) != null;
315 case ANY_KEY:
316 for (String v:osm.getKeys().values()) {
317 if (v.equals(value))
318 return true;
319 }
320 return false;
321 case EXACT:
322 return value.equals(osm.get(key));
323 case ANY_KEY_REGEXP:
324 for (String v:osm.getKeys().values()) {
325 if (valuePattern.matcher(v).matches())
326 return true;
327 }
328 return false;
329 case ANY_VALUE_REGEXP:
330 case EXACT_REGEXP:
331 for (String key: osm.keySet()) {
332 if (keyPattern.matcher(key).matches()) {
333 if (mode == Mode.ANY_VALUE_REGEXP
334 || valuePattern.matcher(osm.get(key)).matches())
335 return true;
336 }
337 }
338 return false;
339 case MISSING_KEY_REGEXP:
340 for (String k:osm.keySet()) {
341 if (keyPattern.matcher(k).matches())
342 return false;
343 }
344 return true;
345 }
346 throw new AssertionError("Missed state");
347 }
348
349 @Override
350 public String toString() {
351 return key + '=' + value;
352 }
353
354 }
355
356 private static class Any extends Match {
357 private final String search;
358 private final Pattern searchRegex;
359 private final boolean caseSensitive;
360
361 public Any(String s, boolean regexSearch, boolean caseSensitive) throws ParseError {
362 this.caseSensitive = caseSensitive;
363 if (regexSearch) {
364 try {
365 this.searchRegex = Pattern.compile(s, regexFlags(caseSensitive));
366 } catch (PatternSyntaxException e) {
367 throw new ParseError(tr(rxErrorMsg, e.getPattern(), e.getIndex(), e.getMessage()));
368 }
369 this.search = s;
370 } else if (caseSensitive) {
371 this.search = s;
372 this.searchRegex = null;
373 } else {
374 this.search = s.toLowerCase();
375 this.searchRegex = null;
376 }
377 }
378
379 @Override public boolean match(OsmPrimitive osm) {
380 if (!osm.hasKeys())
381 return search.equals("");
382
383 // is not Java 1.5
384 //search = java.text.Normalizer.normalize(search, java.text.Normalizer.Form.NFC);
385 for (String key: osm.keySet()) {
386 String value = osm.get(key);
387 if (searchRegex != null) {
388
389 // is not Java 1.5
390 //value = java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFC);
391
392 Matcher keyMatcher = searchRegex.matcher(key);
393 Matcher valMatcher = searchRegex.matcher(value);
394
395 boolean keyMatchFound = keyMatcher.find();
396 boolean valMatchFound = valMatcher.find();
397
398 if (keyMatchFound || valMatchFound)
399 return true;
400 } else {
401 if (!caseSensitive) {
402 key = key.toLowerCase();
403 value = value.toLowerCase();
404 }
405
406 // is not Java 1.5
407 //value = java.text.Normalizer.normalize(value, java.text.Normalizer.Form.NFC);
408
409 if (key.indexOf(search) != -1 || value.indexOf(search) != -1)
410 return true;
411 }
412 }
413 if (osm.getUser() != null) {
414 String name = osm.getUser().getName();
415 // is not Java 1.5
416 //String name = java.text.Normalizer.normalize(name, java.text.Normalizer.Form.NFC);
417 if (!caseSensitive) {
418 name = name.toLowerCase();
419 }
420 if (name.indexOf(search) != -1)
421 return true;
422 }
423 return false;
424 }
425 @Override public String toString() {
426 return search;
427 }
428 }
429
430 private static class ExactType extends Match {
431 private final Class<?> type;
432 public ExactType(String type) throws ParseError {
433 if ("node".equals(type)) {
434 this.type = Node.class;
435 } else if ("way".equals(type)) {
436 this.type = Way.class;
437 } else if ("relation".equals(type)) {
438 this.type = Relation.class;
439 } else
440 throw new ParseError(tr("Unknown primitive type: {0}. Allowed values are node, way or relation",
441 type));
442 }
443 @Override public boolean match(OsmPrimitive osm) {
444 return osm.getClass() == type;
445 }
446 @Override public String toString() {return "type="+type;}
447 }
448
449 private static class UserMatch extends Match {
450 private String user;
451 public UserMatch(String user) {
452 if (user.equals("anonymous")) {
453 this.user = null;
454 } else {
455 this.user = user;
456 }
457 }
458
459 @Override public boolean match(OsmPrimitive osm) {
460 if (osm.getUser() == null)
461 return user == null;
462 else
463 return osm.getUser().hasName(user);
464 }
465
466 @Override public String toString() {
467 return "user=" + user == null ? "" : user;
468 }
469 }
470
471 private static class NodeCount extends Match {
472 private int count;
473 public NodeCount(int count) {this.count = count;}
474 @Override public boolean match(OsmPrimitive osm) {
475 return osm instanceof Way && ((Way) osm).getNodesCount() == count;
476 }
477 @Override public String toString() {return "nodes="+count;}
478 }
479
480 private static class NodeCountRange extends Match {
481 private int minCount;
482 private int maxCount;
483 public NodeCountRange(int minCount, int maxCount) {
484 if(maxCount < minCount) {
485 this.minCount = maxCount;
486 this.maxCount = minCount;
487 } else {
488 this.minCount = minCount;
489 this.maxCount = maxCount;
490 }
491 }
492 @Override public boolean match(OsmPrimitive osm) {
493 if(!(osm instanceof Way)) return false;
494 int size = ((Way)osm).getNodesCount();
495 return (size >= minCount) && (size <= maxCount);
496 }
497 @Override public String toString() {return "nodes="+minCount+"-"+maxCount;}
498 }
499
500 private static class TagCount extends Match {
501 private int count;
502 public TagCount(int count) {this.count = count;}
503 @Override public boolean match(OsmPrimitive osm) {
504 int size = osm.getKeys().size();
505 return size == count;
506 }
507 @Override public String toString() {return "tags="+count;}
508 }
509
510 private static class TagCountRange extends Match {
511 private int minCount;
512 private int maxCount;
513 public TagCountRange(int minCount, int maxCount) {
514 if(maxCount < minCount) {
515 this.minCount = maxCount;
516 this.maxCount = minCount;
517 } else {
518 this.minCount = minCount;
519 this.maxCount = maxCount;
520 }
521 }
522 @Override public boolean match(OsmPrimitive osm) {
523 int size = osm.getKeys().size();
524 return (size >= minCount) && (size <= maxCount);
525 }
526 @Override public String toString() {return "tags="+minCount+"-"+maxCount;}
527 }
528
529 private static class Modified extends Match {
530 @Override public boolean match(OsmPrimitive osm) {
531 return osm.isModified() || osm.isNew();
532 }
533 @Override public String toString() {return "modified";}
534 }
535
536 private static class Selected extends Match {
537 @Override public boolean match(OsmPrimitive osm) {
538 return Main.main.getCurrentDataSet().isSelected(osm);
539 }
540 @Override public String toString() {return "selected";}
541 }
542
543 private static class Incomplete extends Match {
544 @Override public boolean match(OsmPrimitive osm) {
545 return osm.isIncomplete();
546 }
547 @Override public String toString() {return "incomplete";}
548 }
549
550 private static class Untagged extends Match {
551 @Override public boolean match(OsmPrimitive osm) {
552 return !osm.isTagged();
553 }
554 @Override public String toString() {return "untagged";}
555 }
556
557 private static class Parent extends Match {
558 private Match child;
559 public Parent(Match m) { child = m; }
560 @Override public boolean match(OsmPrimitive osm) {
561 boolean isParent = false;
562
563 // "parent" (null) should mean the same as "parent()"
564 // (Always). I.e. match everything
565 if (child == null) {
566 child = new Always();
567 }
568
569 if (osm instanceof Way) {
570 for (Node n : ((Way)osm).getNodes()) {
571 isParent |= child.match(n);
572 }
573 } else if (osm instanceof Relation) {
574 for (RelationMember member : ((Relation)osm).getMembers()) {
575 isParent |= child.match(member.getMember());
576 }
577 }
578 return isParent;
579 }
580 @Override public String toString() {return "parent(" + child + ")";}
581 }
582
583 private static class Child extends Match {
584 private final Match parent;
585
586 public Child(Match m) {
587 // "child" (null) should mean the same as "child()"
588 // (Always). I.e. match everything
589 if (m == null) {
590 parent = new Always();
591 } else {
592 parent = m;
593 }
594 }
595
596 @Override public boolean match(OsmPrimitive osm) {
597 boolean isChild = false;
598 for (OsmPrimitive p : osm.getReferrers()) {
599 isChild |= parent.match(p);
600 }
601 return isChild;
602 }
603 @Override public String toString() {return "child(" + parent + ")";}
604 }
605
606 public static class ParseError extends Exception {
607 public ParseError(String msg) {
608 super(msg);
609 }
610 }
611
612 public static Match compile(String searchStr, boolean caseSensitive, boolean regexSearch)
613 throws ParseError {
614 return new SearchCompiler(caseSensitive, regexSearch,
615 new PushbackTokenizer(
616 new PushbackReader(new StringReader(searchStr))))
617 .parse();
618 }
619
620 public Match parse() throws ParseError {
621 Match m = parseExpression();
622 if (!tokenizer.readIfEqual(Token.EOF))
623 throw new ParseError(tr("Unexpected token: {0}", tokenizer.nextToken()));
624 if (m == null)
625 return new Always();
626 return m;
627 }
628
629 private Match parseExpression() throws ParseError {
630 Match factor = parseFactor();
631 if (factor == null)
632 return null;
633 if (tokenizer.readIfEqual(Token.OR))
634 return new Or(factor, parseExpression(tr("Missing parameter for OR")));
635 else {
636 Match expression = parseExpression();
637 if (expression == null)
638 return factor;
639 else
640 return new And(factor, expression);
641 }
642 }
643
644 private Match parseExpression(String errorMessage) throws ParseError {
645 Match expression = parseExpression();
646 if (expression == null)
647 throw new ParseError(errorMessage);
648 else
649 return expression;
650 }
651
652 private Match parseFactor() throws ParseError {
653 if (tokenizer.readIfEqual(Token.LEFT_PARENT)) {
654 Match expression = parseExpression();
655 if (!tokenizer.readIfEqual(Token.RIGHT_PARENT))
656 throw new ParseError(tr("Unexpected token. Expected {0}, found {1}", Token.RIGHT_PARENT, tokenizer.nextToken()));
657 return expression;
658 } else if (tokenizer.readIfEqual(Token.NOT))
659 return new Not(parseFactor(tr("Missing operator for NOT")));
660 else if (tokenizer.readIfEqual(Token.KEY)) {
661 String key = tokenizer.getText();
662 if (tokenizer.readIfEqual(Token.EQUALS))
663 return new ExactKeyValue(regexSearch, key, tokenizer.readText());
664 else if (tokenizer.readIfEqual(Token.COLON))
665 return parseKV(key, tokenizer.readText());
666 else if (tokenizer.readIfEqual(Token.QUESTION_MARK))
667 return new BooleanMatch(key, false);
668 else if ("modified".equals(key))
669 return new Modified();
670 else if ("incomplete".equals(key))
671 return new Incomplete();
672 else if ("untagged".equals(key))
673 return new Untagged();
674 else if ("selected".equals(key))
675 return new Selected();
676 else if ("child".equals(key))
677 return new Child(parseFactor());
678 else if ("parent".equals(key))
679 return new Parent(parseFactor());
680 else
681 return new Any(key, regexSearch, caseSensitive);
682 } else
683 return null;
684 }
685
686 private Match parseFactor(String errorMessage) throws ParseError {
687 Match fact = parseFactor();
688 if (fact == null)
689 throw new ParseError(errorMessage);
690 else
691 return fact;
692 }
693
694 private Match parseKV(String key, String value) throws ParseError {
695 if (value == null) {
696 value = "";
697 }
698 if (key.equals("type"))
699 return new ExactType(value);
700 else if (key.equals("user"))
701 return new UserMatch(value);
702 else if (key.equals("tags")) {
703 try {
704 String[] range = value.split("-");
705 if (range.length == 1)
706 return new TagCount(Integer.parseInt(value));
707 else if (range.length == 2)
708 return new TagCountRange(Integer.parseInt(range[0]), Integer.parseInt(range[1]));
709 else
710 throw new ParseError(tr("Wrong number of parameters for tags operator."));
711 } catch (NumberFormatException e) {
712 throw new ParseError(tr("Incorrect value of tags operator: {0}. Tags operator expects number of tags or range, for example tags:1 or tags:2-5", value));
713 }
714 } else if (key.equals("nodes")) {
715 try {
716 String[] range = value.split("-");
717 if (range.length == 1)
718 return new NodeCount(Integer.parseInt(value));
719 else if (range.length == 2)
720 return new NodeCountRange(Integer.parseInt(range[0]), Integer.parseInt(range[1]));
721 else
722 throw new ParseError(tr("Wrong number of parameters for nodes operator."));
723 } catch (NumberFormatException e) {
724 throw new ParseError(tr("Incorrect value of nodes operator: {0}. Nodes operator expects number of nodes or range, for example nodes:10-20", value));
725 }
726
727 } else if (key.equals("id")) {
728 try {
729 return new Id(Long.parseLong(value));
730 } catch (NumberFormatException x) {
731 throw new ParseError(tr("Incorrect value of id operator: {0}. Number is expected.", value));
732 }
733 } else if (key.equals("changeset")) {
734 try {
735 return new ChangesetId(Integer.parseInt(value));
736 } catch (NumberFormatException x) {
737 throw new ParseError(tr("Incorrect value of changeset operator: {0}. Number is expected.", value));
738 }
739
740 } else if (key.equals("version")) {
741 try {
742 return new Version(Long.parseLong(value));
743 } catch (NumberFormatException x) {
744 throw new ParseError(tr("Incorrect value of version operator: {0}. Number is expected.", value));
745 }
746 }
747 else
748 return new KeyValue(key, value, regexSearch, caseSensitive);
749 }
750
751 private static int regexFlags(boolean caseSensitive) {
752 int searchFlags = 0;
753
754 // Enables canonical Unicode equivalence so that e.g. the two
755 // forms of "\u00e9gal" and "e\u0301gal" will match.
756 //
757 // It makes sense to match no matter how the character
758 // happened to be constructed.
759 searchFlags |= Pattern.CANON_EQ;
760
761 // Make "." match any character including newline (/s in Perl)
762 searchFlags |= Pattern.DOTALL;
763
764 // CASE_INSENSITIVE by itself only matches US-ASCII case
765 // insensitively, but the OSM data is in Unicode. With
766 // UNICODE_CASE casefolding is made Unicode-aware.
767 if (!caseSensitive) {
768 searchFlags |= (Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
769 }
770
771 return searchFlags;
772 }
773}
Note: See TracBrowser for help on using the repository browser.