KickJava   Java API By Example, From Geeks To Geeks.

Java > Open Source Codes > org > opencrx > kernel > layer > application > ICalendar


1 /*
2  * ====================================================================
3  * Project: opencrx, http://www.opencrx.org/
4  * Name: $Id: ICalendar.java,v 1.7 2005/07/29 22:43:37 wfro Exp $
5  * Description: ICalendar
6  * Revision: $Revision: 1.7 $
7  * Owner: CRIXP AG, Switzerland, http://www.crixp.com
8  * Date: $Date: 2005/07/29 22:43:37 $
9  * ====================================================================
10  *
11  * This software is published under the BSD license
12  * as listed below.
13  *
14  * Copyright (c) 2004-2005, CRIXP Corp., Switzerland
15  * All rights reserved.
16  *
17  * Redistribution and use in source and binary forms, with or without
18  * modification, are permitted provided that the following conditions
19  * are met:
20  *
21  * * Redistributions of source code must retain the above copyright
22  * notice, this list of conditions and the following disclaimer.
23  *
24  * * Redistributions in binary form must reproduce the above copyright
25  * notice, this list of conditions and the following disclaimer in
26  * the documentation and/or other materials provided with the
27  * distribution.
28  *
29  * * Neither the name of CRIXP Corp. nor the names of the contributors
30  * to openCRX may be used to endorse or promote products derived
31  * from this software without specific prior written permission
32  *
33  *
34  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
35  * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
36  * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
37  * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
38  * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS
39  * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
40  * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
41  * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
42  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
43  * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
44  * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
45  * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
46  * POSSIBILITY OF SUCH DAMAGE.
47  *
48  * ------------------
49  *
50  * This product includes software developed by the Apache Software
51  * Foundation (http://www.apache.org/).
52  *
53  * This product includes software developed by contributors to
54  * openMDX (http://www.openmdx.org/)
55  */

56 package org.opencrx.kernel.layer.application;
57
58 import java.io.BufferedReader JavaDoc;
59 import java.io.ByteArrayInputStream JavaDoc;
60 import java.io.ByteArrayOutputStream JavaDoc;
61 import java.io.IOException JavaDoc;
62 import java.io.InputStream JavaDoc;
63 import java.io.InputStreamReader JavaDoc;
64 import java.io.PrintWriter JavaDoc;
65 import java.util.ArrayList JavaDoc;
66 import java.util.Date JavaDoc;
67 import java.util.HashMap JavaDoc;
68 import java.util.Iterator JavaDoc;
69 import java.util.List JavaDoc;
70 import java.util.Map JavaDoc;
71 import java.util.StringTokenizer JavaDoc;
72
73 import org.openmdx.application.log.AppLog;
74 import org.openmdx.base.exception.ServiceException;
75 import org.openmdx.base.text.format.DateFormat;
76 import org.openmdx.compatibility.base.dataprovider.cci.AttributeSelectors;
77 import org.openmdx.compatibility.base.dataprovider.cci.AttributeSpecifier;
78 import org.openmdx.compatibility.base.dataprovider.cci.DataproviderObject;
79 import org.openmdx.compatibility.base.dataprovider.cci.DataproviderObject_1_0;
80 import org.openmdx.compatibility.base.dataprovider.cci.Directions;
81 import org.openmdx.compatibility.base.dataprovider.cci.RequestCollection;
82 import org.openmdx.compatibility.base.dataprovider.cci.SystemAttributes;
83 import org.openmdx.compatibility.base.naming.Path;
84 import org.openmdx.compatibility.base.query.FilterOperators;
85 import org.openmdx.compatibility.base.query.FilterProperty;
86 import org.openmdx.compatibility.base.query.Quantors;
87 import org.openmdx.kernel.exception.BasicException;
88 import org.openmdx.kernel.id.UUIDs;
89
90 public class ICalendar {
91   
92   //-------------------------------------------------------------------------
93
public ICalendar(
94     OpenCrxKernel_1 plugin,
95     RequestCollection delegation,
96     Codes codes
97   ) {
98     this.plugin = plugin;
99     this.delegation = delegation;
100     this.codes = codes;
101   }
102
103   //-------------------------------------------------------------------------
104
private Short JavaDoc numberAsShort(
105     Object JavaDoc number
106   ) {
107     return new Short JavaDoc(((Number JavaDoc)number).shortValue());
108   }
109     
110   //-------------------------------------------------------------------------
111
private String JavaDoc getPrimaryEMailAddress(
112     Path contact
113   ) throws ServiceException {
114     List JavaDoc addresses = this.delegation.addFindRequest(
115       contact.getChild("address"),
116       null,
117       AttributeSelectors.ALL_ATTRIBUTES,
118       0,
119       Integer.MAX_VALUE,
120       Directions.ASCENDING
121     );
122     String JavaDoc emailAddress = null;
123     for(Iterator JavaDoc i = addresses.iterator(); i.hasNext(); ) {
124       DataproviderObject_1_0 address = (DataproviderObject_1_0)i.next();
125       String JavaDoc addressClass = (String JavaDoc)address.values(SystemAttributes.OBJECT_CLASS).get(0);
126       List JavaDoc usage = new ArrayList JavaDoc();
127       for(Iterator JavaDoc j = address.values("usage").iterator(); j.hasNext(); ) {
128         usage.add(
129           this.numberAsShort(j.next())
130         );
131       }
132       if("org:opencrx:kernel:account1:EMailAddress".equals(addressClass)) {
133         List JavaDoc adr = address.values("emailAddress");
134         if((emailAddress == null) && (adr.size() > 0)) {
135           emailAddress = (String JavaDoc)adr.get(0);
136         }
137         if((adr.size() > 0) && (usage.contains(USAGE_EMAIL_PRIMARY))) {
138           emailAddress = (String JavaDoc)adr.get(0);
139         }
140       }
141     }
142     return emailAddress;
143   }
144
145   //-------------------------------------------------------------------------
146
private String JavaDoc escapeNewlines(
147     String JavaDoc from
148   ) {
149     String JavaDoc to = "";
150     for(int i = 0; i < from.length(); i++) {
151       if(from.charAt(i) == '\n') {
152         to += "\\n";
153       }
154       else {
155         to += from.charAt(i);
156       }
157     }
158     return to;
159   }
160   
161   //-------------------------------------------------------------------------
162
public byte[] exportItem(
163     DataproviderObject_1_0 meeting,
164     short locale,
165     List JavaDoc statusMessage
166   ) throws ServiceException {
167     AppLog.trace("inspecting meeting", meeting);
168
169     // DTSTART
170
String JavaDoc dtStart = null;
171     if(meeting.values("scheduledStart").size() == 0) {
172       statusMessage.add("DTSTART (scheduled start)");
173     }
174     else {
175       dtStart = (String JavaDoc)meeting.values("scheduledStart").get(0);
176     }
177     // DTEND
178
String JavaDoc dtEnd = null;
179     if(meeting.values("scheduledEnd").size() == 0) {
180       statusMessage.add("DTEND (scheduled end)");
181     }
182     else {
183       dtEnd = (String JavaDoc)meeting.values("scheduledEnd").get(0);
184     }
185     // PRIORITY
186
String JavaDoc priority = null;
187     if(meeting.values("priority").size() == 0) {
188       statusMessage.add("PRIORITY (priority)");
189     }
190     else {
191       priority = meeting.values("priority").get(0).toString();
192     }
193     // SUMMARY
194
String JavaDoc summary = meeting.values("name").size() == 0 ? "" : (String JavaDoc)meeting.values("name").get(0);
195     // DESCRIPTION
196
String JavaDoc description = meeting.values("description").size() == 0 ? "" : (String JavaDoc)meeting.values("description").get(0);
197     // LOCATION
198
String JavaDoc location = meeting.values("location").size() == 0 ? "" : (String JavaDoc)meeting.values("location").get(0);
199     
200     // attendees
201
List JavaDoc participants = this.delegation.addFindRequest(
202       meeting.path().getChild("meetingParty"),
203       null,
204       AttributeSelectors.ALL_ATTRIBUTES,
205       0,
206       Integer.MAX_VALUE,
207       Directions.ASCENDING
208     );
209     List JavaDoc attendees = new ArrayList JavaDoc();
210     for(Iterator JavaDoc i = participants.iterator(); i.hasNext(); ) {
211         DataproviderObject_1_0 participant = (DataproviderObject_1_0)i.next();
212         if(participant.values("party").size() > 0) {
213             try {
214                 DataproviderObject_1_0 contact = this.delegation.addGetRequest(
215                     (Path)participant.values("party").get(0),
216                     AttributeSelectors.ALL_ATTRIBUTES,
217                     new AttributeSpecifier[]{}
218                 );
219                 String JavaDoc emailAddress = this.getPrimaryEMailAddress(
220                     contact.path()
221                 );
222                 String JavaDoc fullName = contact.values("fullName").size() == 0 ? null : (String JavaDoc)contact.values("fullName").get(0);
223                 if(emailAddress != null) {
224                     if(fullName == null) {
225                       attendees.add(
226                           "CN=" + emailAddress + ";ROLE=REQ-PARTICIPANT;RSVP=TRUE:MAILTO:" + emailAddress
227                       );
228                     }
229                     else {
230                         attendees.add(
231                           "CN=\"" + fullName + " (" + emailAddress + ")\";ROLE=REQ-PARTICIPANT;RSVP=TRUE:MAILTO:" + emailAddress
232                         );
233                     }
234                 }
235             }
236             catch(ServiceException e) {
237                 if(e.getExceptionCode() != BasicException.Code.AUTHORIZATION_FAILURE) {
238                     throw e;
239                 }
240             }
241         }
242     }
243     
244     // ORGANIZER
245
String JavaDoc organizerEmailAddress = null;
246     if(meeting.values("assignedTo").size() == 0) {
247       statusMessage.add("ORGANIZER (assigned to)");
248     }
249     else {
250       organizerEmailAddress = this.getPrimaryEMailAddress(
251         (Path)meeting.values("assignedTo").get(0)
252       );
253     }
254     
255     // return if data is missing
256
if(statusMessage.size() > 0) {
257       return null;
258     }
259     
260     ByteArrayOutputStream JavaDoc os = new ByteArrayOutputStream JavaDoc();
261     PrintWriter JavaDoc pw = new PrintWriter JavaDoc(os);
262     pw.println("BEGIN:VCALENDAR");
263     pw.println("VERSION:2.0");
264     pw.println("METHOD:PUBLISH");
265     pw.println("BEGIN:VEVENT");
266     for(int i = 0; i < attendees.size(); i++) {
267       pw.println("ATTENDEE;" + attendees.get(i));
268     }
269     pw.println("ORGANIZER:MAILTO:" + (organizerEmailAddress == null ? "" : organizerEmailAddress));
270     pw.println("DTSTART:" + dtStart.substring(0, 15) + "Z");
271     pw.println("DTEND:" + dtEnd.substring(0, 15) + "Z");
272     pw.println("LOCATION:" + location);
273     pw.println("TRANSP:OPAQUE");
274     pw.println("SEQUENCE:0");
275     pw.println("UID:" + (meeting.values("activityNumber").size() > 0 ? meeting.values("activityNumber").get(0) : meeting.path().getBase()));
276     pw.println("DTSTAMP:" + DateFormat.getInstance().format(new Date JavaDoc()));
277     pw.println("DESCRIPTION:" + this.escapeNewlines(description));
278     pw.println("SUMMARY:" + summary);
279     pw.println("PRIORITY:" + priority);
280     pw.println("CLASS:PUBLIC");
281     pw.println("END:VEVENT");
282     pw.println("END:VCALENDAR");
283     try {
284       pw.flush();
285       os.close();
286     } catch(Exception JavaDoc e) {}
287     return os.toByteArray();
288   }
289   
290   //-------------------------------------------------------------------------
291
public DataproviderObject importItem(
292     byte[] item,
293     Path activitySegmentPath,
294     Path contactSegmentPath,
295     short locale,
296     List JavaDoc errors,
297     List JavaDoc report
298   ) throws ServiceException {
299     try {
300       InputStream JavaDoc is = new ByteArrayInputStream JavaDoc(item);
301       BufferedReader JavaDoc reader = new BufferedReader JavaDoc(new InputStreamReader JavaDoc(is));
302       Map JavaDoc data = new HashMap JavaDoc();
303       String JavaDoc line = null;
304       int nAttendees = 0;
305       boolean lineCont = false;
306       String JavaDoc currentName = null;
307       while((line = reader.readLine()) != null) {
308         if(lineCont && line.startsWith(" ")) {
309           data.put(
310             currentName,
311             data.get(currentName) + line.substring(1)
312           );
313           lineCont = false;
314         }
315         else if(line.startsWith("ATTENDEE;") || line.startsWith("attendee;")) {
316           currentName = "ATTENDEE[" + nAttendees + "]";
317           data.put(
318             currentName,
319             line.substring("ATTENDEE;".length())
320           );
321           nAttendees++;
322         }
323         else if(line.indexOf(":") >= 0) {
324           currentName = line.substring(0, line.indexOf(":")).toUpperCase();
325           data.put(
326             currentName,
327             line.substring(line.indexOf(":") + 1)
328           );
329           lineCont = line.length() == 79;
330         }
331       }
332       AppLog.trace("ICalendar", data);
333       return this.importItem(
334         data,
335         activitySegmentPath,
336         contactSegmentPath,
337         locale,
338         errors,
339         report
340       );
341     }
342     catch(IOException JavaDoc e) {
343       AppLog.warning("can not read item", e.getMessage());
344     }
345     return null;
346   }
347
348   //-------------------------------------------------------------------------
349
private DataproviderObject getAttendeeAsContact(
350     String JavaDoc attendeeAsString,
351     VCard vcardImporter,
352     Path contactSegmentPath,
353     short locale,
354     List JavaDoc report
355   ) {
356     Map JavaDoc vcard = new HashMap JavaDoc();
357     int pos = attendeeAsString.indexOf("MAILTO:");
358     String JavaDoc emailPrefInternet = attendeeAsString.substring(pos + 7);
359     vcard.put("EMAIL;PREF;INTERNET", emailPrefInternet);
360     String JavaDoc name = null;
361     if((attendeeAsString.startsWith("CN=\"") && (attendeeAsString.indexOf("(") >= 0))) {
362       name = attendeeAsString.substring(4, attendeeAsString.indexOf("("));
363     }
364     else {
365       pos = emailPrefInternet.indexOf("@");
366       name = emailPrefInternet.substring(0, pos > 0 ? pos : emailPrefInternet.length());
367       name = name.replace('.', ' ');
368     }
369     StringBuffer JavaDoc n = new StringBuffer JavaDoc();
370     StringTokenizer JavaDoc nameTokenizer = new StringTokenizer JavaDoc(name, " ");
371     while(nameTokenizer.hasMoreTokens()) {
372       n.insert(0, n.length() == 0 ? "" : ";");
373       n.insert(0, nameTokenizer.nextToken());
374     }
375     vcard.put("N", n.toString());
376     try {
377       return vcardImporter.importItem(
378         vcard,
379         contactSegmentPath,
380         locale,
381         true,
382         report
383       );
384     }
385     catch(Exception JavaDoc e) {
386       return null;
387     }
388   }
389   
390   //-------------------------------------------------------------------------
391
public DataproviderObject importItem(
392       Map JavaDoc data,
393       Path activitySegmentPath,
394       Path contactSegmentPath,
395       short locale,
396       List JavaDoc errors,
397       List JavaDoc report
398   ) throws ServiceException {
399
400     /**
401      * prepare Attendees
402      */

403     VCard vcardImporter = new VCard(
404         this.plugin,
405         this.delegation,
406         this.codes
407     );
408     List JavaDoc attendees = new ArrayList JavaDoc();
409     while(data.get("ATTENDEE[" + attendees.size() + "]") != null) {
410       String JavaDoc attendeeAsString = (String JavaDoc)data.get("ATTENDEE[" + attendees.size() + "]");
411       if(attendeeAsString.indexOf("MAILTO:") < 0) {
412           errors.add("MAILTO (" + attendeeAsString + ")");
413       }
414       else {
415         DataproviderObject attendee = this.getAttendeeAsContact(
416             attendeeAsString,
417             vcardImporter,
418             contactSegmentPath,
419             locale,
420             report
421         );
422         if(attendee != null) {
423             attendees.add(attendee);
424         }
425       }
426     }
427     if(errors.size() > 0) {
428         return null;
429     }
430     AppLog.trace("attendees=", attendees);
431     
432     /**
433      * Meeting
434      */

435     DataproviderObject meeting = null;
436     
437     // try to find meeting with UID as qualifier
438
try {
439         meeting = this.plugin.retrieveObjectForModification(
440             activitySegmentPath.getDescendant(new String JavaDoc[]{"activity", data.get("UID") + ""})
441         );
442     }
443     // if NOT_FOUND try to find meeting with number=UID
444
catch(ServiceException e) {
445         if(e.getExceptionCode() == BasicException.Code.NOT_FOUND) {
446             List JavaDoc meetings = this.delegation.addFindRequest(
447                 activitySegmentPath.getChild("activity"),
448                 new FilterProperty[]{
449                     new FilterProperty(
450                         Quantors.THERE_EXISTS,
451                         "activityNumber",
452                         FilterOperators.IS_IN,
453                         new String JavaDoc[]{data.get("UID") + ""}
454                     )
455                 },
456                 AttributeSelectors.ALL_ATTRIBUTES,
457                 0,
458                 Integer.MAX_VALUE,
459                 Directions.ASCENDING
460             );
461             if(meetings.size() > 0) {
462                 meeting = this.plugin.retrieveObjectForModification(
463                      ((DataproviderObject)meetings.iterator().next()).path()
464                 );
465             }
466         }
467     }
468     if(meeting == null) {
469         String JavaDoc meetingNumber = (String JavaDoc)data.get("UID");
470         if((meetingNumber == null) || (meetingNumber.length() == 0)) {
471             UUIDs.getGenerator().next().toString();
472         }
473         meeting = new DataproviderObject(
474             activitySegmentPath.getDescendant(
475                 new String JavaDoc[]{
476                     "activity",
477                     UUIDs.getGenerator().next().toString()
478                 }
479             )
480         );
481         meeting.values(SystemAttributes.OBJECT_CLASS).add("org:opencrx:kernel:activity1:Meeting");
482         meeting.values("activityState").add(new Short JavaDoc((short)0));
483         meeting.values("activityNumber").add(meetingNumber);
484         this.delegation.addCreateRequest(
485             meeting
486         );
487         meeting = this.plugin.retrieveObjectForModification(
488             meeting.path()
489         );
490         report.add("Create meeting");
491     }
492     String JavaDoc s = (String JavaDoc)data.get("DTSTART");
493     if((s != null) && (s.length() > 0)) {
494         try {
495             meeting.clearValues("scheduledStart").add(
496                 DateFormat.getInstance().format(DateFormat.getInstance().parse(
497                     s.substring(0, s.length()-1) + ".000Z")
498                 )
499             );
500         } catch(Exception JavaDoc e) {
501             errors.add("DTSTART (" + s + ")");
502         }
503     }
504     s = (String JavaDoc)data.get("DTEND");
505     if((s != null) && (s.length() > 0)) {
506         try {
507             meeting.clearValues("scheduledEnd").add(
508                 DateFormat.getInstance().format(DateFormat.getInstance().parse(
509                     s.substring(0, s.length()-1) + ".000Z")
510                 )
511             );
512         } catch(Exception JavaDoc e) {
513             errors.add("DTEND (" + s + ")");
514         }
515     }
516     s = (String JavaDoc)data.get("PRIORITY");
517     if((s != null) && (s.length() > 0)) {
518         try {
519             meeting.clearValues("priority").add(new Short JavaDoc(s));
520         } catch(Exception JavaDoc e) {
521             errors.add("PRIORITY (" + s + ")");
522         }
523     }
524     s = (String JavaDoc)data.get("SUMMARY");
525     if((s != null) && (s.length() > 0)) {
526         meeting.clearValues("name").add(s);
527     }
528     s = (String JavaDoc)data.get("DESCRIPTION");
529     if((s != null) && (s.length() > 0)) {
530         String JavaDoc temp = "";
531         int pos = 0;
532         while((pos = s.indexOf("\\n")) >= 0) {
533             temp += temp.length() == 0 ? "" : "\n";
534             temp += s.substring(0, pos);
535             s = s.substring(pos + 2);
536         }
537         temp += temp.length() == 0 ? "" : "\n";
538         temp += s;
539         meeting.clearValues("description").add(temp);
540     }
541     s = (String JavaDoc)data.get("ORGANIZER");
542     if((s != null) && (s.length() > 0)) {
543         DataproviderObject organizer = this.getAttendeeAsContact(
544             s.startsWith("MAILTO:") ? s : "MAILTO:" + s,
545             vcardImporter,
546             contactSegmentPath,
547             locale,
548             report
549         );
550         if(organizer != null) {
551             meeting.clearValues("assignedTo").add(
552                 organizer.path()
553             );
554         }
555     }
556     if(errors.size() > 0) {
557         return null;
558     }
559     report.add("Update meeting");
560     
561     /**
562      * Meeting parties
563      */

564     List JavaDoc meetingParties = this.delegation.addFindRequest(
565         meeting.path().getChild("meetingParty"),
566         null,
567         AttributeSelectors.ALL_ATTRIBUTES,
568         0,
569         Integer.MAX_VALUE,
570         Directions.ASCENDING
571     );
572     // replace existing
573
int count = 0;
574     List JavaDoc meetingPartiesToBeRemoved = new ArrayList JavaDoc();
575     for(
576         Iterator JavaDoc i = meetingParties.iterator();
577         i.hasNext();
578         count++
579     ) {
580         if(count > attendees.size()) {
581             meetingPartiesToBeRemoved.add(
582                 ((DataproviderObject_1_0)i.next()).path()
583             );
584         }
585         else {
586             DataproviderObject attendee = (DataproviderObject)attendees.get(count);
587             DataproviderObject meetingParty = this.plugin.retrieveObjectForModification(
588                 ((DataproviderObject_1_0)i.next()).path()
589             );
590             meetingParty.clearValues("partyType").add(new Short JavaDoc((short)0));
591             meetingParty.clearValues("party").add(attendee.path());
592             report.add("Update meeting party");
593         }
594     }
595     // create new
596
for(int i = count; i < attendees.size(); i++) {
597         DataproviderObject attendee = (DataproviderObject)attendees.get(i);
598         DataproviderObject meetingParty = new DataproviderObject(
599             meeting.path().getDescendant(new String JavaDoc[]{"meetingParty", UUIDs.getGenerator().next().toString()})
600         );
601         meetingParty.values(SystemAttributes.OBJECT_CLASS).add("org:opencrx:kernel:activity1:MeetingParty");
602         meetingParty.clearValues("partyType").add(new Short JavaDoc((short)0));
603         meetingParty.clearValues("party").add(attendee.path());
604         this.delegation.addCreateRequest(
605             meetingParty
606         );
607         report.add("create meeting party");
608     }
609     // remove
610
for(Iterator JavaDoc i = meetingPartiesToBeRemoved.iterator(); i.hasNext(); ) {
611         this.delegation.addRemoveRequest(
612             (Path)i.next()
613         );
614     }
615     return meeting;
616   }
617
618   //-------------------------------------------------------------------------
619
// Members
620
//-------------------------------------------------------------------------
621
public static final String JavaDoc MIME_TYPE = "text/calendar";
622   public static final int MIME_TYPE_CODE = 4;
623
624   static private final Short JavaDoc USAGE_EMAIL_PRIMARY = new Short JavaDoc((short)300);
625   
626   private final OpenCrxKernel_1 plugin;
627   private final RequestCollection delegation;
628   private final Codes codes;
629 }
630
631 //--- End of File -----------------------------------------------------------
632

633
Popular Tags