diff --git a/.classpath b/.classpath index d315a07..decd619 100644 --- a/.classpath +++ b/.classpath @@ -13,7 +13,7 @@ - + diff --git a/ChangeLog b/ChangeLog index ebb6954..dc7afbd 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,10 @@ +Version 1.0.2 (02 Sep 2024) + - Google's RFC 2445 RRule library source is now included with the source here + since there is not a reliable maven jar to reference. The source is unmodified + except for a single Java 9+ incompatibility. + - Switch back to Java 11 since Java 17+ features are not being used yet. + - Removed all the journal UI code since that is now its own git repo + at craigk5n/k5njournal. Version 1.0.1 (28 Aug 2024) - Added new unit tests. - Updating Java 17. diff --git a/LICENSE-google-rfc-2445 b/LICENSE-google-rfc-2445 new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE-google-rfc-2445 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README-google-rfc-2445.html b/README-google-rfc-2445.html new file mode 100644 index 0000000..c1c5a04 --- /dev/null +++ b/README-google-rfc-2445.html @@ -0,0 +1,89 @@ + + + +Read Me + + + + + + +

Online Documentation

+

The javadoc is available online.

+ +

Downloading

+

The source is available from subversion. See the project page for +instructions on how to download the source from subversion.

+ + +

Building

+ +

Requires ant to build with +junit for testing, and optionally +joda-time installed.

+ +

Once you've got ant +set up and installed, +run ant default to build the jars and documentation, and then +read the documentation in the docs directory. You can use the jar +at jars/rfc2445.jar in your program.

+ +

To build without joda-time, run ant rfc2445-no-joda and it will +produce a version of the jar without the joda-time compatibility classes.

+ + +

Using

+ +

Using the API is pretty easy. Pass in some ical and you get back +a date iterable, which can be used in a for-each loop thusly:

+ +
+// A compatibility layer for joda-time
+import com.google.ical.compat.jodatime.LocalDateIteratorFactory;
+// A Joda time class that represents a day regardless of timezone
+import org.joda.time.LocalDate;
+
+public class ThirteenFridaysTheThirteenth {
+
+  /** print the first 13 Friday the 13ths in the 3rd millenium AD. */
+  public static void main(String[] args) throws java.text.ParseException {
+    LocalDate start = new LocalDate(2001, 4, 13);
+
+    // Every friday the thirteenth.
+    String ical = "RRULE:FREQ=MONTHLY"
+                  + ";BYDAY=FR"  // every Friday
+                  + ";BYMONTHDAY=13"  // that occurs on the 13th of the month
+                  + ";COUNT=13";  // stop after 13 occurences
+
+    // Print out each date in the series.
+    for (LocalDate date :
+         LocalDateIteratorFactory.createLocalDateIterable(ical, start, true)) {
+      System.out.println(date);
+    }
+  }
+
+}
+
+

See RFC 2445 for the recurrence rule +syntax and what it means, and the examples later in the same document.

+ +

If you use java.util.Date and java.util.Calendar in +your application instead of Joda-Time, you can use the +com.google.ical.compat.javautil package instead to provide +Date objects.

+ + +

Running tests

+ +

If you make source changes, you can run ant runtests to run +build and run the tests.

+ + + diff --git a/pom.xml b/pom.xml index ab64751..4b7c6db 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ us.k5n javacaltools - 1.0.1 + 1.0.2 jar javacaltools @@ -13,8 +13,9 @@ https://www.k5n.us/java-calendar-tools/ - 17 - 17 + 11 + 11 + 11 @@ -23,12 +24,6 @@ joda-time 1.4 - - - org.scala-saddle - google-rfc-2445 - 20110304 - net.sourceforge.javacsv javacsv @@ -62,6 +57,9 @@ + src/main/java + src/test/java + @@ -71,6 +69,7 @@ ${maven.compiler.source} ${maven.compiler.target} + ${maven.compiler.release} @@ -82,14 +81,40 @@ - true - lib/ - us.k5n.journal.Main + true + true + us.k5n.journal.Main + + + org.apache.maven.plugins + maven-assembly-plugin + 3.1.0 + + + + us.k5n.ui.calendar.CalendarPanelTest + + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + org.apache.maven.plugins @@ -98,11 +123,6 @@ ${project.build.directory}/docs ${maven.compiler.source} - - com.yworks - ydoc - 3.0.1 - @@ -130,4 +150,4 @@ - + \ No newline at end of file diff --git a/src/main/java/com/google/ical/compat/javautil/DateIterable.java b/src/main/java/com/google/ical/compat/javautil/DateIterable.java new file mode 100644 index 0000000..86bd0b7 --- /dev/null +++ b/src/main/java/com/google/ical/compat/javautil/DateIterable.java @@ -0,0 +1,28 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.javautil; + +import java.util.Date; + +/** + * an iterable over dates in order. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface DateIterable extends Iterable { + + DateIterator iterator(); + +} diff --git a/src/main/java/com/google/ical/compat/javautil/DateIterator.java b/src/main/java/com/google/ical/compat/javautil/DateIterator.java new file mode 100644 index 0000000..dc6825b --- /dev/null +++ b/src/main/java/com/google/ical/compat/javautil/DateIterator.java @@ -0,0 +1,29 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.javautil; + +import java.util.Date; +import java.util.Iterator; + +public interface DateIterator extends Iterator { + + /** + * skips all dates in the series before the given date. + * + * @param newStartUtc non null. + */ + void advanceTo(Date newStartUtc); + +} diff --git a/src/main/java/com/google/ical/compat/javautil/DateIteratorFactory.java b/src/main/java/com/google/ical/compat/javautil/DateIteratorFactory.java new file mode 100644 index 0000000..d7bfc6d --- /dev/null +++ b/src/main/java/com/google/ical/compat/javautil/DateIteratorFactory.java @@ -0,0 +1,158 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.javautil; + +import com.google.ical.iter.RecurrenceIterable; +import com.google.ical.iter.RecurrenceIterator; +import com.google.ical.iter.RecurrenceIteratorFactory; +import com.google.ical.util.TimeUtils; +import com.google.ical.values.DateTimeValueImpl; +import com.google.ical.values.DateValue; +import com.google.ical.values.DateValueImpl; +import com.google.ical.values.TimeValue; +import java.text.ParseException; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +/** + * a factory for converting RRULEs and RDATEs into + * Iterator<Date> and Iterable<Date>. + * + * @see RecurrenceIteratorFactory + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class DateIteratorFactory { + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single date iterator. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param tzid the local timezone -- used to interpret start and any dates in + * RDATE and EXDATE lines that don't have TZID params. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static DateIterator createDateIterator( + String rdata, Date start, TimeZone tzid, boolean strict) + throws ParseException { + return new RecurrenceIteratorWrapper( + RecurrenceIteratorFactory.createRecurrenceIterator( + rdata, dateToDateValue(start, true), + tzid, strict)); + } + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single date iterable. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param tzid the local timezone -- used to interpret start and any dates in + * RDATE and EXDATE lines that don't have TZID params. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static DateIterable createDateIterable( + String rdata, Date start, TimeZone tzid, boolean strict) + throws ParseException { + return new RecurrenceIterableWrapper( + RecurrenceIteratorFactory.createRecurrenceIterable( + rdata, dateToDateValue(start, true), + tzid, strict)); + } + + /** + * creates a date iterator given a recurrence iterator from + * {@link com.google.ical.iter.RecurrenceIteratorFactory}. + */ + public static DateIterator createDateIterator(RecurrenceIterator rit) { + return new RecurrenceIteratorWrapper(rit); + } + + private static final class RecurrenceIterableWrapper + implements DateIterable { + private final RecurrenceIterable it; + + public RecurrenceIterableWrapper(RecurrenceIterable it) { this.it = it; } + + public DateIterator iterator() { + return new RecurrenceIteratorWrapper(it.iterator()); + } + } + + private static final class RecurrenceIteratorWrapper + implements DateIterator { + private final RecurrenceIterator it; + RecurrenceIteratorWrapper(RecurrenceIterator it) { this.it = it; } + public boolean hasNext() { return it.hasNext(); } + public Date next() { return dateValueToDate(it.next()); } + public void remove() { throw new UnsupportedOperationException(); } + public void advanceTo(Date d) { + // we need to treat midnight as a date value so that passing in + // dateValueToDate() will not advance past any + // occurrences of some-date-value in the iterator. + it.advanceTo(dateToDateValue(d, true)); + } + } + + static Date dateValueToDate(DateValue dvUtc) { + GregorianCalendar c = new GregorianCalendar(TimeUtils.utcTimezone()); + c.clear(); + if (dvUtc instanceof TimeValue) { + TimeValue tvUtc = (TimeValue) dvUtc; + c.set(dvUtc.year(), + dvUtc.month() - 1, // java.util's dates are zero-indexed + dvUtc.day(), + tvUtc.hour(), + tvUtc.minute(), + tvUtc.second()); + } else { + c.set(dvUtc.year(), + dvUtc.month() - 1, // java.util's dates are zero-indexed + dvUtc.day(), + 0, + 0, + 0); + } + return c.getTime(); + } + + static DateValue dateToDateValue(Date date, boolean midnightAsDate) { + GregorianCalendar c = new GregorianCalendar(TimeUtils.utcTimezone()); + c.setTime(date); + int h = c.get(Calendar.HOUR_OF_DAY), + m = c.get(Calendar.MINUTE), + s = c.get(Calendar.SECOND); + if (midnightAsDate && 0 == (h | m | s)) { + return new DateValueImpl(c.get(Calendar.YEAR), + c.get(Calendar.MONTH) + 1, + c.get(Calendar.DAY_OF_MONTH)); + } else { + return new DateTimeValueImpl(c.get(Calendar.YEAR), + c.get(Calendar.MONTH) + 1, + c.get(Calendar.DAY_OF_MONTH), + h, + m, + s); + } + } + + private DateIteratorFactory() { + // uninstantiable + } +} diff --git a/src/main/java/com/google/ical/compat/javautil/package.html b/src/main/java/com/google/ical/compat/javautil/package.html new file mode 100644 index 0000000..b9a9fc7 --- /dev/null +++ b/src/main/java/com/google/ical/compat/javautil/package.html @@ -0,0 +1,3 @@ + +

A compatability layer that produces java Date instances.

+ diff --git a/src/main/java/com/google/ical/compat/jodatime/DateTimeIterable.java b/src/main/java/com/google/ical/compat/jodatime/DateTimeIterable.java new file mode 100644 index 0000000..849e813 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/DateTimeIterable.java @@ -0,0 +1,28 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import org.joda.time.DateTime; + +/** + * an iterable over dates in order. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface DateTimeIterable extends Iterable { + + DateTimeIterator iterator(); + +} diff --git a/src/main/java/com/google/ical/compat/jodatime/DateTimeIterator.java b/src/main/java/com/google/ical/compat/jodatime/DateTimeIterator.java new file mode 100644 index 0000000..c6753dd --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/DateTimeIterator.java @@ -0,0 +1,30 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import org.joda.time.DateTime; +import org.joda.time.ReadableDateTime; +import java.util.Iterator; + +public interface DateTimeIterator extends Iterator { + + /** + * skips all dates in the series before the given date. + * + * @param newStart non null. + */ + void advanceTo(ReadableDateTime newStart); + +} diff --git a/src/main/java/com/google/ical/compat/jodatime/DateTimeIteratorFactory.java b/src/main/java/com/google/ical/compat/jodatime/DateTimeIteratorFactory.java new file mode 100644 index 0000000..51ee142 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/DateTimeIteratorFactory.java @@ -0,0 +1,144 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import com.google.ical.iter.RecurrenceIterable; +import com.google.ical.iter.RecurrenceIterator; +import com.google.ical.iter.RecurrenceIteratorFactory; +import com.google.ical.values.DateTimeValueImpl; +import com.google.ical.values.DateValue; +import com.google.ical.values.TimeValue; +import java.text.ParseException; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.joda.time.ReadableDateTime; + +/** + * a factory for converting RRULEs and RDATEs into + * Iterator<DateTime> and + * Iterable<DateTime>. + * + * @see RecurrenceIteratorFactory + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class DateTimeIteratorFactory { + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single date time iterator. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param tzid the local timezone -- used to interpret start and any dates in + * RDATE and EXDATE lines that don't have TZID params. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static DateTimeIterator createDateTimeIterator( + String rdata, ReadableDateTime start, DateTimeZone tzid, boolean strict) + throws ParseException { + return new RecurrenceIteratorWrapper( + RecurrenceIteratorFactory.createRecurrenceIterator( + rdata, dateTimeToDateValue(start.toDateTime().withZone(tzid)), + TimeZoneConverter.toTimeZone(tzid), strict)); + } + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single date time iterable. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param tzid the local timezone -- used to interpret start and any dates in + * RDATE and EXDATE lines that don't have TZID params. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static DateTimeIterable createDateTimeIterable( + String rdata, ReadableDateTime start, DateTimeZone tzid, boolean strict) + throws ParseException { + return new RecurrenceIterableWrapper( + RecurrenceIteratorFactory.createRecurrenceIterable( + rdata, dateTimeToDateValue(start.toDateTime().withZone(tzid)), + TimeZoneConverter.toTimeZone(tzid), strict)); + } + + /** + * creates a date-time iterator given a recurrence iterator from + * {@link com.google.ical.iter.RecurrenceIteratorFactory}. + */ + public static DateTimeIterator createDateTimeIterator( + RecurrenceIterator rit) { + return new RecurrenceIteratorWrapper(rit); + } + + private static final class RecurrenceIterableWrapper + implements DateTimeIterable { + private final RecurrenceIterable it; + + public RecurrenceIterableWrapper(RecurrenceIterable it) { this.it = it; } + + public DateTimeIterator iterator() { + return new RecurrenceIteratorWrapper(it.iterator()); + } + } + + private static final class RecurrenceIteratorWrapper + implements DateTimeIterator { + private final RecurrenceIterator it; + RecurrenceIteratorWrapper(RecurrenceIterator it) { this.it = it; } + public boolean hasNext() { return it.hasNext(); } + public DateTime next() { return dateValueToDateTime(it.next()); } + public void remove() { throw new UnsupportedOperationException(); } + public void advanceTo(ReadableDateTime d) { + DateTime dUtc = d.toDateTime().withZone(DateTimeZone.UTC); + it.advanceTo(dateTimeToDateValue(dUtc)); + } + } + + static DateTime dateValueToDateTime(DateValue dvUtc) { + if (dvUtc instanceof TimeValue) { + TimeValue tvUtc = (TimeValue) dvUtc; + return new DateTime( + dvUtc.year(), + dvUtc.month(), // java.util's dates are zero-indexed + dvUtc.day(), + tvUtc.hour(), + tvUtc.minute(), + tvUtc.second(), + 0, + DateTimeZone.UTC); + } else { + return new DateTime( + dvUtc.year(), + dvUtc.month(), // java.util's dates are zero-indexed + dvUtc.day(), + 0, + 0, + 0, + 0, + DateTimeZone.UTC); + } + } + + static DateValue dateTimeToDateValue(ReadableDateTime dt) { + return new DateTimeValueImpl( + dt.getYear(), dt.getMonthOfYear(), dt.getDayOfMonth(), + dt.getHourOfDay(), dt.getMinuteOfHour(), dt.getSecondOfMinute()); + } + + private DateTimeIteratorFactory() { + // uninstantiable + } +} diff --git a/src/main/java/com/google/ical/compat/jodatime/LocalDateIterable.java b/src/main/java/com/google/ical/compat/jodatime/LocalDateIterable.java new file mode 100644 index 0000000..4476ee3 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/LocalDateIterable.java @@ -0,0 +1,28 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import org.joda.time.LocalDate; + +/** + * an iterable over dates in order. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface LocalDateIterable extends Iterable { + + LocalDateIterator iterator(); + +} diff --git a/src/main/java/com/google/ical/compat/jodatime/LocalDateIterator.java b/src/main/java/com/google/ical/compat/jodatime/LocalDateIterator.java new file mode 100644 index 0000000..4469358 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/LocalDateIterator.java @@ -0,0 +1,29 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import org.joda.time.LocalDate; +import java.util.Iterator; + +public interface LocalDateIterator extends Iterator { + + /** + * skips all dates in the series before the given date. + * + * @param newStart non null. + */ + void advanceTo(LocalDate newStart); + +} diff --git a/src/main/java/com/google/ical/compat/jodatime/LocalDateIteratorFactory.java b/src/main/java/com/google/ical/compat/jodatime/LocalDateIteratorFactory.java new file mode 100644 index 0000000..c1ea620 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/LocalDateIteratorFactory.java @@ -0,0 +1,150 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import com.google.ical.iter.RecurrenceIterable; +import com.google.ical.iter.RecurrenceIterator; +import com.google.ical.iter.RecurrenceIteratorFactory; +import com.google.ical.values.DateValue; +import com.google.ical.values.DateValueImpl; +import java.text.ParseException; +import org.joda.time.DateTimeZone; +import org.joda.time.LocalDate; + +/** + * a factory for converting RRULEs and RDATEs into + * Iterator<LocalDate> and + * Iterable<LocalDate>. + * + * @see RecurrenceIteratorFactory + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class LocalDateIteratorFactory { + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single local date iterator. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param tzid the local timezone -- used to interpret any dates in RDATE and + * EXDATE lines that don't have TZID params. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static LocalDateIterator createLocalDateIterator( + String rdata, LocalDate start, DateTimeZone tzid, boolean strict) + throws ParseException { + return new RecurrenceIteratorWrapper( + RecurrenceIteratorFactory.createRecurrenceIterator( + rdata, localDateToDateValue(start), + TimeZoneConverter.toTimeZone(tzid), strict)); + } + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single local date iterator. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static LocalDateIterator createLocalDateIterator( + String rdata, LocalDate start, boolean strict) + throws ParseException { + return createLocalDateIterator(rdata, start, DateTimeZone.UTC, strict); + } + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single local date iterable. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param tzid the local timezone -- used to interpret any dates in RDATE and + * EXDATE lines that don't have TZID params. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static LocalDateIterable createLocalDateIterable( + String rdata, LocalDate start, DateTimeZone tzid, boolean strict) + throws ParseException { + return new RecurrenceIterableWrapper( + RecurrenceIteratorFactory.createRecurrenceIterable( + rdata, localDateToDateValue(start), + TimeZoneConverter.toTimeZone(tzid), strict)); + } + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single local date iterable. + * @param rdata RRULE, EXRULE, RDATE, and EXDATE lines. + * @param start the first occurrence of the series. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static LocalDateIterable createLocalDateIterable( + String rdata, LocalDate start, boolean strict) + throws ParseException { + return createLocalDateIterable(rdata, start, DateTimeZone.UTC, strict); + } + + /** + * creates a local date iterator given a recurrence iterator from + * {@link com.google.ical.iter.RecurrenceIteratorFactory}. + */ + public static LocalDateIterator createLocalDateIterator( + RecurrenceIterator rit) { + return new RecurrenceIteratorWrapper(rit); + } + + private static final class RecurrenceIterableWrapper + implements LocalDateIterable { + private final RecurrenceIterable it; + + public RecurrenceIterableWrapper(RecurrenceIterable it) { this.it = it; } + + public LocalDateIterator iterator() { + return new RecurrenceIteratorWrapper(it.iterator()); + } + } + + private static final class RecurrenceIteratorWrapper + implements LocalDateIterator { + private final RecurrenceIterator it; + RecurrenceIteratorWrapper(RecurrenceIterator it) { this.it = it; } + public boolean hasNext() { return it.hasNext(); } + public LocalDate next() { return dateValueToLocalDate(it.next()); } + public void remove() { throw new UnsupportedOperationException(); } + public void advanceTo(LocalDate d) { + // we need to treat midnight as a date value so that passing in + // dateValueToDate() will not advance past any + // occurrences of some-date-value in the iterator. + it.advanceTo(localDateToDateValue(d)); + } + } + + static LocalDate dateValueToLocalDate(DateValue dvUtc) { + return new LocalDate(dvUtc.year(), dvUtc.month(), dvUtc.day()); + } + + static DateValue localDateToDateValue(LocalDate date) { + return new DateValueImpl( + date.getYear(), date.getMonthOfYear(), date.getDayOfMonth()); + } + + private LocalDateIteratorFactory() { + // uninstantiable + } +} diff --git a/src/main/java/com/google/ical/compat/jodatime/TimeZoneConverter.java b/src/main/java/com/google/ical/compat/jodatime/TimeZoneConverter.java new file mode 100644 index 0000000..0e0d976 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/TimeZoneConverter.java @@ -0,0 +1,166 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.compat.jodatime; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.SimpleTimeZone; +import java.util.TimeZone; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; + +/** + * Replacement for Joda-time's broken {@link DateTimeZone#toTimeZone} which + * returns a java.util.TimeZone that supposedly is equivalent to + * the DateTimeZone. + * Joda time's implementation simply uses the ID to look up the corresponding + * java.util.TimeZones which should not be used since they're + * frequently out-of-date re Brazilian timezones. + * + *

See Sun bug 4328058. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class TimeZoneConverter { + + static final int MILLISECONDS_PER_SECOND = 1000; + static final int MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND; + static final int MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE; + + /** + * return a java.util.Timezone object that delegates to + * the given Joda DateTimeZone. + */ + public static TimeZone toTimeZone(final DateTimeZone dtz) { + + TimeZone tz = new TimeZone() { + @Override + public void setRawOffset(int n) { + throw new UnsupportedOperationException(); + } + @Override + public boolean useDaylightTime() { + long firstTransition = 0L; + return firstTransition != dtz.nextTransition(firstTransition); + } + @Override + public boolean inDaylightTime(Date d) { + long t = d.getTime(); + return dtz.getStandardOffset(t) != dtz.getOffset(t); + } + @Override + public int getRawOffset() { + return dtz.getStandardOffset(0); + } + @Override + public int getOffset(long instant) { + // This method is not abstract, but it normally calls through to the + // method below. + // It's optimized here since there's a direct equivalent in + // DateTimeZone. + // DateTimeZone and java.util.TimeZone use the same + // epoch so there's no translation of instant required. + return dtz.getOffset(instant); + } + @Override + public int getOffset( + int era, int year, int month, int day, int dayOfWeek, + int milliseconds) { + int millis = milliseconds; // milliseconds is day in standard time + int hour = millis / MILLISECONDS_PER_HOUR; + millis %= MILLISECONDS_PER_HOUR; + int minute = millis / MILLISECONDS_PER_MINUTE; + millis %= MILLISECONDS_PER_MINUTE; + int second = millis / MILLISECONDS_PER_SECOND; + millis %= MILLISECONDS_PER_SECOND; + if (era == GregorianCalendar.BC) { year = -(year - 1); } + + // get the time in UTC in case a timezone has changed it's standard + // offset, e.g. rid of a half hour from UTC. + DateTime dt = null; + try { + dt = new DateTime(year, month + 1, day, hour, minute, + second, millis, dtz); + } catch (IllegalArgumentException ex) { + // Java does not complain if you try to convert a Date that does not + // exist due to the offset shifting forward, but Joda time does. + // Since we're trying to preserve the semantics of TimeZone, shift + // forward over the gap so that we're on a time that exists. + // This assumes that the DST correction is one hour long or less. + if (hour < 23) { + dt = new DateTime(year, month + 1, day, hour + 1, minute, + second, millis, dtz); + } else { // Some timezones shift at midnight. + Calendar c = new GregorianCalendar(); + c.clear(); + c.setTimeZone(TimeZone.getTimeZone("UTC")); + c.set(year, month, day, hour, minute, second); + c.add(Calendar.HOUR_OF_DAY, 1); + int year2 = c.get(Calendar.YEAR), + month2 = c.get(Calendar.MONTH), + day2 = c.get(Calendar.DAY_OF_MONTH), + hour2 = c.get(Calendar.HOUR_OF_DAY); + dt = new DateTime(year2, month2 + 1, day2, hour2, minute, + second, millis, dtz); + } + } + // since millis is in standard time, we construct the equivalent + // GMT+xyz timezone and use that to convert. + int offset = dtz.getStandardOffset(dt.getMillis()); + DateTime stdDt = new DateTime( + year, month + 1, day, hour, minute, + second, millis, DateTimeZone.forOffsetMillis(offset)); + return getOffset(stdDt.getMillis()); + } + + @Override + public String toString() { + return dtz.toString(); + } + + private static final long serialVersionUID = 58752546800455L; + }; + // Now fix the tzids. DateTimeZone has a bad habit of returning + // "+06:00" when it should be "GMT+06:00" + String newTzid = cleanUpTzid(dtz.getID()); + tz.setID(newTzid); + return tz; + } + + /** + * If tzid is of the form [+-]hh:mm, we rewrite it to GMT[+-]hh:mm + * Otherwise return it unchanged. + */ + static String cleanUpTzid(String tzid) { + if ("".equals(tzid)) { return "GMT"; } + switch (tzid.charAt(0)) { + case '+': case '-': + return "GMT" + tzid; + case '0': case '1': case '2': case '3': case '4': + case '5': case '6': case '7': case '8': case '9': + return "GMT+" + tzid; + default: + return tzid; + } + } + + private TimeZoneConverter() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/compat/jodatime/package.html b/src/main/java/com/google/ical/compat/jodatime/package.html new file mode 100644 index 0000000..99d97a9 --- /dev/null +++ b/src/main/java/com/google/ical/compat/jodatime/package.html @@ -0,0 +1,20 @@ + +

Joda time compatability layer.

+ +

RFC 2445 allows mixing of dates, date-times (and periods) within the same +recurrence description. Joda Time makes a clear distinction between dates and +times, so we provide two iterator factories, one for LocalDates and another for DateTimes.

+ +

If you need to blur the distinction between +LocalDateIterator and +DateTimeIterator you can +downcast them to Iterator<? extends ReadablePartial>.

+ + diff --git a/src/main/java/com/google/ical/iter/CompoundIteratorImpl.java b/src/main/java/com/google/ical/iter/CompoundIteratorImpl.java new file mode 100644 index 0000000..7e36ec4 --- /dev/null +++ b/src/main/java/com/google/ical/iter/CompoundIteratorImpl.java @@ -0,0 +1,226 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateValue; +import java.util.Collection; +import java.util.Comparator; +import java.util.NoSuchElementException; +import java.util.PriorityQueue; + +/** + * a recurrence iterator that combines others. Some may be inclusions, and + * some may be exclusions. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class CompoundIteratorImpl implements RecurrenceIterator { + + /** a queue that keeps the earliest dates at the head */ + private PriorityQueue queue; + private HeapElement pending; + /** + * the number of inclusions on queue. We keep track of this so that we + * don't have to drain the exclusions to conclude that the series is + * exhausted. + */ + private int nInclusionsRemaining; + + /** + * A generator that will generate only dates that are generated by inclusions + * and will not generate any dates that are generated by exclusions -- i.e. + * exclusions trump inclusions. + * @param inclusions iterators whose elements should be included unless + * explicitly excluded. non null without null elements. + * @param exclusions iterators whose elements should not be included. + * non null without null elements. + */ + CompoundIteratorImpl( + Collection inclusions, + Collection exclusions) { + queue = new PriorityQueue( + inclusions.size() + exclusions.size(), HeapElement.CMP); + for (RecurrenceIterator it : inclusions) { + HeapElement el = new HeapElement(true, it); + if (el.shift()) { + queue.add(el); + ++nInclusionsRemaining; + } + } + for (RecurrenceIterator it : exclusions) { + HeapElement el = new HeapElement(false, it); + if (el.shift()) { queue.add(el); } + } + } + + public boolean hasNext() { + requirePending(); + return null != pending; + } + + public DateValue next() { + requirePending(); + if (null == pending) { throw new NoSuchElementException(); } + DateValue head = pending.head(); + reattach(pending); + pending = null; + return head; + } + + public void remove() { throw new UnsupportedOperationException(); } + + public void advanceTo(DateValue newStart) { + long newStartCmp = DateValueComparison.comparable(newStart); + if (null != pending) { + if (pending.comparable() >= newStartCmp) { return; } + pending.advanceTo(newStart); + reattach(pending); + pending = null; + } + + // Pull each element off the stack in turn, and advance it. + // Once we reach one we don't need to advance, we're done + while (0 != nInclusionsRemaining && !queue.isEmpty() + && queue.peek().comparable() < newStartCmp) { + HeapElement el = queue.poll(); + el.advanceTo(newStart); + reattach(el); + } + } + + /** + * if the given element's iterator has more data, then push back onto the + * heap. + */ + private void reattach(HeapElement el) { + if (el.shift()) { + queue.add(el); + } else if (el.inclusion) { + // if we have no live inclusions, then the rest are exclusions which we + // can safely discard. + if (0 == --nInclusionsRemaining) { + queue.clear(); + } + } + } + + /** + * make sure that pending contains the next inclusive HeapElement that doesn't + * match any exclusion, and remove any dupes of it. + */ + private void requirePending() { + if (null != pending) { return; } + + long exclusionComparable = Long.MIN_VALUE; + while (0 != nInclusionsRemaining && !queue.isEmpty()) { + // find a candidate that is not excluded + HeapElement inclusion = null; + do { + HeapElement candidate = queue.poll(); + if (candidate.inclusion) { + if (exclusionComparable != candidate.comparable()) { + inclusion = candidate; + break; + } + } else { + exclusionComparable = candidate.comparable(); + } + reattach(candidate); + if (0 == nInclusionsRemaining) { return; } + } while (!queue.isEmpty()); + long inclusionComparable = inclusion.comparable(); + + // Check for any following exclusions and for duplicates. + // We could change the sort order so that exclusions always preceded + // inclusions, but that would be less efficient and would make the + // ordering different than the comparable value. + boolean excluded = exclusionComparable == inclusionComparable; + while (!queue.isEmpty() + && queue.peek().comparable() == inclusionComparable) { + HeapElement match = queue.poll(); + excluded |= !match.inclusion; + reattach(match); + if (0 == nInclusionsRemaining) { return; } + } + if (!excluded) { + pending = inclusion; + return; + } else { + reattach(inclusion); + } + } + } + +} + +final class HeapElement { + /** + * should iterators items be included in the series or should they + * nullify any matched items included by other series. + */ + final boolean inclusion; + /** the {@link DateValueComparison#comparable} for {@link #head}. */ + private long comparable; + /** the last value removed from it. In utc. */ + private DateValue head; + private RecurrenceIterator it; + + HeapElement(boolean inclusion, RecurrenceIterator it) { + this.inclusion = inclusion; + this.it = it; + } + + /** the last value removed from the iterator. */ + DateValue head() { return head; } + /** + * A given HeapElement may be compared to many others as it bubbles towards + * the heap's root, so we cache this for each HeapElement. + */ + long comparable() { return comparable; } + /** + * discard the current, and return true iff there is another head to + * replace it. + */ + boolean shift() { + if (!it.hasNext()) { return false; } + head = it.next(); + comparable = DateValueComparison.comparable(head); + return true; + } + + /** + * advance the underlying iterator to the given date value a la + * {@link RecurrenceIterator#advanceTo}. + */ + void advanceTo(DateValue dvUtc) { + it.advanceTo(dvUtc); + } + + @Override + public String toString() { + return + "[" + head.toString() + (inclusion ? ", inclusion]" : ", exclusion]"); + } + + /** compares to heap elements by comparing their heads. */ + static Comparator CMP = new Comparator() { + public int compare(HeapElement a, HeapElement b) { + long ac = a.comparable(), + bc = b.comparable(); + return ac < bc ? -1 : ac == bc ? 0 : 1; + } + }; + +} diff --git a/src/main/java/com/google/ical/iter/Conditions.java b/src/main/java/com/google/ical/iter/Conditions.java new file mode 100644 index 0000000..b9296da --- /dev/null +++ b/src/main/java/com/google/ical/iter/Conditions.java @@ -0,0 +1,52 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.util.Predicate; +import com.google.ical.values.DateValue; + +/** + * factory for predicates used to test whether a recurrence is over. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class Conditions { + + /** constructs a condition that fails after passing count dates. */ + static Predicate countCondition(final int count) { + return new Predicate() { + int count_ = count; + public boolean apply(DateValue _x) { + return --count_ >= 0; + } + }; + } + + /** + * constructs a condition that passes for every date on or before until. + * @param until non null. + */ + static Predicate untilCondition(final DateValue until) { + return new Predicate() { + public boolean apply(DateValue date) { + return date.compareTo(until) <= 0; + } + }; + } + + // uninstantiable + private Conditions() {} + +} diff --git a/src/main/java/com/google/ical/iter/DateValueComparison.java b/src/main/java/com/google/ical/iter/DateValueComparison.java new file mode 100644 index 0000000..58a12de --- /dev/null +++ b/src/main/java/com/google/ical/iter/DateValueComparison.java @@ -0,0 +1,86 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateValue; +import com.google.ical.values.TimeValue; + +/** + * DateValue comparison methods. + *

When we're pulling dates off the priority order, we need them to come off + * in a consistent order, so we need a total ordering on date values. + *

This means that a DateValue with no time must not be equal to a + * DateTimeValue at midnight. Since it obviously doesn't make sense for a + * DateValue to be after a DateTimeValue the same day at 23:59:59, we put the + * DateValue before 0 hours of the same day. + *

If we didn't have a total ordering, then it would be harder to correctly + * handle the case + *

+ *   RDATE:20060607
+ *   EXDATE:20060607
+ *   EXDATE:20060607T000000Z
+ * 
+ * because we'd have two exdates that are equal according to the comparison, but + * only the first should match. + *

In the following example + *

+ *   RDATE:20060607
+ *   RDATE:20060607T000000Z
+ *   EXDATE:20060607
+ * 
+ * the problem is worse because we may pull a candidate RDATE off the + * priority queue and then not know whether to consume the EXDATE or not. + *

Absent a total ordering, the following case could only be solved with + * lookahead and ugly logic. + *

+ *   RDATE:20060607
+ *   RDATE:20060607T000000Z
+ *   EXDATE:20060607
+ *   EXDATE:20060607T000000Z
+ * 
+ *

The conversion to GMT is also an implementation detail, so it's not clear + * which timezone we should consider midnight in, and a total ordering allows + * us to avoid timezone conversions during iteration.

+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class DateValueComparison { + + /** + * reduces a date to a value that can be easily compared to others, consistent + * with {@link com.google.ical.values.DateValueImpl#compareTo}. + */ + static long comparable(DateValue dv) { + long comp = (((((long) dv.year()) << 4) + dv.month()) << 5) + dv.day(); + if (dv instanceof TimeValue) { + TimeValue tv = (TimeValue) dv; + // We add 1 to comparable for timed values to make sure that timed + // events are distinct from all-day events, in keeping with + // DateValue.compareTo. + + // It would be odd if an all day exclusion matched a midnight event on + // the same day, but not one at another time of day. + return (((((comp << 5) + tv.hour()) << 6) + tv.minute()) << 6) + + tv.second() + 1; + } else { + return comp << 17; + } + } + + private DateValueComparison() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/iter/Filters.java b/src/main/java/com/google/ical/iter/Filters.java new file mode 100644 index 0000000..53dbe2e --- /dev/null +++ b/src/main/java/com/google/ical/iter/Filters.java @@ -0,0 +1,159 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateValue; +import com.google.ical.values.Weekday; +import com.google.ical.values.WeekdayNum; +import com.google.ical.util.DTBuilder; +import com.google.ical.util.Predicate; +import com.google.ical.util.TimeUtils; + + +/** + * predicates used to filter out dates produced by a generator that do not + * pass some secondary criterion. For example, the recurrence rule + * FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13 should generate every friday the + * 13th. It is implemented as a generator that generates the 13th of every + * month -- a byMonthDay generator, and then the results of that are filtered + * by a byDayFilter that tests whether the date falls on Friday. + * + *

A filter returns true to indicate the item is included in the + * recurrence.

+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +class Filters { + + /** + * constructs a day filter based on a BYDAY rule. + * @param days non null + * @param weeksInYear are the week numbers meant to be weeks in the + * current year, or weeks in the current month. + */ + static Predicate byDayFilter( + final WeekdayNum[] days, final boolean weeksInYear, final Weekday wkst) { + return new Predicate() { + public boolean apply(DateValue date) { + Weekday dow = Weekday.valueOf(date); + + int nDays; + // first day of the week in the given year or month + Weekday dow0; + // where does date appear in the year or month? + // in [0, lengthOfMonthOrYear - 1] + int instance; + if (weeksInYear) { + nDays = TimeUtils.yearLength(date.year()); + dow0 = Weekday.firstDayOfWeekInMonth(date.year(), 1); + instance = TimeUtils.dayOfYear( + date.year(), date.month(), date.day()); + } else { + nDays = TimeUtils.monthLength(date.year(), date.month()); + dow0 = Weekday.firstDayOfWeekInMonth(date.year(), date.month()); + instance = date.day() - 1; + } + + // which week of the year or month does this date fall on? + // one-indexed + int dateWeekNo; + if (wkst.javaDayNum <= dow.javaDayNum) { + dateWeekNo = 1 + (instance / 7); + } else { + dateWeekNo = (instance / 7); + } + + // TODO(msamuel): according to section 4.3.10 + // Week number one of the calendar year is the first week which + // contains at least four (4) days in that calendar year. This + // rule part is only valid for YEARLY rules. + // That's mentioned under the BYWEEKNO rule, and there's no mention + // of it in the earlier discussion of the BYDAY rule. + // Does it apply to yearly week numbers calculated for BYDAY rules in + // a FREQ=YEARLY rule? + + for (int i = days.length; --i >= 0;) { + WeekdayNum day = days[i]; + + if (day.wday == dow) { + int weekNo = day.num; + if (0 == weekNo) { return true; } + + if (weekNo < 0) { + weekNo = Util.invertWeekdayNum(day, dow0, nDays); + } + + if (dateWeekNo == weekNo) { return true; } + } + } + return false; + } + }; + } + + /** + * constructs a day filter based on a BYDAY rule. + * @param monthDays days of the month in [-31, 31] != 0 + */ + static Predicate byMonthDayFilter(final int[] monthDays) { + return new Predicate() { + public boolean apply(DateValue date) { + int nDays = TimeUtils.monthLength(date.year(), date.month()); + for (int i = monthDays.length; --i >= 0;) { + int day = monthDays[i]; + if (day < 0) { day += nDays + 1; } + if (day == date.day()) { return true; } + } + return false; + } + }; + } + + /** + * constructs a filter that accepts only every interval-th week from the week + * containing dtStart. + * @param interval > 0 number of weeks + * @param wkst day of the week that the week starts on. + * @param dtStart non null + */ + static Predicate weekIntervalFilter( + final int interval, final Weekday wkst, final DateValue dtStart) { + return new Predicate() { + DateValue wkStart; + { + // the latest day with day of week wkst on or before dtStart + DTBuilder wkStartB = new DTBuilder(dtStart); + wkStartB.day -= + (7 + Weekday.valueOf(dtStart).javaDayNum - wkst.javaDayNum) % 7; + wkStart = wkStartB.toDate(); + } + + public boolean apply(DateValue date) { + int daysBetween = TimeUtils.daysBetween(date, wkStart); + if (daysBetween < 0) { + // date must be before dtStart. Shouldn't occur in practice. + daysBetween += (interval * 7 * (1 + daysBetween / (-7 * interval))); + } + int off = (daysBetween / 7) % interval; + return 0 == off; + } + }; + } + + private Filters() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/iter/Generator.java b/src/main/java/com/google/ical/iter/Generator.java new file mode 100644 index 0000000..aaf57df --- /dev/null +++ b/src/main/java/com/google/ical/iter/Generator.java @@ -0,0 +1,78 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.util.DTBuilder; + +/** + * a stateful operation that can be successively invoked to generate the next + * part of a date in series. + * + *

Each field generator takes as input the larger fields, and modifies its + * field, leaving the other fields unchanged. + * A year generator will update bldr.year, leaving the smaller fields unchanged, + * a month generator will update bldr.month, taking its cue from bldr.year, + * also leaving the smaller fields unchanged.

+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +abstract class Generator { + + /** + * @param bldr both input and output. non null. modified in place. + * @return true iff there are more instances of the generator's field to + * generate. If a generator is exhausted, generating a new value of a + * larger field may allow it to continue, so a month generator that runs + * out of months at 12, may start over at 1 if called with a bldr with + * a different year. + * @throws IteratorShortCircuitingException when an iterator reaches + * a threshold past which it cannot generate any more dates. This indicates + * that the entire iteration process should end. + */ + abstract boolean generate(DTBuilder bldr) + throws IteratorShortCircuitingException; + + /** + * thrown when an iteration process should be ended completely due to an + * artificial system limit. This allows us to make a distinction between + * normal exhaustion of iteration, and an artificial limit that may fall in + * a set, and so affect subsequent evaluation of BYSETPOS rules. + * + *

Since this class is meant to be thrown as a flow control construct to + * indicate an artificial limit has been reached, not really an exceptional + * condition, and since its clients have no need of the stacktrace, we use a + * singleton to avoid forcing the JVM to unoptimize and decompile the + * RecurrenceIterator's inner loop.

+ */ + static class IteratorShortCircuitingException extends Exception { + private IteratorShortCircuitingException() { + super(); + setStackTrace(new StackTraceElement[0]); + } + + private static final IteratorShortCircuitingException INSTANCE = + new IteratorShortCircuitingException(); + + static IteratorShortCircuitingException instance() { return INSTANCE; } + } + + static { + // suffer the stack trace generation on class load of Generator, which will + // happen before any of the recuriter stuff could possibly have been JIT + // compiled. + IteratorShortCircuitingException.instance(); + } + +} diff --git a/src/main/java/com/google/ical/iter/Generators.java b/src/main/java/com/google/ical/iter/Generators.java new file mode 100644 index 0000000..e3c8976 --- /dev/null +++ b/src/main/java/com/google/ical/iter/Generators.java @@ -0,0 +1,551 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.util.DTBuilder; +import com.google.ical.util.TimeUtils; +import com.google.ical.values.DateValue; +import com.google.ical.values.DateValueImpl; +import com.google.ical.values.Weekday; +import com.google.ical.values.WeekdayNum; +import java.util.Arrays; + +/** + * factory for field generators. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class Generators { + + /** + * the maximum number of years generated between instances. + * See {@link ThrottledGenerator} for a description of the problem this + * solves. + * Note: this counts the maximum number of years generated, so for + * FREQ=YEARLY;INTERVAL=4 the generator would try 100 individual years over + * a span of 400 years before giving up and concluding that the rule generates + * no usable dates. + */ + private static final int MAX_YEARS_BETWEEN_INSTANCES = 100; + + /** + * constructs a generator that generates years successively counting from the + * first year passed in. + * @param interval number of years to advance each step. + * @param dtStart non null + * @return the year in dtStart the first time called and interval + last + * return value on subsequent calls. + */ + static ThrottledGenerator serialYearGenerator( + final int interval, final DateValue dtStart) { + return new ThrottledGenerator() { + /** the last year seen */ + int year = dtStart.year() - interval; + int throttle = MAX_YEARS_BETWEEN_INSTANCES; + + @Override + boolean generate(DTBuilder builder) + throws IteratorShortCircuitingException { + // make sure things halt even if the rrule is bad. + // Rules like + // FREQ=YEARLY;BYMONTHDAY=30;BYMONTH=2 + // should halt + + if (--throttle < 0) { + throw IteratorShortCircuitingException.instance(); + } + builder.year = year += interval; + return true; + } + + @Override + void workDone() { this.throttle = MAX_YEARS_BETWEEN_INSTANCES; } + + @Override + public String toString() { return "serialYearGenerator:" + interval; } + }; + } + + /** + * constructs a generator that generates months in the given builder's year + * successively counting from the first month passed in. + * @param interval number of months to advance each step. + * @param dtStart non null. + * @return the year in dtStart the first time called and interval + last + * return value on subsequent calls. + */ + static Generator serialMonthGenerator( + final int interval, final DateValue dtStart) { + return new Generator() { + int year = dtStart.year(); + int month = dtStart.month() - interval; + { + while (month < 1) { + month += 12; + --year; + } + } + @Override + boolean generate(DTBuilder builder) { + int nmonth; + if (year != builder.year) { + int monthsBetween = (builder.year - year) * 12 - (month - 1); + nmonth = ((interval - (monthsBetween % interval)) % interval) + 1; + if (nmonth > 12) { + // don't update year so that the difference calculation above is + // correct when this function is reentered with a different year + return false; + } + year = builder.year; + } else { + nmonth = month + interval; + if (nmonth > 12) { + return false; + } + } + month = builder.month = nmonth; + return true; + } + + @Override + public String toString() { return "serialMonthGenerator:" + interval; } + }; + } + + /** + * constructs a generator that generates every day in the current month that + * is an integer multiple of interval days from dtStart. + */ + static Generator serialDayGenerator( + final int interval, final DateValue dtStart) { + return new Generator() { + int year, month, date; + /** ndays in the last month encountered */ + int nDays; + + { + // step back one interval + DTBuilder dtStartMinus1B = new DTBuilder(dtStart); + dtStartMinus1B.day -= interval; + DateValue dtStartMinus1 = dtStartMinus1B.toDate(); + year = dtStartMinus1.year(); + month = dtStartMinus1.month(); + date = dtStartMinus1.day(); + nDays = TimeUtils.monthLength(year, month); + } + + @Override + boolean generate(DTBuilder builder) { + int ndate; + if (year == builder.year && month == builder.month) { + ndate = date + interval; + if (ndate > nDays) { + return false; + } + } else { + nDays = TimeUtils.monthLength(builder.year, builder.month); + if (interval != 1) { + // Calculate the number of days between the first of the new + // month andthe old date and extend it to make it an integer + // multiple of interval + int daysBetween = TimeUtils.daysBetween( + new DateValueImpl(builder.year, builder.month, 1), + new DateValueImpl(year, month, date)); + ndate = ((interval - (daysBetween % interval)) % interval) + 1; + if (ndate > nDays) { + // need to early out without updating year or month so that the + // next time we enter, with a different month, the daysBetween + // call above compares against the proper last date + return false; + } + } else { + ndate = 1; + } + year = builder.year; + month = builder.month; + } + date = builder.day = ndate; + return true; + } + + @Override + public String toString() { return "serialDayGenerator:" + interval; } + }; + } + + /** + * constructs a generator that yields the specified years in increasing order. + */ + static Generator byYearGenerator(int[] years, final DateValue dtStart) { + final int[] uyears = Util.uniquify(years); + + // index into years + return new Generator() { + int i; + { + while (i < uyears.length && dtStart.year() > uyears[i]) { ++i; } + } + + @Override + boolean generate(DTBuilder builder) { + if (i >= uyears.length) { return false; } + builder.year = uyears[i++]; + return true; + } + + @Override + public String toString() { return "byYearGenerator"; } + }; + } + + /** + * constructs a generator that yields the specified months in increasing order + * for each year. + * @param months values in [1-12] + * @param dtStart non null + */ + static Generator byMonthGenerator(int[] months, final DateValue dtStart) { + final int[] umonths = Util.uniquify(months); + + return new Generator() { + int i; + int year = dtStart.year(); + + @Override + boolean generate(DTBuilder builder) { + if (year != builder.year) { + i = 0; + year = builder.year; + } + if (i >= umonths.length) { return false; } + builder.month = umonths[i++]; + return true; + } + + @Override + public String toString() { return "byMonthGenerator"; } + }; + } + + /** + * constructs a function that yields the specified dates + * (possibly relative to end of month) in increasing order + * for each month seen. + * @param dates elements in [-53,53] != 0 + * @param dtStart non null + */ + static Generator byMonthDayGenerator(int[] dates, final DateValue dtStart) { + final int[] udates = Util.uniquify(dates); + + return new Generator() { + int year = dtStart.year(); + int month = dtStart.month(); + /** list of generated dates for the current month */ + int[] posDates; + /** index of next date to return */ + int i = 0; + + { + convertDatesToAbsolute(); + } + + private void convertDatesToAbsolute() { + IntSet posDates = new IntSet(); + int nDays = TimeUtils.monthLength(year, month); + for (int j = 0; j < udates.length; ++j) { + int date = udates[j]; + if (date < 0) { + date += nDays + 1; + } + if (date >= 1 && date <= nDays) { + posDates.add(date); + } + } + this.posDates = posDates.toIntArray(); + } + + @Override + boolean generate(DTBuilder builder) { + if (year != builder.year || month != builder.month) { + year = builder.year; + month = builder.month; + + convertDatesToAbsolute(); + + i = 0; + } + if (i >= posDates.length) { return false; } + builder.day = posDates[i++]; + return true; + } + + @Override + public String toString() { return "byMonthDayGenerator"; } + }; + } + + /** + * constructs a day generator based on a BYDAY rule. + * + * @param days day of week, number pairs, + * e.g. SU,3MO means every sunday and the 3rd monday. + * @param weeksInYear are the week numbers meant to be weeks in the + * current year, or weeks in the current month. + * @param dtStart non null + */ + static Generator byDayGenerator( + WeekdayNum[] days, final boolean weeksInYear, final DateValue dtStart) { + final WeekdayNum[] udays = days.clone(); + + return new Generator() { + int year = dtStart.year(); + int month = dtStart.month(); + /** list of generated dates for the current month */ + int[] dates; + /** index of next date to return */ + int i = 0; + + { generateDates(); } + + void generateDates() { + int nDays; + Weekday dow0; + int nDaysInMonth = TimeUtils.monthLength(year, month); + // index of the first day of the month in the month or year + int d0; + + if (weeksInYear) { + nDays = TimeUtils.yearLength(year); + dow0 = Weekday.firstDayOfWeekInMonth(year, 1); + d0 = TimeUtils.dayOfYear(year, month, 1); + } else { + nDays = nDaysInMonth; + dow0 = Weekday.firstDayOfWeekInMonth(year, month); + d0 = 0; + } + + // an index not greater than the first week of the month in the month + // or year + int w0 = d0 / 7; + + // iterate through days and resolve each [week, day of week] pair to a + // day of the month + IntSet udates = new IntSet(); + for (int j = 0; j < udays.length; ++j) { + WeekdayNum day = udays[j]; + if (0 != day.num) { + int date = Util.dayNumToDate( + dow0, nDays, day.num, day.wday, d0, nDaysInMonth); + if (0 != date) { udates.add(date); } + } else { + int wn = w0 + 6; + for (int w = w0; w <= wn; ++w) { + int date = Util.dayNumToDate( + dow0, nDays, w, day.wday, d0, nDaysInMonth); + if (0 != date) { udates.add(date); } + } + } + } + dates = udates.toIntArray(); + } + + @Override + boolean generate(DTBuilder builder) { + if (year != builder.year || month != builder.month) { + year = builder.year; + month = builder.month; + + generateDates(); + // start at the beginning of the month + i = 0; + } + if (i >= dates.length) { return false; } + builder.day = dates[i++]; + return true; + } + + @Override + public String toString() { + return "byDayGenerator:" + Arrays.toString(udays); + } + }; + } + + /** + * constructs a generator that yields each day in the current month that falls + * in one of the given weeks of the year. + * @param weekNos (elements in [-53,53] != 0) week numbers + * @param wkst (in RRULE_WDAY_*) day of the week that the week starts on. + * @param dtStart non null + */ + static Generator byWeekNoGenerator( + int[] weekNos, final Weekday wkst, final DateValue dtStart) { + final int[] uWeekNos = Util.uniquify(weekNos); + + return new Generator() { + int year = dtStart.year(); + int month = dtStart.month(); + /** number of weeks in the last year seen */ + int weeksInYear; + /** dates generated anew for each month seen */ + int[] dates; + /** index into dates */ + int i = 0; + + /** + * day of the year of the start of week 1 of the current year. + * Since week 1 may start on the previous year, this may be negative. + */ + int doyOfStartOfWeek1; + + { + checkYear(); + checkMonth(); + } + + void checkYear() { + // if the first day of jan is wkst, then there are 7. + // if the first day of jan is wkst + 1, then there are 6 + // if the first day of jan is wkst + 6, then there is 1 + Weekday dowJan1 = Weekday.firstDayOfWeekInMonth(year, 1); + int nDaysInFirstWeek = + 7 - ((7 + dowJan1.javaDayNum - wkst.javaDayNum) % 7); + // number of days not in any week + int nOrphanedDays = 0; + // according to RFC 2445 + // Week number one of the calendar year is the first week which + // contains at least four (4) days in that calendar year. + if (nDaysInFirstWeek < 4) { + nOrphanedDays = nDaysInFirstWeek; + nDaysInFirstWeek = 7; + } + + // calculate the day of year (possibly negative) of the start of the + // first week in the year. This day must be of wkst. + doyOfStartOfWeek1 = nDaysInFirstWeek - 7 + nOrphanedDays; + + weeksInYear = (TimeUtils.yearLength(year) - nOrphanedDays + 6) / 7; + } + + void checkMonth() { + // the day of the year of the 1st day in the month + int doyOfMonth1 = TimeUtils.dayOfYear(year, month, 1); + // the week of the year of the 1st day of the month. approximate. + int weekOfMonth = ((doyOfMonth1 - doyOfStartOfWeek1) / 7) + 1; + // number of days in the month + int nDays = TimeUtils.monthLength(year, month); + + // generate the dates in the month + IntSet udates = new IntSet(); + for (int j = 0; j < uWeekNos.length; j++) { + int weekNo = uWeekNos[j]; + if (weekNo < 0) { + weekNo += weeksInYear + 1; + } + if (weekNo >= weekOfMonth - 1 && weekNo <= weekOfMonth + 6) { + for (int d = 0; d < 7; ++d) { + int date = + ((weekNo - 1) * 7 + d + doyOfStartOfWeek1 - doyOfMonth1) + 1; + if (date >= 1 && date <= nDays) { + udates.add(date); + } + } + } + } + dates = udates.toIntArray(); + } + + @Override + boolean generate(DTBuilder builder) { + + // this is a bit odd, since we're generating days within the given + // weeks of the year within the month/year from builder + if (year != builder.year || month != builder.month) { + if (year != builder.year) { + year = builder.year; + checkYear(); + } + month = builder.month; + checkMonth(); + + i = 0; + } + + if (i >= dates.length) { return false; } + builder.day = dates[i++]; + return true; + } + + @Override + public String toString() { return "byWeekNoGenerator"; } + }; + } + + /** + * constructs a day generator that generates dates in the current month that + * fall on one of the given days of the year. + * @param yearDays elements in [-366,366] != 0 + */ + static Generator byYearDayGenerator(int[] yearDays, final DateValue dtStart) { + final int[] uYearDays = Util.uniquify(yearDays); + + return new Generator() { + int year = dtStart.year(); + int month = dtStart.month(); + int[] dates; + int i = 0; + + { checkMonth(); } + + void checkMonth() { + // now, calculate the first week of the month + int doyOfMonth1 = TimeUtils.dayOfYear(year, month, 1); + int nDays = TimeUtils.monthLength(year, month); + int nYearDays = TimeUtils.yearLength(year); + IntSet udates = new IntSet(); + for (int j = 0; j < uYearDays.length; j++) { + int yearDay = uYearDays[j]; + if (yearDay < 0) { yearDay += nYearDays + 1; } + int date = yearDay - doyOfMonth1; + if (date >= 1 && date <= nDays) { udates.add(date); } + } + dates = udates.toIntArray(); + } + + @Override + boolean generate(DTBuilder builder) { + if (year != builder.year || month != builder.month) { + year = builder.year; + month = builder.month; + + checkMonth(); + + i = 0; + } + if (i >= dates.length) { return false; } + builder.day = dates[i++]; + return true; + } + + @Override + public String toString() { return "byYearDayGenerator"; } + }; + } + + private Generators() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/iter/InstanceGenerators.java b/src/main/java/com/google/ical/iter/InstanceGenerators.java new file mode 100644 index 0000000..4aded7c --- /dev/null +++ b/src/main/java/com/google/ical/iter/InstanceGenerators.java @@ -0,0 +1,271 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.util.DTBuilder; +import com.google.ical.util.Predicate; +import com.google.ical.util.TimeUtils; +import com.google.ical.values.Frequency; +import com.google.ical.values.Weekday; +import com.google.ical.values.DateValue; + +import java.util.ArrayList; +import java.util.List; + +/** + * factory for generators that operate on groups of generators to generate full + * dates. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +class InstanceGenerators { + + /** + * a collector that yields each date in the period without doing any set + * collecting. + */ + static Generator serialInstanceGenerator( + final Predicate filter, + final Generator yearGenerator, final Generator monthGenerator, + final Generator dayGenerator) { + return new Generator() { + @Override + public boolean generate(DTBuilder builder) + throws IteratorShortCircuitingException { + // cascade through periods to compute the next date + do { + // until we run out of days in the current month + while (!dayGenerator.generate(builder)) { + // until we run out of months in the current year + while (!monthGenerator.generate(builder)) { + // if there are more years available fetch one + if (!yearGenerator.generate(builder)) { + // otherwise the recurrence is exhausted + return false; + } + } + } + // apply filters to generated dates + } while (!filter.apply(builder.toDate())); + + return true; + } + }; + } + + static Generator bySetPosInstanceGenerator( + int[] setPos, final Frequency freq, final Weekday wkst, + final Predicate filter, + final Generator yearGenerator, final Generator monthGenerator, + final Generator dayGenerator) { + final int[] uSetPos = Util.uniquify(setPos); + + final Generator serialInstanceGenerator = + serialInstanceGenerator( + filter, yearGenerator, monthGenerator, dayGenerator); + + final boolean allPositive; + final int maxPos; + if (false) { + int mp = 0; + boolean ap = true; + for (int i = setPos.length; --i >= 0;) { + if (setPos[i] < 0) { + ap = false; + break; + } + mp = Math.max(setPos[i], mp); + } + maxPos = mp; + allPositive = ap; + } else { + // TODO(msamuel): does this work? + maxPos = uSetPos[uSetPos.length - 1]; + allPositive = uSetPos[0] > 0; + } + + return new Generator() { + DateValue pushback = null; + /** + * Is this the first instance we generate? + * We need to know so that we don't clobber dtStart. + */ + boolean first = true; + /** Do we need to halt iteration once the current set has been used? */ + boolean done = false; + + /** The elements in the current set, filtered by set pos */ + List candidates; + /** + * index into candidates. The number of elements in candidates already + * consumed. + */ + int i; + + @Override + public boolean generate(DTBuilder builder) + throws IteratorShortCircuitingException { + while (null == candidates || i >= candidates.size()) { + if (done) { return false; } + + // (1) Make sure that builder is appropriately initialized so that + // we only generate instances in the next set + + DateValue d0 = null; + if (null != pushback) { + d0 = pushback; + builder.year = d0.year(); + builder.month = d0.month(); + builder.day = d0.day(); + pushback = null; + } else if (!first) { + // we need to skip ahead to the next item since we didn't exhaust + // the last period + switch (freq) { + case YEARLY: + if (!yearGenerator.generate(builder)) { return false; } + // fallthru + case MONTHLY: + while (!monthGenerator.generate(builder)) { + if (!yearGenerator.generate(builder)) { return false; } + } + break; + case WEEKLY: + // consume because just incrementing date doesn't do anything + DateValue nextWeek = + Util.nextWeekStart(builder.toDate(), wkst); + do { + if (!serialInstanceGenerator.generate(builder)) { + return false; + } + } while (builder.compareTo(nextWeek) < 0); + d0 = builder.toDate(); + break; + default: + break; + } + } else { + first = false; + } + + // (2) Build a set of the dates in the year/month/week that match + // the other rule. + List dates = new ArrayList(); + if (null != d0) { dates.add(d0); } + + // Optimization: if min(bySetPos) > 0 then we already have absolute + // positions, so we don't need to generate all of the instances for + // the period. + // This speeds up things like the first weekday of the year: + // RRULE:FREQ=YEARLY;BYDAY=MO,TU,WE,TH,FR,BYSETPOS=1 + // that would otherwise generate 260+ instances per one emitted + // TODO(msamuel): this may be premature. If needed, We could + // improve more generally by inferring a BYMONTH generator based on + // distribution of set positions within the year. + int limit = allPositive ? maxPos : Integer.MAX_VALUE; + + while (limit > dates.size()) { + if (!serialInstanceGenerator.generate(builder)) { + // If we can't generate any, then make sure we return false + // once the instances we have generated are exhausted. + // If this is returning false due to some artificial limit, such + // as the 100 year limit in serialYearGenerator, then we exit + // via an exception because otherwise we would pick the wrong + // elements for some uSetPoses that contain negative elements. + done = true; + } + DateValue d = builder.toDate(); + boolean contained = false; + if (null == d0) { + d0 = d; + contained = true; + } else { + switch (freq) { + case WEEKLY: + int nb = TimeUtils.daysBetween(d, d0); + // Two dates (d, d0) are in the same week + // if there isn't a whole week in between them and the + // later day is later in the week than the earlier day. + contained = + nb < 7 + && ((7 + Weekday.valueOf(d).javaDayNum + - wkst.javaDayNum) % 7) + > ((7 + Weekday.valueOf(d0).javaDayNum + - wkst.javaDayNum) % 7); + break; + case MONTHLY: + contained = + d0.month() == d.month() && d0.year() == d.year(); + break; + case YEARLY: + contained = d0.year() == d.year(); + break; + default: + break; + } + } + if (contained) { + dates.add(d); + } else { + // reached end of the set + pushback = d; // save d so we can use it later + break; + } + } + + // (3) Resolve the positions to absolute positions and order them + int[] absSetPos; + if (allPositive) { + absSetPos = uSetPos; + } else { + IntSet uAbsSetPos = new IntSet(); + for (int j = 0; j < uSetPos.length; ++j) { + int p = uSetPos[j]; + if (p < 0) { p = dates.size() + p + 1; } + uAbsSetPos.add(p); + } + absSetPos = uAbsSetPos.toIntArray(); + } + + candidates = new ArrayList(); + for (int j = 0; j < absSetPos.length; ++j) { + int p = absSetPos[j] - 1; + if (p >= 0 && p < dates.size()) { + candidates.add(dates.get(p)); + } + } + i = 0; + if (candidates.isEmpty()) { + // none in this region, so keep looking + candidates = null; + continue; + } + } + // (5) Emit a date. It will be checked against the end condition and + // dtStart elsewhere + DateValue d = candidates.get(i++); + builder.year = d.year(); + builder.month = d.month(); + builder.day = d.day(); + return true; + } + }; + + } + + private InstanceGenerators() { + // uninstantiable + } +} diff --git a/src/main/java/com/google/ical/iter/IntSet.java b/src/main/java/com/google/ical/iter/IntSet.java new file mode 100644 index 0000000..ea0a0b7 --- /dev/null +++ b/src/main/java/com/google/ical/iter/IntSet.java @@ -0,0 +1,73 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import java.util.BitSet; + +/** + * a set of integers in a small range. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class IntSet { + + BitSet ints = new BitSet(); + + void add(int n) { + ints.set(encode(n)); + } + + int encode(int n) { + return n < 0 ? ((-n << 1) + 1) : (n << 1); + } + + int decode(int i) { + return (i >>> 1) * (-(i & 1) | 1); + } + + boolean contains(int n) { + return ints.get(encode(n)); + } + + int size() { return ints.cardinality(); } + + int[] toIntArray() { + int[] out = new int[ints.cardinality()]; + int a = 0, b = out.length; + for (int i = -1; (i = ints.nextSetBit(i + 1)) >= 0;) { + int n = decode(i); + if (n < 0) { + out[a++] = n; + } else { + out[--b] = n; + } + } + // if it contains -3, -1, 0, 1, 2, 4 + // Then out will be -1, -3, 4, 2, 1, 0 + reverse(out, 0, a); + reverse(out, a, out.length); + + return out; + } + + private static void reverse(int[] arr, int s, int e) { + for (int i = s, j = e; i < --j; ++i) { + int t = arr[i]; + arr[i] = arr[j]; + arr[j] = t; + } + } + +} diff --git a/src/main/java/com/google/ical/iter/RDateIteratorImpl.java b/src/main/java/com/google/ical/iter/RDateIteratorImpl.java new file mode 100644 index 0000000..fce25c3 --- /dev/null +++ b/src/main/java/com/google/ical/iter/RDateIteratorImpl.java @@ -0,0 +1,55 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateValue; + +/** + * a recurrence iterator that iterates over an array of dates. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class RDateIteratorImpl implements RecurrenceIterator { + private int i; + private DateValue[] datesUtc; + + RDateIteratorImpl(DateValue[] datesUtc) { + this.datesUtc = datesUtc.clone(); // defensive copy + assert increasing(datesUtc); // indirectly checks that not-null. + } + + public boolean hasNext() { return i < datesUtc.length; } + + public DateValue next() { return datesUtc[i++]; } + + public void remove() { throw new UnsupportedOperationException(); } + + public void advanceTo(DateValue newStartUtc) { + long startCmp = DateValueComparison.comparable(newStartUtc); + while (i < datesUtc.length + && startCmp > DateValueComparison.comparable(datesUtc[i])) { + ++i; + } + } + + /** monotonically. */ + private static > boolean increasing(C[] els) { + for (int i = els.length; --i >= 1;) { + if (els[i - 1].compareTo(els[i]) > 0) { return false; } + } + return true; + } + +} diff --git a/src/main/java/com/google/ical/iter/RRuleIteratorImpl.java b/src/main/java/com/google/ical/iter/RRuleIteratorImpl.java new file mode 100644 index 0000000..b0de731 --- /dev/null +++ b/src/main/java/com/google/ical/iter/RRuleIteratorImpl.java @@ -0,0 +1,266 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.util.DTBuilder; +import com.google.ical.util.Predicate; +import com.google.ical.util.TimeUtils; +import com.google.ical.values.DateValue; +import com.google.ical.values.DateValueImpl; +import com.google.ical.values.TimeValue; +import java.util.TimeZone; + +/** + * an iterator over dates in an RRULE or EXRULE series. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class RRuleIteratorImpl implements RecurrenceIterator { + /** + * a function that determines when the recurrence ends. + * Takes a date builder and yields shouldContinue:boolean. + * The condition is applied after the date is converted to utc. + */ + private final Predicate condition_; + /** + * a function that applies secondary rules to eliminate some dates. + * Takes a builder and yields isPartOfRecurrence:boolean. + */ + private final Predicate filter_; + /** + * a function that applies the various period generators to generate an entire + * date. + * This may involve generating a set of dates and discarding all but those + * that match the BYSETPOS rule. + */ + private final Generator instanceGenerator_; + /** + * a function that takes a builder and populates the year field. + * Returns false if no more years available. + */ + private final ThrottledGenerator yearGenerator_; + /** + * a function that takes a builder and populates the month field. + * Returns false if no more months available in the builder's year. + */ + private final Generator monthGenerator_; + /** + * a function that takes a builder and populates the day of month field. + * Returns false if no more days available in the builder's month. + */ + private final Generator dayGenerator_; + /** + * a date that has been computed but not yet yielded to the user. + */ + private DateValue pendingUtc_; + /** + * used to build successive dates. + * At the start of the building process, contains the last date generated. + * Different periods are successively inserted into it. + */ + private DTBuilder builder_; + /** true iff the recurrence has been exhausted. */ + private boolean done_; + /** the start date of the recurrence */ + private final DateValue dtStart_; + /** + * false iff shorcutting advance would break the semantics of the iteration. + * This may happen when, for example, the end condition requires that it see + * every item. + */ + private final boolean canShortcutAdvance_; + /** + * the timezone that result dates should be converted from. + * All date fields, parameters, and local variables in this class are in + * the tzid_ timezone, unless they carry the Utc suffix. + */ + private final TimeZone tzid_; + + /** An iterator that generates dates from an RFC2445 Recurrence Rule */ + RRuleIteratorImpl( + DateValue dtStart, TimeZone tzid, Predicate condition, + Predicate filter, + Generator instanceGenerator, ThrottledGenerator yearGenerator, + Generator monthGenerator, Generator dayGenerator, + boolean canShortcutAdvance, TimeValue startTime) { + + this.condition_ = condition; + this.filter_ = filter; + this.instanceGenerator_ = instanceGenerator; + this.yearGenerator_ = yearGenerator; + this.monthGenerator_ = monthGenerator; + this.dayGenerator_ = dayGenerator; + this.dtStart_ = dtStart; + this.tzid_ = tzid; + this.canShortcutAdvance_ = canShortcutAdvance; + + // Initialize the builder and skip over any extraneous start instances + this.builder_ = new DTBuilder(dtStart); + if (null != startTime) { + this.builder_.hour = startTime.hour(); + this.builder_.minute = startTime.minute(); + this.builder_.second = startTime.second(); + } + // Apply the year and month generators so that we can start with the day + // generator on the first call to FetchNext_. + try { + this.yearGenerator_.generate(this.builder_); + this.monthGenerator_.generate(this.builder_); + } catch (Generator.IteratorShortCircuitingException ex) { + this.done_ = true; + } + + while (!this.done_) { + this.pendingUtc_ = this.generateInstance(); + if (null == this.pendingUtc_) { + this.done_ = true; + break; + } else if (this.pendingUtc_.compareTo( + TimeUtils.toUtc(dtStart, tzid)) >= 0) { + // We only apply the condition to the ones past dtStart to avoid + // counting useless instances + if (!this.condition_.apply(this.pendingUtc_)) { + this.done_ = true; + this.pendingUtc_ = null; + } + break; + } + } + } + + /** are there more dates in this recurrence? */ + public boolean hasNext() { + if (null == this.pendingUtc_) { this.fetchNext(); } + return null != this.pendingUtc_; + } + + /** fetch and return the next date in this recurrence. */ + public DateValue next() { + if (null == this.pendingUtc_) { + this.fetchNext(); + } + DateValue next = this.pendingUtc_; + this.pendingUtc_ = null; + return next; + } + + public void remove() { throw new UnsupportedOperationException(); } + + /** + * skip over all instances of the recurrence before the given date, so that + * the next call to {@link #next} will return a date on or after the given + * date, assuming the recurrence includes such a date. + */ + public void advanceTo(DateValue dateUtc) { + DateValue dateLocal = TimeUtils.fromUtc(dateUtc, tzid_); + if (dateLocal.compareTo(this.builder_.toDate()) < 0) { + return; + } + this.pendingUtc_ = null; + + try { + if (this.canShortcutAdvance_) { + // skip years before date.year + if (this.builder_.year < dateLocal.year()) { + do { + if (!this.yearGenerator_.generate(this.builder_)) { + this.done_ = true; + return; + } + } while (this.builder_.year < dateLocal.year()); + while (!this.monthGenerator_.generate(this.builder_)) { + if (!this.yearGenerator_.generate(this.builder_)) { + this.done_ = true; + return; + } + } + } + // skip months before date.year/date.month + while (this.builder_.year == dateLocal.year() + && this.builder_.month < dateLocal.month()) { + while (!this.monthGenerator_.generate(this.builder_)) { + // if there are more years available fetch one + if (!this.yearGenerator_.generate(this.builder_)) { + // otherwise the recurrence is exhausted + this.done_ = true; + return; + } + } + } + } + + // consume any remaining instances + while (!this.done_) { + DateValue dUtc = this.generateInstance(); + if (null == dUtc) { + this.done_ = true; + } else { + if (!this.condition_.apply(dUtc)) { + this.done_ = true; + } else if (dUtc.compareTo(dateUtc) >= 0) { + this.pendingUtc_ = dUtc; + break; + } + } + } + } catch (Generator.IteratorShortCircuitingException ex) { + this.done_ = true; + } + } + + /** calculates and stored the next date in this recurrence. */ + private void fetchNext() { + if (null != this.pendingUtc_ || this.done_) { return; } + + DateValue dUtc = this.generateInstance(); + + // check the exit condition + if (null != dUtc && this.condition_.apply(dUtc)) { + this.pendingUtc_ = dUtc; + this.yearGenerator_.workDone(); + } else { + this.done_ = true; + } + } + + private static final DateValue MIN_DATE = + new DateValueImpl(Integer.MIN_VALUE, 1, 1); + /** + * make sure the iterator is monotonically increasing. + * The local time is guaranteed to be monotonic, but because of daylight + * savings shifts, the time in UTC may not be. + */ + private DateValue lastUtc_ = MIN_DATE; + /** + * @return a date value in UTC. + */ + private DateValue generateInstance() { + try { + do { + if (!this.instanceGenerator_.generate(this.builder_)) { return null; } + // TODO(msamuel): apply byhour, byminute, bysecond rules here + DateValue dUtc = this.dtStart_ instanceof TimeValue + ? TimeUtils.toUtc(this.builder_.toDateTime(), this.tzid_) + : this.builder_.toDate(); + if (dUtc.compareTo(this.lastUtc_) > 0) { + return dUtc; + } + } while (true); + } catch (Generator.IteratorShortCircuitingException ex) { + return null; + } + } + +} diff --git a/src/main/java/com/google/ical/iter/RecurrenceIterable.java b/src/main/java/com/google/ical/iter/RecurrenceIterable.java new file mode 100644 index 0000000..cf8b081 --- /dev/null +++ b/src/main/java/com/google/ical/iter/RecurrenceIterable.java @@ -0,0 +1,28 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateValue; + +/** + * an iterable over date values in order. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface RecurrenceIterable extends Iterable { + + RecurrenceIterator iterator(); + +} diff --git a/src/main/java/com/google/ical/iter/RecurrenceIterator.java b/src/main/java/com/google/ical/iter/RecurrenceIterator.java new file mode 100644 index 0000000..db17839 --- /dev/null +++ b/src/main/java/com/google/ical/iter/RecurrenceIterator.java @@ -0,0 +1,52 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateValue; +import java.util.Iterator; + +/** + * an iterator over date values in order. Does not support the + * remove operation. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface RecurrenceIterator extends Iterator { + + /** true iff there are more dates in the series. */ + boolean hasNext(); + + /** + * returns the next date in the series, in UTC. + * If !hasNext(), then behavior is undefined. + * + * @return a DateValue that is strictly later than any date previously + * returned by this iterator. + */ + DateValue next(); + + /** + * skips all dates in the series before the given date. + * + * @param newStartUtc non null. + */ + void advanceTo(DateValue newStartUtc); + + /** + * unsupported. + * @throws UnsupportedOperationException always + */ + void remove(); +} diff --git a/src/main/java/com/google/ical/iter/RecurrenceIteratorFactory.java b/src/main/java/com/google/ical/iter/RecurrenceIteratorFactory.java new file mode 100644 index 0000000..4b41e4d --- /dev/null +++ b/src/main/java/com/google/ical/iter/RecurrenceIteratorFactory.java @@ -0,0 +1,487 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.values.DateTimeValue; +import com.google.ical.values.DateTimeValueImpl; +import com.google.ical.values.DateValue; +import com.google.ical.values.Frequency; +import com.google.ical.values.IcalObject; +import com.google.ical.values.RDateList; +import com.google.ical.values.RRule; +import com.google.ical.values.TimeValue; +import com.google.ical.values.Weekday; +import com.google.ical.values.WeekdayNum; +import com.google.ical.util.Predicate; +import com.google.ical.util.Predicates; +import com.google.ical.util.TimeUtils; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.TimeZone; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * for calculating the occurrences of an individual RFC 2445 RRULE or groups of + * RRULES, RDATES, EXRULES, and EXDATES. + * + *

Glossary

+ * Period - year|month|day|...
+ * Day of the week - an int in [0-6]. See RRULE_WDAY_* in rrule.js
+ * Day of the year - zero indexed in [0,365]
+ * Day of the month - 1 indexed in [1,31]
+ * Month - 1 indexed integer in [1,12] + * + *

Abstractions

+ * Generator - a function corresponding to an RRULE part that takes a date and + * returns a later (year or month or day depending on its period) within the + * next larger period. + * A generator ignores all periods in its input smaller than its period. + *

+ * Filter - a function that returns true iff the given date matches the subrule. + *

+ * Condition - returns true if the given date is past the end of the recurrence. + * + *

All the functions that represent rule parts are stateful. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class RecurrenceIteratorFactory { + + private static final Logger LOGGER = Logger.getLogger( + RecurrenceIteratorFactory.class.getName()); + + /** + * given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse + * them into a single recurrence iterator. + * @param rdata ical text. + * @param dtStart the date of the first occurrence in timezone tzid, which is + * used to fill in optional fields in the RRULE, such as the day of the + * month for a monthly repetition when no ther day specified. + * Note: this may not be the first date in the series since an EXRULE or + * EXDATE might force it to be skipped, but there will be no earlier date + * generated by this ruleset. + * @param strict true if any failure to parse should result in a + * ParseException. false causes bad content lines to be logged and ignored. + */ + public static RecurrenceIterator createRecurrenceIterator( + String rdata, DateValue dtStart, TimeZone tzid, boolean strict) + throws ParseException { + return createRecurrenceIterable(rdata, dtStart, tzid, strict).iterator(); + } + + public static RecurrenceIterable createRecurrenceIterable( + String rdata, final DateValue dtStart, final TimeZone tzid, + final boolean strict) + throws ParseException { + final IcalObject[] contentLines = parseContentLines(rdata, tzid, strict); + + return new RecurrenceIterable() { + public RecurrenceIterator iterator() { + List inclusions = + new ArrayList(); + List exclusions = + new ArrayList(); + // always include DTStart + inclusions.add(new RDateIteratorImpl( + new DateValue[] {TimeUtils.toUtc(dtStart, tzid)})); + for (IcalObject contentLine : contentLines) { + try { + String name = contentLine.getName(); + if ("rrule".equalsIgnoreCase(name)) { + inclusions.add(createRecurrenceIterator( + (RRule) contentLine, dtStart, tzid)); + } else if ("rdate".equalsIgnoreCase(name)) { + inclusions.add( + createRecurrenceIterator((RDateList) contentLine)); + } else if ("exrule".equalsIgnoreCase(name)) { + exclusions.add(createRecurrenceIterator( + (RRule) contentLine, dtStart, tzid)); + } else if ("exdate".equalsIgnoreCase(name)) { + exclusions.add( + createRecurrenceIterator((RDateList) contentLine)); + } + } catch (IllegalArgumentException ex) { + // bad frequency on rrule or exrule + if (strict) { throw ex; } + LOGGER.log( + Level.SEVERE, + "Dropping bad recurrence rule line: " + contentLine.toIcal(), + ex); + } + } + return new CompoundIteratorImpl(inclusions, exclusions); + } + }; + } + + /** + * like {@link #createRecurrenceIterator(String,DateValue,TimeZone,boolean)} + * but defaults to strict parsing. + */ + public static RecurrenceIterator createRecurrenceIterator( + String rdata, DateValue dtStart, TimeZone tzid) + throws ParseException { + return createRecurrenceIterator(rdata, dtStart, tzid, true); + } + + /** + * create a recurrence iterator from an rdate or exdate list. + */ + public static RecurrenceIterator createRecurrenceIterator(RDateList rdates) { + DateValue[] dates = rdates.getDatesUtc(); + Arrays.sort(dates); + int k = 0; + for (int i = 1; i < dates.length; ++i) { + if (!dates[i].equals(dates[k])) { dates[++k] = dates[i]; } + } + if (++k < dates.length) { + DateValue[] uniqueDates = new DateValue[k ]; + System.arraycopy(dates, 0, uniqueDates, 0, k); + dates = uniqueDates; + } + return new RDateIteratorImpl(dates); + } + + /** + * create a recurrence iterator from an rrule. + * @param rrule the recurrence rule to iterate. + * @param dtStart the start of the series, in tzid. + * @param tzid the timezone to iterate in. + */ + public static RecurrenceIterator createRecurrenceIterator( + RRule rrule, DateValue dtStart, TimeZone tzid) { + assert null != tzid; + assert null != dtStart; + + Frequency freq = rrule.getFreq(); + Weekday wkst = rrule.getWkSt(); + DateValue untilUtc = rrule.getUntil(); + int count = rrule.getCount(); + int interval = rrule.getInterval(); + WeekdayNum[] byDay = rrule.getByDay().toArray(new WeekdayNum[0]); + int[] byMonth = rrule.getByMonth(); + int[] byMonthDay = rrule.getByMonthDay(); + int[] byWeekNo = rrule.getByWeekNo(); + int[] byYearDay = rrule.getByYearDay(); + int[] bySetPos = rrule.getBySetPos(); + int[] byHour = rrule.getByHour(); + int[] byMinute = rrule.getByMinute(); + int[] bySecond = rrule.getBySecond(); + + // Make sure that BYMINUTE, BYHOUR, and BYSECOND rules are respected if they + // have exactly one iteration, so not causing frequency to exceed daily. + TimeValue startTime = null; + if (1 == (byHour.length | byMinute.length | bySecond.length) + && dtStart instanceof TimeValue) { + TimeValue tv = (TimeValue) dtStart; + startTime = new DateTimeValueImpl( + 0, 0, 0, + 1 == byHour.length ? byHour[0] : tv.hour(), + 1 == byMinute.length ? byMinute[0] : tv.minute(), + 1 == bySecond.length ? bySecond[0] : tv.second()); + } + + + if (interval <= 0) { interval = 1; } + if (null == wkst) { + wkst = Weekday.MO; + } + + // recurrences are implemented as a sequence of periodic generators. + // First a year is generated, and then months, and within months, days + ThrottledGenerator yearGenerator = Generators.serialYearGenerator( + freq == Frequency.YEARLY ? interval : 1, dtStart); + Generator monthGenerator = null; + Generator dayGenerator; + + // When multiple generators are specified for a period, they act as a union + // operator. We could have multiple generators (for day say) and then + // run each and merge the results, but some generators are more efficient + // than others, so to avoid generating 53 sundays and throwing away all but + // 1 for RRULE:FREQ=YEARLY;BYDAY=TU;BYWEEKNO=1, we reimplement some of the + // more prolific generators as filters. + // TODO(msamuel): don't need a list here + List> filters = + new ArrayList>(); + + // choose the appropriate generators and filters + switch (freq) { + case DAILY: + if (0 == byMonthDay.length) { + dayGenerator = Generators.serialDayGenerator(interval, dtStart); + } else { + dayGenerator = Generators.byMonthDayGenerator(byMonthDay, dtStart); + } + if (0 != byDay.length) { + // TODO(msamuel): the spec is not clear on this. Treat the week + // numbers as weeks in the year. This is only implemented for + // conformance with libical. + filters.add(Filters.byDayFilter(byDay, true, wkst)); + } + break; + case WEEKLY: + // week is not considered a period because a week may span multiple + // months &| years. There are no week generators, but so a filter is + // used to make sure that FREQ=WEEKLY;INTERVAL=2 only generates dates + // within the proper week. + if (0 != byDay.length) { + dayGenerator = Generators.byDayGenerator(byDay, false, dtStart); + if (interval > 1) { + filters.add(Filters.weekIntervalFilter(interval, wkst, dtStart)); + } + } else { + dayGenerator = Generators.serialDayGenerator(interval * 7, dtStart); + } + if (0 != byMonthDay.length) { + filters.add(Filters.byMonthDayFilter(byMonthDay)); + } + break; + case YEARLY: + if (0 != byYearDay.length) { + // The BYYEARDAY rule part specifies a COMMA separated list of days of + // the year. Valid values are 1 to 366 or -366 to -1. For example, -1 + // represents the last day of the year (December 31st) and -306 + // represents the 306th to the last day of the year (March 1st). + dayGenerator = Generators.byYearDayGenerator(byYearDay, dtStart); + if (0 != byDay.length) { + filters.add(Filters.byDayFilter(byDay, true, wkst)); + } + if (0 != byMonthDay.length) { + filters.add(Filters.byMonthDayFilter(byMonthDay)); + } + // TODO(msamuel): filter byWeekNo and write unit tests + break; + } + // fallthru to monthly cases + case MONTHLY: + if (0 != byMonthDay.length) { + // The BYMONTHDAY rule part specifies a COMMA separated list of days + // of the month. Valid values are 1 to 31 or -31 to -1. For example, + // -10 represents the tenth to the last day of the month. + dayGenerator = Generators.byMonthDayGenerator(byMonthDay, dtStart); + if (0 != byDay.length) { + filters.add( + Filters.byDayFilter(byDay, Frequency.YEARLY == freq, wkst)); + } + // TODO(msamuel): filter byWeekNo and write unit tests + } else if (0 != byWeekNo.length && Frequency.YEARLY == freq) { + // The BYWEEKNO rule part specifies a COMMA separated list of ordinals + // specifying weeks of the year. This rule part is only valid for + // YEARLY rules. + dayGenerator = Generators.byWeekNoGenerator(byWeekNo, wkst, dtStart); + if (0 != byDay.length) { + filters.add(Filters.byDayFilter(byDay, true, wkst)); + } + } else if (0 != byDay.length) { + // Each BYDAY value can also be preceded by a positive (n) or negative + // (-n) integer. If present, this indicates the nth occurrence of the + // specific day within the MONTHLY or YEARLY RRULE. For example, + // within a MONTHLY rule, +1MO (or simply 1MO) represents the first + // Monday within the month, whereas -1MO represents the last Monday of + // the month. If an integer modifier is not present, it means all days + // of this type within the specified frequency. For example, within a + // MONTHLY rule, MO represents all Mondays within the month. + dayGenerator = Generators.byDayGenerator( + byDay, Frequency.YEARLY == freq && 0 == byMonth.length, dtStart); + } else { + if (Frequency.YEARLY == freq) { + monthGenerator = Generators.byMonthGenerator( + new int[] { dtStart.month() }, dtStart); + } + dayGenerator = Generators.byMonthDayGenerator( + new int[] { dtStart.day() }, dtStart); + } + break; + default: + throw new IllegalArgumentException( + "Can't iterate more frequently than daily"); + } + + // generator inference common to all periods + if (0 != byMonth.length) { + monthGenerator = Generators.byMonthGenerator(byMonth, dtStart); + } else if (null == monthGenerator) { + monthGenerator = Generators.serialMonthGenerator( + freq == Frequency.MONTHLY ? interval : 1, dtStart); + } + + // the condition tells the iterator when to halt. + // The condition is exclusive, so the date that triggers it will not be + // included. + Predicate condition; + boolean canShortcutAdvance = true; + if (0 != count) { + condition = Conditions.countCondition(count); + // We can't shortcut because the countCondition must see every generated + // instance. + // TODO(msamuel): if count is large, we might try predicting the end date + // so that we can convert the COUNT condition to an UNTIL condition. + canShortcutAdvance = false; + } else if (null != untilUtc) { + if ((untilUtc instanceof TimeValue) != (dtStart instanceof TimeValue)) { + // TODO(msamuel): warn + if (dtStart instanceof TimeValue) { + untilUtc = TimeUtils.dayStart(untilUtc); + } else { + untilUtc = TimeUtils.toDateValue(untilUtc); + } + } + condition = Conditions.untilCondition(untilUtc); + } else { + condition = Predicates.alwaysTrue(); + } + + // combine filters into a single function + Predicate filter; + switch (filters.size()) { + case 0: + filter = Predicates.alwaysTrue(); + break; + case 1: + filter = filters.get(0); + break; + default: + filter = Predicates.and(filters.toArray(new Predicate[0])); + break; + } + + Generator instanceGenerator; + if (0 != bySetPos.length) { + switch (freq) { + case WEEKLY: + case MONTHLY: + case YEARLY: + instanceGenerator = InstanceGenerators.bySetPosInstanceGenerator( + bySetPos, freq, wkst, filter, + yearGenerator, monthGenerator, dayGenerator); + break; + default: + // TODO(msamuel): if we allow iteration more frequently than daily + // then we will need to implement bysetpos for hours, minutes, and + // seconds. It should be sufficient though to simply choose the + // instance of the set statically for every occurrence except the + // first. + // E.g. RRULE:FREQ=DAILY;BYHOUR=0,6,12,18;BYSETPOS=1 + // for DTSTART:20000101T130000 + // will yield + // 20000101T180000 + // 20000102T000000 + // 20000103T000000 + // ... + + instanceGenerator = InstanceGenerators.serialInstanceGenerator( + filter, yearGenerator, monthGenerator, dayGenerator); + break; + } + } else { + instanceGenerator = InstanceGenerators.serialInstanceGenerator( + filter, yearGenerator, monthGenerator, dayGenerator); + } + + return new RRuleIteratorImpl( + dtStart, tzid, condition, filter, instanceGenerator, + yearGenerator, monthGenerator, dayGenerator, canShortcutAdvance, + startTime); + } + + /** + * a recurrence iterator that returns the union of the given recurrence + * iterators. + */ + public static RecurrenceIterator join( + RecurrenceIterator a, RecurrenceIterator... b) { + List incl = new ArrayList(); + incl.add(a); + incl.addAll(Arrays.asList(b)); + return new CompoundIteratorImpl( + incl, Collections.emptyList()); + } + + /** + * an iterator over all the dates included except those excluded, i.e. + * inclusions - exclusions. + * Exclusions trump inclusions, and {@link DateValue dates} and + * {@link DateTimeValue date-times} never match one another. + * @param included non null. + * @param excluded non null. + * @return non null. + */ + public static RecurrenceIterator except( + RecurrenceIterator included, RecurrenceIterator excluded) { + return new CompoundIteratorImpl( + Collections.singleton(included), + Collections.singleton(excluded)); + } + + private static final Pattern FOLD = Pattern.compile("(?:\\r\\n?|\\n)[ \t]"); + private static final Pattern NEWLINE = Pattern.compile("[\\r\\n]+"); + private static final Pattern RULE = Pattern.compile( + "^(?:R|EX)RULE[:;]", Pattern.CASE_INSENSITIVE); + private static final Pattern DATE = Pattern.compile( + "^(?:R|EX)DATE[:;]", Pattern.CASE_INSENSITIVE); + private static IcalObject[] parseContentLines( + String rdata, TimeZone tzid, boolean strict) + throws ParseException { + String unfolded = FOLD.matcher(rdata).replaceAll("").trim(); + if ("".equals(unfolded)) { return new IcalObject[0]; } + String[] lines = NEWLINE.split(unfolded); + IcalObject[] out = new IcalObject[lines.length]; + int nbad = 0; + for (int i = 0; i < lines.length; ++i) { + String line = lines[i].trim(); + try { + if (RULE.matcher(line).find()) { + out[i] = new RRule(line); + } else if (DATE.matcher(line).find()) { + out[i] = new RDateList(line, tzid); + } else { + throw new ParseException(lines[i], i); + } + } catch (ParseException ex) { + if (strict) { + throw ex; + } + LOGGER.log(Level.SEVERE, + "Dropping bad recurrence rule line: " + line, ex); + ++nbad; + } catch (IllegalArgumentException ex) { + if (strict) { + throw ex; + } + LOGGER.log(Level.SEVERE, + "Dropping bad recurrence rule line: " + line, ex); + ++nbad; + } + } + if (0 != nbad) { + IcalObject[] trimmed = new IcalObject[out.length - nbad]; + for (int i = 0, k = 0; i < trimmed.length; ++k) { + if (null != out[k]) { trimmed[i++] = out[k]; } + } + out = trimmed; + } + return out; + } + + private RecurrenceIteratorFactory() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/iter/ThrottledGenerator.java b/src/main/java/com/google/ical/iter/ThrottledGenerator.java new file mode 100644 index 0000000..0dbaa78 --- /dev/null +++ b/src/main/java/com/google/ical/iter/ThrottledGenerator.java @@ -0,0 +1,38 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +/** + * A generator that may stop generating values after some point, if e.g. it's + * output is never productive. This is used to stop rules like + *

+ * RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30 + *
+ * from hanging an iterator. + *

If a rule does prove productive though, it can be alerted to the fact by + * the {@link #workDone} method, so that any throttle can be reset. + *

+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +abstract class ThrottledGenerator extends Generator { + + /** + * called to reset any throttle after work is done. This must be called in + * the outermost loop of any iterator. + */ + abstract void workDone(); + +} diff --git a/src/main/java/com/google/ical/iter/Util.java b/src/main/java/com/google/ical/iter/Util.java new file mode 100644 index 0000000..af05dd1 --- /dev/null +++ b/src/main/java/com/google/ical/iter/Util.java @@ -0,0 +1,137 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.iter; + +import com.google.ical.util.DTBuilder; +import com.google.ical.values.Weekday; +import com.google.ical.values.WeekdayNum; +import com.google.ical.values.DateValue; + +/** + * a dumping ground for utility functions that don't fit anywhere else. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +class Util { + + /** + * advances builder to the earliest day on or after builder that falls on + * wkst. + * @param builder non null. + * @param wkst the day of the week that the week starts on + */ + static void rollToNextWeekStart(DTBuilder builder, Weekday wkst) { + DateValue bd = builder.toDate(); + builder.day += (7 - ((7 + (Weekday.valueOf(bd).javaDayNum + - wkst.javaDayNum)) + % 7)) % 7; + builder.normalize(); + } + + /** + * the earliest day on or after d that falls on wkst. + * @param wkst the day of the week that the week starts on + */ + static DateValue nextWeekStart(DateValue d, Weekday wkst) { + DTBuilder builder = new DTBuilder(d); + builder.day += (7 - ((7 + (Weekday.valueOf(d).javaDayNum + - wkst.javaDayNum)) % 7)) + % 7; + return builder.toDate(); + } + + /** returns a sorted unique copy of ints. */ + static int[] uniquify(int[] ints) { + IntSet iset = new IntSet(); + for (int i = ints.length; --i >= 0;) { + iset.add(ints[i]); + } + return iset.toIntArray(); + } + + /** + * given a weekday number, such as -1SU, returns the day of the month that it + * falls on. + * The weekday number may be refer to a week in the current month in some + * contexts or a week in the current year in other contexts. + * @param dow0 the day of week of the first day in the current year/month. + * @param nDays the number of days in the current year/month. + * In [28,29,30,31,365,366]. + * @param weekNum -1SU in the example above. + * @param d0 the number of days between the 1st day of the current + * year/month and the current month. + * @param nDaysInMonth the number of days in the current month. + * @return 0 indicates no such day + */ + static int dayNumToDate(Weekday dow0, int nDays, int weekNum, + Weekday dow, int d0, int nDaysInMonth) { + // if dow is wednesday, then this is the date of the first wednesday + int firstDateOfGivenDow = 1 + ((7 + dow.javaDayNum - dow0.javaDayNum) % 7); + + int date; + if (weekNum > 0) { + date = ((weekNum - 1) * 7) + firstDateOfGivenDow - d0; + } else { // count weeks from end of month + // calculate last day of the given dow. + // Since nDays <= 366, this should be > nDays + int lastDateOfGivenDow = firstDateOfGivenDow + (7 * 54); + lastDateOfGivenDow -= 7 * ((lastDateOfGivenDow - nDays + 6) / 7); + date = lastDateOfGivenDow + 7 * (weekNum + 1) - d0; + } + if (date <= 0 || date > nDaysInMonth) { return 0; } + return date; + } + + /** + * Compute an absolute week number given a relative one. + * The day number -1SU refers to the last Sunday, so if there are 5 Sundays + * in a period that starts on dow0 with nDays, then -1SU is 5SU. + * Depending on where its used it may refer to the last Sunday of the year + * or of the month. + * + * @param weekdayNum -1SU in the example above. + * @param dow0 the day of the week of the first day of the week or month. + * One of the RRULE_WDAY_* constants. + * @param nDays the number of days in the month or year. + * @return an abolute week number, e.g. 5 in the example above. + * Valid if in [1,53]. + */ + static int invertWeekdayNum( + WeekdayNum weekdayNum, Weekday dow0, int nDays) { + assert weekdayNum.num < 0; + // how many are there of that week? + return countInPeriod(weekdayNum.wday, dow0, nDays) + weekdayNum.num + 1; + } + + /** + * the number of occurences of dow in a period nDays long where the first day + * of the period has day of week dow0. + */ + static int countInPeriod(Weekday dow, Weekday dow0, int nDays) { + // Two cases + // (1a) dow >= dow0: count === (nDays - (dow - dow0)) / 7 + // (1b) dow < dow0: count === (nDays - (7 - dow0 - dow)) / 7 + if (dow.javaDayNum >= dow0.javaDayNum) { + return 1 + ((nDays - (dow.javaDayNum - dow0.javaDayNum) - 1) / 7); + } else { + return 1 + ((nDays - (7 - (dow0.javaDayNum - dow.javaDayNum)) - 1) / 7); + } + } + + private Util() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/iter/package.html b/src/main/java/com/google/ical/iter/package.html new file mode 100644 index 0000000..95a6e51 --- /dev/null +++ b/src/main/java/com/google/ical/iter/package.html @@ -0,0 +1,267 @@ + + +

recurrence rule implementation as described at +RFC 2445 section 4.3.10.

+ +

4.3.10 Recurrence Rule

+Value Name: RECUR +

+ Purpose: This value type is used to identify properties that contain + a recurrence rule specification. +

+ Formal Definition: The value type is defined by the following + notation: +

+     recur      = "FREQ"=freq *(
+
+                ; either UNTIL or COUNT may appear in a 'recur',
+                ; but UNTIL and COUNT MUST NOT occur in the same 'recur'
+
+                ( ";" "UNTIL" "=" enddate ) /
+                ( ";" "COUNT" "=" 1*DIGIT ) /
+
+                ; the rest of these keywords are optional,
+                ; but MUST NOT occur more than once
+
+                ( ";" "INTERVAL" "=" 1*DIGIT )          /
+                ( ";" "BYSECOND" "=" byseclist )        /
+                ( ";" "BYMINUTE" "=" byminlist )        /
+                ( ";" "BYHOUR" "=" byhrlist )           /
+                ( ";" "BYDAY" "=" bywdaylist )          /
+                ( ";" "BYMONTHDAY" "=" bymodaylist )    /
+                ( ";" "BYYEARDAY" "=" byyrdaylist )     /
+                ( ";" "BYWEEKNO" "=" bywknolist )       /
+                ( ";" "BYMONTH" "=" bymolist )          /
+                ( ";" "BYSETPOS" "=" bysplist )         /
+                ( ";" "WKST" "=" weekday )              /
+
+                ( ";" x-name "=" text )
+                )
+
+     freq       = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY"
+                / "WEEKLY" / "MONTHLY" / "YEARLY"
+
+     enddate    = date
+     enddate    =/ date-time            ;An UTC value
+
+     byseclist  = seconds / ( seconds *("," seconds) )
+
+     seconds    = 1DIGIT / 2DIGIT       ;0 to 59
+
+     byminlist  = minutes / ( minutes *("," minutes) )
+
+     minutes    = 1DIGIT / 2DIGIT       ;0 to 59
+
+     byhrlist   = hour / ( hour *("," hour) )
+
+     hour       = 1DIGIT / 2DIGIT       ;0 to 23
+
+     bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) )
+
+     weekdaynum = [([plus] ordwk / minus ordwk)] weekday
+
+     plus       = "+"
+
+     minus      = "-"
+
+     ordwk      = 1DIGIT / 2DIGIT       ;1 to 53
+
+     weekday    = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
+     ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY,
+     ;FRIDAY, SATURDAY and SUNDAY days of the week.
+
+     bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) )
+
+     monthdaynum = ([plus] ordmoday) / (minus ordmoday)
+
+     ordmoday   = 1DIGIT / 2DIGIT       ;1 to 31
+
+     byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) )
+
+     yeardaynum = ([plus] ordyrday) / (minus ordyrday)
+
+     ordyrday   = 1DIGIT / 2DIGIT / 3DIGIT      ;1 to 366
+
+     bywknolist = weeknum / ( weeknum *("," weeknum) )
+
+     weeknum    = ([plus] ordwk) / (minus ordwk)
+
+     bymolist   = monthnum / ( monthnum *("," monthnum) )
+
+     monthnum   = 1DIGIT / 2DIGIT       ;1 to 12
+
+     bysplist   = setposday / ( setposday *("," setposday) )
+
+     setposday  = yeardaynum
+
+

+ Description: If the property permits, multiple "recur" values are + specified by a COMMA character (US-ASCII decimal 44) separated list + of values. The value type is a structured value consisting of a list + of one or more recurrence grammar parts. Each rule part is defined by + a NAME=VALUE pair. The rule parts are separated from each other by + the SEMICOLON character (US-ASCII decimal 59). The rule parts are not + ordered in any particular sequence. Individual rule parts MUST only + be specified once. +

+ The FREQ rule part identifies the type of recurrence rule. This rule + part MUST be specified in the recurrence rule. Valid values include + SECONDLY, to specify repeating events based on an interval of a + second or more; MINUTELY, to specify repeating events based on an + interval of a minute or more; HOURLY, to specify repeating events + based on an interval of an hour or more; DAILY, to specify repeating + events based on an interval of a day or more; WEEKLY, to specify + repeating events based on an interval of a week or more; MONTHLY, to + specify repeating events based on an interval of a month or more; and + YEARLY, to specify repeating events based on an interval of a year or + more. +

+ The INTERVAL rule part contains a positive integer representing how + often the recurrence rule repeats. The default value is "1", meaning + every second for a SECONDLY rule, or every minute for a MINUTELY + rule, every hour for an HOURLY rule, every day for a DAILY rule, + every week for a WEEKLY rule, every month for a MONTHLY rule and + every year for a YEARLY rule. +

+ The UNTIL rule part defines a date-time value which bounds the + recurrence rule in an inclusive manner. If the value specified by + UNTIL is synchronized with the specified recurrence, this date or + date-time becomes the last instance of the recurrence. If specified + as a date-time value, then it MUST be specified in an UTC time + format. If not present, and the COUNT rule part is also not present, + the RRULE is considered to repeat forever. +

+ The COUNT rule part defines the number of occurrences at which to + range-bound the recurrence. The "DTSTART" property value, if + specified, counts as the first occurrence. +

+ The BYSECOND rule part specifies a COMMA character (US-ASCII decimal + 44) separated list of seconds within a minute. Valid values are 0 to + 59. The BYMINUTE rule part specifies a COMMA character (US-ASCII + decimal 44) separated list of minutes within an hour. Valid values + are 0 to 59. The BYHOUR rule part specifies a COMMA character (US- + ASCII decimal 44) separated list of hours of the day. Valid values + are 0 to 23. +

+ The BYDAY rule part specifies a COMMA character (US-ASCII decimal 44) + separated list of days of the week; MO indicates Monday; TU indicates + Tuesday; WE indicates Wednesday; TH indicates Thursday; FR indicates + Friday; SA indicates Saturday; SU indicates Sunday. +

+ Each BYDAY value can also be preceded by a positive (+n) or negative + (-n) integer. If present, this indicates the nth occurrence of the + specific day within the MONTHLY or YEARLY RRULE. For example, within + a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday + within the month, whereas -1MO represents the last Monday of the + month. If an integer modifier is not present, it means all days of + this type within the specified frequency. For example, within a + MONTHLY rule, MO represents all Mondays within the month. +

+ The BYMONTHDAY rule part specifies a COMMA character (ASCII decimal + 44) separated list of days of the month. Valid values are 1 to 31 or + -31 to -1. For example, -10 represents the tenth to the last day of + the month. +

+ The BYYEARDAY rule part specifies a COMMA character (US-ASCII decimal + 44) separated list of days of the year. Valid values are 1 to 366 or + -366 to -1. For example, -1 represents the last day of the year + (December 31st) and -306 represents the 306th to the last day of the + year (March 1st). +

+ The BYWEEKNO rule part specifies a COMMA character (US-ASCII decimal + 44) separated list of ordinals specifying weeks of the year. Valid + values are 1 to 53 or -53 to -1. This corresponds to weeks according + to week numbering as defined in [ISO 8601]. A week is defined as a + seven day period, starting on the day of the week defined to be the + week start (see WKST). Week number one of the calendar year is the + first week which contains at least four (4) days in that calendar + year. This rule part is only valid for YEARLY rules. For example, 3 + represents the third week of the year. +

+ Note: Assuming a Monday week start, week 53 can only occur when + Thursday is January 1 or if it is a leap year and Wednesday is + January 1. +
+ The BYMONTH rule part specifies a COMMA character (US-ASCII decimal + 44) separated list of months of the year. Valid values are 1 to 12. +

+ The WKST rule part specifies the day on which the workweek starts. + Valid values are MO, TU, WE, TH, FR, SA and SU. This is significant + when a WEEKLY RRULE has an interval greater than 1, and a BYDAY rule + part is specified. This is also significant when in a YEARLY RRULE + when a BYWEEKNO rule part is specified. The default value is MO. +

+ The BYSETPOS rule part specifies a COMMA character (US-ASCII decimal + 44) separated list of values which corresponds to the nth occurrence + within the set of events specified by the rule. Valid values are 1 to + 366 or -366 to -1. It MUST only be used in conjunction with another + BYxxx rule part. For example "the last work day of the month" could + be represented as: +

+     RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1
+
+

+ Each BYSETPOS value can include a positive (+n) or negative (-n) + integer. If present, this indicates the nth occurrence of the + specific occurrence within the set of events specified by the rule. +

+ If BYxxx rule part values are found which are beyond the available + scope (ie, BYMONTHDAY=30 in February), they are simply ignored. +

+ Information, not contained in the rule, necessary to determine the + various recurrence instance start time and dates are derived from the + Start Time (DTSTART) entry attribute. For example, + "FREQ=YEARLY;BYMONTH=1" doesn't specify a specific day within the + month or a time. This information would be the same as what is + specified for DTSTART. +

+ BYxxx rule parts modify the recurrence in some manner. BYxxx rule + parts for a period of time which is the same or greater than the + frequency generally reduce or limit the number of occurrences of the + recurrence generated. For example, "FREQ=DAILY;BYMONTH=1" reduces the + number of recurrence instances from all days (if BYMONTH tag is not + present) to all days in January. BYxxx rule parts for a period of + time less than the frequency generally increase or expand the number + of occurrences of the recurrence. For example, + "FREQ=YEARLY;BYMONTH=1,2" increases the number of days within the + yearly recurrence set from 1 (if BYMONTH tag is not present) to 2. +

+ If multiple BYxxx rule parts are specified, then after evaluating the + specified FREQ and INTERVAL rule parts, the BYxxx rule parts are + applied to the current set of evaluated occurrences in the following + order: BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, + BYMINUTE, BYSECOND and BYSETPOS; then COUNT and UNTIL are evaluated. +

+ Here is an example of evaluating multiple BYxxx rule parts. +

+     DTSTART;TZID=US-Eastern:19970105T083000
+     RRULE:FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;
+      BYMINUTE=30
+
+

+ First, the "INTERVAL=2" would be applied to "FREQ=YEARLY" to arrive + at "every other year". Then, "BYMONTH=1" would be applied to arrive + at "every January, every other year". Then, "BYDAY=SU" would be + applied to arrive at "every Sunday in January, every other year". + Then, "BYHOUR=8,9" would be applied to arrive at "every Sunday in + January at 8 AM and 9 AM, every other year". Then, "BYMINUTE=30" + would be applied to arrive at "every Sunday in January at 8:30 AM and + 9:30 AM, every other year". Then, lacking information from RRULE, the + second is derived from DTSTART, to end up in "every Sunday in January + at 8:30:00 AM and 9:30:00 AM, every other year". Similarly, if the + BYMINUTE, BYHOUR, BYDAY, BYMONTHDAY or BYMONTH rule part were + missing, the appropriate minute, hour, day or month would have been + retrieved from the "DTSTART" property. +

+ No additional content value encoding (i.e., BACKSLASH character + encoding) is defined for this value type. +

+ Example: The following is a rule which specifies 10 meetings which + occur every other day: +

+     FREQ=DAILY;COUNT=10;INTERVAL=2
+
+

+ There are other examples specified in the "RRULE" specification. + + \ No newline at end of file diff --git a/src/main/java/com/google/ical/util/DTBuilder.java b/src/main/java/com/google/ical/util/DTBuilder.java new file mode 100644 index 0000000..0880c0a --- /dev/null +++ b/src/main/java/com/google/ical/util/DTBuilder.java @@ -0,0 +1,197 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.util; + +import com.google.ical.values.DateTimeValue; +import com.google.ical.values.DateTimeValueImpl; +import com.google.ical.values.DateValue; +import com.google.ical.values.DateValueImpl; +import com.google.ical.util.TimeUtils; +import com.google.ical.values.TimeValue; + +/** + * a mutable buffer that can be used to build {@link DateValue}s and + * {@link DateTimeValue}s. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class DTBuilder { + + /** in AD. 0 -> 1BC. */ + public int year; + /** one indexed. */ + public int month; + /** one indexed */ + public int day; + /** zero indexed in 24 hour time. */ + public int hour; + /** zero indexed */ + public int minute; + /** zero indexed */ + public int second; + + public DTBuilder() { + } + + public DTBuilder(int year, int month, int day, + int hour, int minute, int second) { + this.year = year; + this.month = month; + this.day = day; + this.hour = hour; + this.minute = minute; + this.second = second; + } + + public DTBuilder(int year, int month, int day) { + this.year = year; + this.month = month; + this.day = day; + } + + public DTBuilder(DateValue dv) { + this.year = dv.year(); + this.month = dv.month(); + this.day = dv.day(); + if (dv instanceof TimeValue) { + TimeValue tv = (TimeValue) dv; + this.hour = tv.hour(); + this.minute = tv.minute(); + this.second = tv.second(); + } + } + + /** + * produces a normalized date time, using zero for the time fields if none + * were provided. + * @return not null + */ + public DateTimeValue toDateTime() { + normalize(); + return new DateTimeValueImpl(year, month, day, hour, minute, second); + } + + /** + * produces a normalized date. + * @return not null + */ + public DateValue toDate() { + normalize(); + return new DateValueImpl(year, month, day); + } + + /** + * behavior undefined unless normalized. + * If you're not sure whether it's appropriate to use this method, use + * toDateValue().compareTo(dv) instead. + */ + public int compareTo(DateValue dv) { + long dvComparable = + (((((long) dv.year()) << 4) + dv.month()) << 5) + dv.day(); + long dtbComparable = + ((((long) year << 4) + month << 5)) + day; + if (dv instanceof TimeValue) { + TimeValue tv = (TimeValue) dv; + dvComparable = (((((dvComparable << 5) + tv.hour()) << 6) + tv.minute()) + << 6) + tv.second(); + dtbComparable = (((((dtbComparable << 5) + hour) << 6) + minute) + << 6) + second; + } + long delta = dtbComparable - dvComparable; + return delta < 0 ? -1 : delta == 0 ? 0 : 1; + } + + /** + * makes sure that the fields are in the proper ranges, by e.g. converting + * 32 January to 1 February, or month 0 to December of the year before. + */ + public void normalize() { + this.normalizeTime(); + this.normalizeDate(); + } + + @Override + public String toString() { + return year + "-" + month + "-" + day + " " + hour + ":" + minute + ":" + + second; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof DTBuilder)) { return false; } + DTBuilder that = (DTBuilder) o; + return this.year == that.year + && this.month == that.month + && this.day == that.day + && this.hour == that.hour + && this.minute == that.minute + && this.second == that.second; + } + + @Override + public int hashCode() { + return + ((((((((year << 4) + month << 5) + day) << 5) + hour) << 6) + minute) + << 6) + second; + } + + private void normalizeTime() { + int addMinutes = ((second < 0) ? (second - 59) : second) / 60; + second -= addMinutes * 60; + minute += addMinutes; + int addHours = ((minute < 0) ? (minute - 59) : minute) / 60; + minute -= addHours * 60; + hour += addHours; + int addDays = ((hour < 0) ? (hour - 23) : hour) / 24; + hour -= addDays * 24; + day += addDays; + } + private void normalizeDate() { + while (day <= 0) { + int days = TimeUtils.yearLength(month > 2 ? year : year - 1); + day += days; + --year; + } + if (month <= 0) { + int years = month / 12 - 1; + year += years; + month -= 12 * years; + } else if (month > 12) { + int years = (month - 1) / 12; + year += years; + month -= 12 * years; + } + while (true) { + if (month == 1) { + int yearLength = TimeUtils.yearLength(year); + if (day > yearLength) { + ++year; + day -= yearLength; + } + } + int monthLength = TimeUtils.monthLength(year, month); + if (day > monthLength) { + day -= monthLength; + if (++month > 12) { + month -= 12; + ++year; + } + } else { + break; + } + } + } + +} diff --git a/src/main/java/com/google/ical/util/Predicate.java b/src/main/java/com/google/ical/util/Predicate.java new file mode 100644 index 0000000..8d2a018 --- /dev/null +++ b/src/main/java/com/google/ical/util/Predicate.java @@ -0,0 +1,18 @@ +// CopyrightGoogle Inc. All rights reserved. + +package com.google.ical.util; + +/** + * A function with a boolean return value useful for filtering. + */ +public interface Predicate { + + /** + * Applies this Predicate to the given object. + * + * @param t may be null. + * @return the value of this Predicate when applied to input {@code t} + */ + boolean apply(T t); +} + diff --git a/src/main/java/com/google/ical/util/Predicates.java b/src/main/java/com/google/ical/util/Predicates.java new file mode 100644 index 0000000..4efb017 --- /dev/null +++ b/src/main/java/com/google/ical/util/Predicates.java @@ -0,0 +1,138 @@ +// CopyrightGoogle Inc. All rights reserved. + +package com.google.ical.util; + +import java.io.Serializable; + +/** + * static methods for creating the standard set of {@link Predicate} objects. + */ +public class Predicates { + + private Predicates() { } + + /* + * For constant Predicates a single instance will suffice; we'll cast it to + * the right parameterized type on demand. + */ + + private static final Predicate ALWAYS_TRUE = new AlwaysTruePredicate(); + private static final Predicate ALWAYS_FALSE = new AlwaysFalsePredicate(); + + /** + * Returns a Predicate that always evaluates to true. + */ + @SuppressWarnings("unchecked") + public static Predicate alwaysTrue() { + return (Predicate) ALWAYS_TRUE; + } + + /** + * Returns a Predicate that always evaluates to false. + */ + @SuppressWarnings("unchecked") + public static Predicate alwaysFalse() { + return (Predicate) ALWAYS_FALSE; + } + + /** + * Returns a Predicate that evaluates to true iff the given Predicate + * evaluates to false. + */ + public static Predicate not(Predicate predicate) { + assert null != predicate; + return new NotPredicate(predicate); + } + + /** + * Returns a Predicate that evaluates to true iff each of its components + * evaluates to true. The components are evaluated in order, and evaluation + * will be "short-circuited" as soon as the answer is determined. Does not + * defensively copy the array passed in, so future changes to it will alter + * the behavior of this Predicate. + */ + public static Predicate and(Predicate... components) { + assert null != components; + return new AndPredicate(components); + } + + /** + * Returns a Predicate that evaluates to true iff any one of its components + * evaluates to true. The components are evaluated in order, and evaluation + * will be "short-circuited" as soon as the answer is determined. Does not + * defensively copy the array passed in, so future changes to it will alter + * the behavior of this Predicate. + */ + public static Predicate or(Predicate... components) { + assert components != null; + return new OrPredicate(components); + } + + /** @see Predicates#alwaysTrue */ + private static class AlwaysTruePredicate implements Predicate, + Serializable { + private static final long serialVersionUID = 8759914710239461322L; + public boolean apply(T t) { + return true; + } + } + + /** @see Predicates#alwaysFalse */ + private static class AlwaysFalsePredicate implements Predicate, + Serializable { + private static final long serialVersionUID = -565481022115659695L; + public boolean apply(T t) { + return false; + } + } + + /** @see Predicates#not */ + private static class NotPredicate implements Predicate, Serializable { + private static final long serialVersionUID = -5113445916422049953L; + private final Predicate predicate; + + private NotPredicate(Predicate predicate) { + this.predicate = predicate; + } + public boolean apply(T t) { + return !predicate.apply(t); + } + } + + /** @see Predicates#and */ + private static class AndPredicate implements Predicate, Serializable { + private static final long serialVersionUID = 1022358602593297546L; + private final Predicate[] components; + + private AndPredicate(Predicate... components) { + this.components = components; + } + public boolean apply(T t) { + for (Predicate predicate : components) { + if (!predicate.apply(t)) { + return false; + } + } + return true; + } + } + + /** @see Predicates#or */ + private static class OrPredicate implements Predicate, Serializable { + private static final long serialVersionUID = -7942366790698074803L; + private final Predicate[] components; + + private OrPredicate(Predicate... components) { + this.components = components; + } + public boolean apply(T t) { + for (Predicate predicate : components) { + if (predicate.apply(t)) { + return true; + } + } + return false; + } + } + +} diff --git a/src/main/java/com/google/ical/util/TimeUtils.java b/src/main/java/com/google/ical/util/TimeUtils.java new file mode 100644 index 0000000..96aee4f --- /dev/null +++ b/src/main/java/com/google/ical/util/TimeUtils.java @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2006 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * All Rights Reserved. + */ + +package com.google.ical.util; + +import com.google.ical.values.DateTimeValue; +import com.google.ical.values.DateTimeValueImpl; +import com.google.ical.values.DateValue; +import com.google.ical.values.DateValueImpl; +import com.google.ical.values.TimeValue; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.SimpleTimeZone; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility methods for working with times and dates. + * + * @author Neal Gafter + */ +public class TimeUtils { + + private static TimeZone ZULU = new SimpleTimeZone(0, "Etc/GMT"); + public static TimeZone utcTimezone() { + return ZULU; + } + + /** + * Get a "time_t" in millis given a number of seconds since + * Dershowitz/Reingold epoch relative to a given timezone. + * @param epochSecs Number of seconds since Dershowitz/Reingold + * epoch, relatve to zone. + * @param zone Timezone against which epochSecs applies + * @return Number of milliseconds since 00:00:00 Jan 1, 1970 GMT + */ + private static long timetMillisFromEpochSecs(long epochSecs, + TimeZone zone) { + DateTimeValue date = timeFromSecsSinceEpoch(epochSecs); + Calendar cal = new GregorianCalendar(zone); + cal.clear(); // clear millis + cal.setTimeZone(zone); + cal.set(date.year(), date.month() - 1, date.day(), + date.hour(), date.minute(), date.second()); + return cal.getTimeInMillis(); + } + + private static DateTimeValue convert(DateTimeValue time, + TimeZone zone, + int sense) { + if (zone == null || + zone.hasSameRules(ZULU) || + time.year() == 0) { + return time; + } + + long timetMillis = 0; + + if (sense > 0) { + // time is in UTC + timetMillis = timetMillisFromEpochSecs(secsSinceEpoch(time), ZULU); + } else { + // time is in local time; since zone.getOffset() expects millis + // in UTC, need to convert before we can get the offset (ironic) + timetMillis = timetMillisFromEpochSecs(secsSinceEpoch(time), zone); + } + + int millisecondOffset = zone.getOffset(timetMillis); + int millisecondRound = millisecondOffset < 0 ? -500 : 500; + int secondOffset = (millisecondOffset + millisecondRound) / 1000; + return addSeconds(time, sense * secondOffset); + } + + public static DateValue fromUtc(DateValue date, TimeZone zone) { + return (date instanceof DateTimeValue) + ? fromUtc((DateTimeValue) date, zone) + : date; + } + + public static DateTimeValue fromUtc(DateTimeValue date, TimeZone zone) { + return convert(date, zone, +1); + } + + public static DateValue toUtc(DateValue date, TimeZone zone) { + return (date instanceof TimeValue) + ? convert((DateTimeValue) date, zone, -1) + : date; + } + + private static DateTimeValue addSeconds(DateTimeValue dtime, int seconds) { + return new DTBuilder(dtime.year(), dtime.month(), + dtime.day(), dtime.hour(), + dtime.minute(), + dtime.second() + seconds).toDateTime(); + } + + public static DateValue add(DateValue d, DateValue dur) { + DTBuilder db = new DTBuilder(d); + db.year += dur.year(); + db.month += dur.month(); + db.day += dur.day(); + if (dur instanceof TimeValue) { + TimeValue tdur = (TimeValue) dur; + db.hour += tdur.hour(); + db.minute += tdur.minute(); + db.second += tdur.second(); + return db.toDateTime(); + } else if (d instanceof TimeValue) { + return db.toDateTime(); + } + return db.toDate(); + } + + /** + * the number of days between two dates. + * + * @param dv1 non null. + * @param dv2 non null. + * @return a number of days. + */ + public static int daysBetween(DateValue dv1, DateValue dv2) { + return fixedFromGregorian(dv1) - fixedFromGregorian(dv2); + } + + private static int fixedFromGregorian(DateValue date) { + return fixedFromGregorian(date.year(), date.month(), date.day()); + } + + /** + * the number of days since the epoch, + * which is the imaginary beginning of year zero in a hypothetical + * backward extension of the Gregorian calendar through time. + * See "Calendrical Calculations" by Reingold and Dershowitz. + */ + public static int fixedFromGregorian(int year, int month, int day) { + int yearM1 = year - 1; + return 365 * yearM1 + + yearM1/4 - + yearM1/100 + + yearM1/400 + + (367*month - 362)/12 + + (month <= 2 ? 0 : + isLeapYear(year) ? -1 : + -2) + + day; + } + + public static boolean isLeapYear(int year) { + return (year % 4 == 0) && ((year % 100 != 0) || (year % 400 == 0)); + } + + /** count of days inthe given year */ + public static int yearLength(int year) { + return isLeapYear(year) ? 366 : 365; + } + + /** count of days in the given month (one indexed) of the given year. */ + public static int monthLength(int year, int month) { + switch (month) { + case 1: + case 3: + case 5: + case 7: + case 8: + case 10: + case 12: + return 31; + case 4: + case 6: + case 9: + case 11: + return 30; + case 2: + return isLeapYear(year) ? 29 : 28; + default: + throw new AssertionError(month); + } + } + + private static int[] MONTH_START_TO_DOY = new int[12]; + static { + assert !isLeapYear(1970); + for (int m = 1; m < 12; ++m) { + MONTH_START_TO_DOY[m] = MONTH_START_TO_DOY[m - 1] + monthLength(1970, m); + } + assert 365 == MONTH_START_TO_DOY[11] + monthLength(1970, 12) : + "" + (MONTH_START_TO_DOY[11] + monthLength(1970, 12)); + } + + /** the day of the year in [0-365] of the given date. */ + public static int dayOfYear(int year, int month, int date) { + int leapAdjust = month > 2 && isLeapYear(year) ? 1 : 0; + return MONTH_START_TO_DOY[month - 1] + leapAdjust + date - 1; + } + + /** + * Compute the gregorian time from the number of seconds since the + * Proleptic Gregorian Epoch. + * See "Calendrical Calculations", Reingold and Dershowitz. + */ + public static DateTimeValue timeFromSecsSinceEpoch(long secsSinceEpoch) { + // TODO: should we handle -ve years? + int secsInDay = (int) (secsSinceEpoch % SECS_PER_DAY); + int daysSinceEpoch = (int) (secsSinceEpoch / SECS_PER_DAY); + int approx = (int) ((daysSinceEpoch + 10) * 400L / 146097); + int year = (daysSinceEpoch >= fixedFromGregorian(approx+1, 1, 1)) + ? approx+1 : approx; + int jan1 = fixedFromGregorian(year, 1, 1); + int priorDays = daysSinceEpoch - jan1; + int march1 = fixedFromGregorian(year, 3, 1); + int correction = (daysSinceEpoch < march1) ? 0 : + isLeapYear(year) ? 1 : 2; + int month = (12 * (priorDays + correction) + 373) / 367; + int month1 = fixedFromGregorian(year, month, 1); + int day = daysSinceEpoch - month1 + 1; + int second = secsInDay % 60; + int minutesInDay = secsInDay / 60; + int minute = minutesInDay % 60; + int hour = minutesInDay / 60; + if (!(hour >= 0 && hour < 24)) throw new AssertionError( + "Input was: " + secsSinceEpoch + "to make hour: " + hour); + DateTimeValue result = + new DateTimeValueImpl(year, month, day, hour, minute, second); + // assert result.equals(normalize(result)); + // assert secsSinceEpoch(result) == secsSinceEpoch; + return result; + } + + private static final long SECS_PER_DAY = 60L * 60 * 24; + + /** + * Compute the number of seconds from the Proleptic Gregorian epoch + * to the given time. + */ + public static long secsSinceEpoch(DateValue date) { + long result = fixedFromGregorian(date) * + SECS_PER_DAY; + if (date instanceof TimeValue) { + TimeValue time = (TimeValue) date; + result += + time.second() + + 60 * (time.minute() + + 60 * time.hour()); + } + return result; + } + + public static DateTimeValue dayStart(DateValue dv) { + return new DateTimeValueImpl(dv.year(), dv.month(), dv.day(), 0, 0, 0); + } + + /** + * a DateValue with the same year, month, and day as the given instance that + * is not a TimeValue. + */ + public static DateValue toDateValue(DateValue dv) { + return (!(dv instanceof TimeValue) ? dv + : new DateValueImpl(dv.year(), dv.month(), dv.day())); + } + + private static final TimeZone BOGUS_TIMEZONE = + TimeZone.getTimeZone("noSuchTimeZone"); + + private static final Pattern UTC_TZID = + Pattern.compile("^GMT([+-]0(:00)?)?$|UTC|Zulu|Etc\\/GMT|Greenwich.*", + Pattern.CASE_INSENSITIVE); + + /** + * returns the timezone with the given name or null if no such timezone. + * calendar/common/ICalUtil uses this function + */ + public static TimeZone timeZoneForName(String tzString) { + // This is a horrible hack since there is no easier way to get a timezone + // only if the string is recognized as a timezone. + // The TimeZone.getTimeZone javadoc says the following: + // Returns: + // the specified TimeZone, or the GMT zone if the given ID cannot be + // understood. + TimeZone tz = TimeZone.getTimeZone(tzString); + if (tz.hasSameRules(BOGUS_TIMEZONE)) { + // see if the user really was asking for GMT because if + // TimeZone.getTimeZone can't recognize tzString, then that is what it + // will return. + Matcher m = UTC_TZID.matcher(tzString); + if (m.matches()) { + return TimeUtils.utcTimezone(); + } + // unrecognizable timezone + return null; + } + return tz; + } + + private TimeUtils() {} // uninstantiable + +} diff --git a/src/main/java/com/google/ical/values/AbstractIcalObject.java b/src/main/java/com/google/ical/values/AbstractIcalObject.java new file mode 100644 index 0000000..edc7644 --- /dev/null +++ b/src/main/java/com/google/ical/values/AbstractIcalObject.java @@ -0,0 +1,101 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + + +import java.text.ParseException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Base class for a mutable ICAL object. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +abstract class AbstractIcalObject implements IcalObject { + private static final Pattern CONTENT_LINE_RE = Pattern.compile( + "^((?:[^:;\"]|\"[^\"]*\")+)(;(?:[^:\"]|\"[^\"]*\")+)?:(.*)$"); + private static final Pattern PARAM_RE = Pattern.compile( + "^;([^=]+)=(?:\"([^\"]*)\"|([^\";:]*))"); + static final Pattern ICAL_SPECIALS = Pattern.compile("[:;]"); + + private String name; + /** + * paramter values. Does not currently allow multiple values for the same + * property. + */ + private Map extParams = null; + + /** + * parse the ical object from the given ical content using the given schema. + * Modifies the current object in place. + * + * @param schema rules for processing individual parameters and body content. + */ + protected void parse(String icalString, IcalSchema schema) + throws ParseException { + + String paramText; + String content; + { + String unfolded = IcalParseUtil.unfoldIcal(icalString); + Matcher m = CONTENT_LINE_RE.matcher(unfolded); + if (!m.matches()) { schema.badContent(icalString); } + + setName(m.group(1).toUpperCase()); + paramText = m.group(2); + if (null == paramText) { paramText = ""; } + content = m.group(3); + } + + // parse parameters + Map params = new HashMap(); + String rest = paramText; + while (!"".equals(rest)) { + Matcher m = PARAM_RE.matcher(rest); + if (!m.find()) { schema.badPart(rest, null); } + rest = rest.substring(m.end(0)); + String k = m.group(1).toUpperCase(); + String v = m.group(2); + if (null == v) { v = m.group(3); } + if (params.containsKey(k)) { + schema.dupePart(k); + } + params.put(k, v); + } + // parse the content and individual attribute values + schema.applyObjectSchema(this.name, params, content, this); + } + + /** the object name such as RRULE, EXRULE, VEVENT. @see #setName */ + public String getName() { return name; } + /** @see #getName */ + public void setName(String name) { this.name = name; } + /** + * a map of any extension parameters such as the X-FOO=BAR in RRULE;X-FOO=BAR. + * Maps the parameter name, X-FOO, to the parameter value, BAR. + */ + public Map getExtParams() { + if (null == extParams) { extParams = new LinkedHashMap(); } + return extParams; + } + public boolean hasExtParams() { + return null != extParams && !extParams.isEmpty(); + } + +} diff --git a/src/main/java/com/google/ical/values/DateTimeValue.java b/src/main/java/com/google/ical/values/DateTimeValue.java new file mode 100644 index 0000000..639d181 --- /dev/null +++ b/src/main/java/com/google/ical/values/DateTimeValue.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2006 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * All Rights Reserved. + */ + +package com.google.ical.values; + +/** + * An instant in time. + * + * @author Neal Gafter + */ +public interface DateTimeValue extends DateValue, TimeValue { +} diff --git a/src/main/java/com/google/ical/values/DateTimeValueImpl.java b/src/main/java/com/google/ical/values/DateTimeValueImpl.java new file mode 100644 index 0000000..7b04a34 --- /dev/null +++ b/src/main/java/com/google/ical/values/DateTimeValueImpl.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2006 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * All Rights Reserved. + */ + +package com.google.ical.values; + +/** + * An instant in time. + * + * @author Neal Gafter + */ +public class DateTimeValueImpl + extends DateValueImpl + implements DateTimeValue { + private final int hour, minute, second; + + public DateTimeValueImpl(int year, int month, int day, + int hour, int minute, int second) { + super(year, month, day); + this.hour = hour; + this.minute = minute; + this.second = second; + } + + public int hour() { + return hour; + } + + public int minute() { + return minute; + } + + public int second() { + return second; + } + + @Override + public int hashCode() { + return super.hashCode() ^ + ((this.hour << 12) + (this.minute << 6) + this.second); + } + + @Override + public String toString() { + return String.format("%sT%02d%02d%02d", + super.toString(), + hour, minute, second); + } +} + + + + + diff --git a/src/main/java/com/google/ical/values/DateValue.java b/src/main/java/com/google/ical/values/DateValue.java new file mode 100644 index 0000000..b6c1d21 --- /dev/null +++ b/src/main/java/com/google/ical/values/DateValue.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2006 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * All Rights Reserved. + */ + +package com.google.ical.values; + +/** + * A calendar date. + * + * @author Neal Gafter + */ +public interface DateValue extends Comparable { + + /** The Gregorian year, for example 2004. */ + int year(); + + /** The Gregorian month, in the range 1-12. */ + int month(); + + /** The Gregorian day of the month, in the range 1-31. */ + int day(); +} diff --git a/src/main/java/com/google/ical/values/DateValueImpl.java b/src/main/java/com/google/ical/values/DateValueImpl.java new file mode 100644 index 0000000..38817e9 --- /dev/null +++ b/src/main/java/com/google/ical/values/DateValueImpl.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2006 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * All Rights Reserved. + */ + +package com.google.ical.values; + +/** + * A calendar date. + * + * @author Neal Gafter + */ +public class DateValueImpl implements DateValue { + private final int year, month, day; + + public DateValueImpl(int year, int month, int day) { + this.year = year; + this.month = month; + this.day = day; + } + + public int year() { + return year; + } + + public int month() { + return month; + } + + public int day() { + return day; + } + + @Override + public String toString() { + return String.format("%04d%02d%02d", year, month, day); + } + + public final int compareTo(DateValue other) { + int n0 = this.day() + // 5 bits + (this.month() << 5) + // 4 bits + (this.year() << 9); + int n1 = other.day() + + (other.month() << 5) + + (other.year() << 9); + if (n0 != n1) return n0 - n1; + if (!(this instanceof TimeValue)) + return (other instanceof TimeValue) ? -1 : 0; + + TimeValue self = (TimeValue) this; + if (!(other instanceof TimeValue)) return 1; + TimeValue othr = (TimeValue) other; + int m0 = self.second() + // 6 bits + (self.minute() << 6) + // 6 bits + (self.hour() << 12); + int m1 = othr.second() + + (othr.minute() << 6) + + (othr.hour() << 12); + return m0 - m1; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof DateValue)) { return false; } + + return 0 == this.compareTo((DateValue) o); + } + + @Override + public int hashCode() { + return (this.year << 9) + (this.month << 5) + this.day; + } +} diff --git a/src/main/java/com/google/ical/values/Frequency.java b/src/main/java/com/google/ical/values/Frequency.java new file mode 100644 index 0000000..0e02308 --- /dev/null +++ b/src/main/java/com/google/ical/values/Frequency.java @@ -0,0 +1,47 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +/** + * possible recurrence frequencies. Names correspond to RFC2445 literals. + * + *

According to section 4.3.10 of RFC 2445:

+ *
+ * The FREQ rule part identifies the type of recurrence rule. This rule + * part MUST be specified in the recurrence rule. Valid values include + * SECONDLY, to specify repeating events based on an interval of a + * second or more; MINUTELY, to specify repeating events based on an + * interval of a minute or more; HOURLY, to specify repeating events + * based on an interval of an hour or more; DAILY, to specify repeating + * events based on an interval of a day or more; WEEKLY, to specify + * repeating events based on an interval of a week or more; MONTHLY, to + * specify repeating events based on an interval of a month or more; and + * YEARLY, to specify repeating events based on an interval of a year or + * more. + *
+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public enum Frequency { + // in order of increasing length + SECONDLY, + MINUTELY, + HOURLY, + DAILY, + WEEKLY, + MONTHLY, + YEARLY, + ; +} diff --git a/src/main/java/com/google/ical/values/IcalObject.java b/src/main/java/com/google/ical/values/IcalObject.java new file mode 100644 index 0000000..bcf44f8 --- /dev/null +++ b/src/main/java/com/google/ical/values/IcalObject.java @@ -0,0 +1,36 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import java.util.Map; + +/** + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface IcalObject { + + /** the name of the content line, such as EXRULE, SUMMARY, ORGANIZER. */ + String getName(); + /** returns a String containing ical content lines. */ + String toIcal(); + /** + * map of extension parameters such as X-Google-Foo=bar. + * The iteration order is the order they appear in the + * content line. + */ + Map getExtParams(); + +} diff --git a/src/main/java/com/google/ical/values/IcalParseUtil.java b/src/main/java/com/google/ical/values/IcalParseUtil.java new file mode 100644 index 0000000..baa5918 --- /dev/null +++ b/src/main/java/com/google/ical/values/IcalParseUtil.java @@ -0,0 +1,122 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import com.google.ical.util.DTBuilder; +import com.google.ical.util.TimeUtils; +import java.text.ParseException; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * static functions for parsing ical values. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public final class IcalParseUtil { + + private static final Pattern DATE_VALUE = Pattern.compile( + "(\\d{4,})(\\d\\d)(\\d\\d)" + + "(?:T([0-1]\\d|2[0-3])([0-5]\\d)([0-5]\\d)(Z)?)?"); + + /** parses a date of the form yyyymmdd or yyyymmdd'T'hhMMss */ + public static DateValue parseDateValue(String s) throws ParseException { + return parseDateValue(s, null); + } + + /** + * parses a date of the form yyyymmdd or yyyymmdd'T'hhMMss converting from + * the given timezone to UTC. + */ + public static DateValue parseDateValue(String s, TimeZone tzid) + throws ParseException { + Matcher m = DATE_VALUE.matcher(s); + if (!m.matches()) { throw new ParseException(s, 0); } + int year = Integer.parseInt(m.group(1)), + month = Integer.parseInt(m.group(2)), + day = Integer.parseInt(m.group(3)); + if (null != m.group(4)) { + int hour = Integer.parseInt(m.group(4)), + minute = Integer.parseInt(m.group(5)), + second = Integer.parseInt(m.group(6)); + boolean utc = null != m.group(7); + + DateValue dv = new DTBuilder( + year, month, day, hour, minute, second).toDateTime(); + if (!utc && null != tzid) { + dv = TimeUtils.toUtc(dv, tzid); + } + return dv; + } else { + return new DTBuilder(year, month, day).toDate(); + } + } + + /** + * parse a period value of the form <start>/<end>. + * This does not yet recognize the <start>/<duration> form. + */ + public static PeriodValue parsePeriodValue(String s) throws ParseException { + return parsePeriodValue(s, null); + } + + /** + * parse a period value of the form <start>/<end>, converting + * from the given timezone to UTC. + * This does not yet recognize the <start>/<duration> form. + */ + public static PeriodValue parsePeriodValue(String s, TimeZone tzid) + throws ParseException { + int sep = s.indexOf('/'); + if (sep < 0) { throw new ParseException(s, s.length()); } + DateValue start = parseDateValue(s.substring(0, sep), tzid), + end = parseDateValue(s.substring(sep + 1), tzid); + if ((start instanceof TimeValue) != (end instanceof TimeValue)) { + throw new ParseException(s, 0); + } + try { + return PeriodValueImpl.create(start, end); + } catch (IllegalArgumentException ex) { + throw (ParseException) new ParseException(s, sep + 1).initCause(ex); + } + } + + /** + * unfolds ical content lines as per RFC 2445 section 4.1. + * + *

4.1 Content Lines

+ * + *

The iCalendar object is organized into individual lines of text, called + * content lines. Content lines are delimited by a line break, which is a CRLF + * sequence (US-ASCII decimal 13, followed by US-ASCII decimal 10). + * + *

Lines of text SHOULD NOT be longer than 75 octets, excluding the line + * break. Long content lines SHOULD be split into a multiple line + * representations using a line "folding" technique. That is, a long line can + * be split between any two characters by inserting a CRLF immediately + * followed by a single linear white space character (i.e., SPACE, US-ASCII + * decimal 32 or HTAB, US-ASCII decimal 9). Any sequence of CRLF followed + * immediately by a single linear white space character is ignored (i.e., + * removed) when processing the content type. + */ + public static String unfoldIcal(String foldedContentLines) { + return IGNORABLE_ICAL_WHITESPACE.matcher(foldedContentLines).replaceAll(""); + } + private static final Pattern IGNORABLE_ICAL_WHITESPACE = + Pattern.compile("(?:\\r\\n?|\\n)[ \t]"); + + private IcalParseUtil() { } +} diff --git a/src/main/java/com/google/ical/values/IcalSchema.java b/src/main/java/com/google/ical/values/IcalSchema.java new file mode 100644 index 0000000..2c76df7 --- /dev/null +++ b/src/main/java/com/google/ical/values/IcalSchema.java @@ -0,0 +1,204 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * ical objects are made up of parameters (key=value pairs) and the contents + * are often one or more value types or key=value pairs. + * This schema encapsulates rules that can be applied to parse each part before + * inserting the results into the {@link IcalObject}. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +class IcalSchema { + + /** rules for decoding parameter values */ + private Map paramRules = + new HashMap(); + + /** rules for decoding parts of the content body */ + private Map contentRules = + new HashMap(); + + /** rules for breaking the content body or parameters into parts */ + private Map objectRules = + new HashMap(); + + /** rules for parsing value types */ + private Map xformRules = + new HashMap(); + + /** list of productions that we're processing for debugging. */ + private List ruleStack = new ArrayList(); + + private static final Pattern EXTENSION_PARAM_NAME_RE = + Pattern.compile("^X-[A-Z0-9\\-]+$", Pattern.CASE_INSENSITIVE); + + IcalSchema(Map paramRules, + Map contentRules, + Map objectRules, + Map xformRules) { + this.paramRules.putAll(paramRules); + this.contentRules.putAll(contentRules); + this.objectRules.putAll(objectRules); + this.xformRules.putAll(xformRules); + } + + ///////////////////////////////// + // Parser Core + ///////////////////////////////// + + public void applyParamsSchema( + String rule, Map params, IcalObject out) + throws ParseException { + for (Map.Entry param : params.entrySet()) { + String name = param.getKey(); + applyParamSchema(rule, name, param.getValue(), out); + } + } + + public void applyParamSchema( + String rule, String name, String value, IcalObject out) + throws ParseException { + // all elements are allowed extension parameters + if (EXTENSION_PARAM_NAME_RE.matcher(name).find()) { + out.getExtParams().put(name, value); + return; + } + // if not an extension, apply the rule + ruleStack.add(rule); + try { + (paramRules.get(rule)).apply(this, name, value, out); + } finally { + ruleStack.remove(ruleStack.get(ruleStack.size() - 1)); + } + } + + public void applyContentSchema(String rule, String content, IcalObject out) + throws ParseException { + ruleStack.add(rule); + try { + try { + (contentRules.get(rule)).apply(this, content, out); + } catch (NumberFormatException ex) { + badContent(content); + } catch (IllegalArgumentException ex) { + badContent(content); + } + } finally { + ruleStack.remove(ruleStack.get(ruleStack.size() - 1)); + } + } + + public void applyObjectSchema( + String rule, Map params, String content, IcalObject out) + throws ParseException { + ruleStack.add(rule); + try { + (objectRules.get(rule)).apply(this, params, content, out); + } finally { + ruleStack.remove(ruleStack.get(ruleStack.size() - 1)); + } + } + + public Object applyXformSchema(String rule, String content) + throws ParseException { + ruleStack.add(rule); + try { + try { + return (xformRules.get(rule)).apply(this, content); + } catch (NumberFormatException ex) { + badContent(content); + } catch (IllegalArgumentException ex) { + badContent(content); + } + throw new AssertionError(); // badContent raises an exception + } finally { + ruleStack.remove(ruleStack.get(ruleStack.size() - 1)); + } + } + + ///////////////////////////////// + // Parser Error Handling + ///////////////////////////////// + + public void badParam(String name, String value) + throws ParseException { + throw new ParseException("parameter " + name + " has bad value [[" + + value + "]] in " + ruleStack, 0); + } + + public void badPart(String part, String msg) throws ParseException { + if (null != msg) { msg = " : " + msg; } else { msg = ""; } + throw new ParseException("cannot parse [[" + part + "]] in " + + ruleStack + msg, 0); + } + + public void dupePart(String part) throws ParseException { + throw new ParseException( + "duplicate part [[" + part + "]] in " + ruleStack, 0); + } + + public void missingPart(String partName, String content) + throws ParseException { + throw new ParseException("missing part " + partName + " from [[" + content + + "]] in " + ruleStack, 0); + } + + public void badContent(String content) throws ParseException { + throw new ParseException( + "cannot parse content line [[" + content + "]] in " + ruleStack, 0); + } + + /** + * rule applied to parse an entire content line after its been split into + * unparsed/unescaped parameters and unescaped content. + * @param schema the schema used to provide further rules. + * @param params + */ + public interface ObjectRule { + public void apply(IcalSchema schema, + Map params, + String content, + IcalObject target) + throws ParseException; + } + + /** rule applied to an ical content line parameter. */ + public interface ParamRule { + public void apply( + IcalSchema schema, String name, String value, IcalObject out) + throws ParseException; + } + + /** rule applied to part of the ical content body. */ + public interface ContentRule { + public void apply(IcalSchema schema, String content, IcalObject target) + throws ParseException; + } + + /** rule applied to parse an ical data value from its string form. */ + public interface XformRule { + public Object apply(IcalSchema schema, String content) + throws ParseException; + } +} diff --git a/src/main/java/com/google/ical/values/IcalValueType.java b/src/main/java/com/google/ical/values/IcalValueType.java new file mode 100644 index 0000000..71cee57 --- /dev/null +++ b/src/main/java/com/google/ical/values/IcalValueType.java @@ -0,0 +1,90 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +/** + * a value type for an Ical property. + * + *

+ * 4.2.20 Value Data Types
+ *
+ * Parameter Name: VALUE
+ *
+ * Purpose: To explicitly specify the data type format for a property value.
+ *
+ * Format Definition: The "VALUE" property parameter is defined by the
+ * following notation:
+ *
+ *      valuetypeparam = "VALUE" "=" valuetype
+ *
+ *      valuetype  = ("BINARY"
+ *                 / "BOOLEAN"
+ *                 / "CAL-ADDRESS"
+ *                 / "DATE"
+ *                 / "DATE-TIME"
+ *                 / "DURATION"
+ *                 / "FLOAT"
+ *                 / "INTEGER"
+ *                 / "PERIOD"
+ *                 / "RECUR"
+ *                 / "TEXT"
+ *                 / "TIME"
+ *                 / "URI"
+ *                 / "UTC-OFFSET"
+ *                 / x-name
+ *                 ; Some experimental iCalendar data type.
+ *                 / iana-token)
+ *                 ; Some other IANA registered iCalendar data type.
+ *
+ *
+ * Description: The parameter specifies the data type and format of the property
+ * value. The property values MUST be of a single value type. For example, a
+ * "RDATE" property cannot have a combination of DATE- TIME and TIME value
+ * types.
+ *
+ * If the property's value is the default value type, then this parameter need
+ * not be specified. However, if the property's default value type is overridden
+ * by some other allowable value type, then this parameter MUST be specified.
+ * 
+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public enum IcalValueType { + + BINARY, + BOOLEAN, + CAL_ADDRESS, + DATE, + DATE_TIME, + DURATION, + FLOAT, + INTEGER, + PERIOD, + RECUR, + TEXT, + TIME, + URI, + UTC_OFFSET, + + X_NAME, + OTHER, + ; + + public static IcalValueType fromIcal(String icalValue) { + return valueOf(icalValue.toUpperCase().replace('-', '_')); + } + + public String toIcal() { return name().replace('_', '-'); } +} diff --git a/src/main/java/com/google/ical/values/PeriodValue.java b/src/main/java/com/google/ical/values/PeriodValue.java new file mode 100644 index 0000000..e07bfcd --- /dev/null +++ b/src/main/java/com/google/ical/values/PeriodValue.java @@ -0,0 +1,54 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + + +/** + * a half-open range of {@link DateValue}s. The start is inclusive, and the + * end is exclusive. The end must be on or after the start. When the start and + * end are the same, the period is zero width, i.e. contains zero seconds. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public interface PeriodValue { + + /** + * the start of the period. + * @return non null. + */ + DateValue start(); + + /** + * the end of the period. + * The end must be >= {@link #start()}, and + * (start() instanceof {@link TimeValue}) == + * (end() instanceof TimeValue). + * @return non null. + */ + DateValue end(); + + /** + * true iff this period overlaps the given period. + * @param pv not null. + */ + boolean intersects(PeriodValue pv); + + /** + * true iff this period completely contains the given period. + * @param pv not null. + */ + boolean contains(PeriodValue pv); + +} // PeriodValue diff --git a/src/main/java/com/google/ical/values/PeriodValueImpl.java b/src/main/java/com/google/ical/values/PeriodValueImpl.java new file mode 100644 index 0000000..2f31979 --- /dev/null +++ b/src/main/java/com/google/ical/values/PeriodValueImpl.java @@ -0,0 +1,104 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import com.google.ical.util.TimeUtils; + +/** + * a half-open range of {@link DateValue}s. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class PeriodValueImpl implements PeriodValue { + + private DateValue start, end; + + /** + * returns a period with the given start and end dates. + * @param start non null. + * @param end on or after start. May/must have a time if the start has a + * time. + */ + public static PeriodValue create(DateValue start, DateValue end) { + return new PeriodValueImpl(start, end); + } + + /** + * returns a period with the given start date and duration. + * @param start non null. + * @param dur a positive duration represented as a DateValue. + */ + public static PeriodValue createFromDuration(DateValue start, DateValue dur) { + DateValue end = TimeUtils.add(start, dur); + if (end instanceof TimeValue && !(start instanceof TimeValue)) { + start = TimeUtils.dayStart(start); + } + return new PeriodValueImpl(start, end); + } + + protected PeriodValueImpl(DateValue start, DateValue end) { + if (start.compareTo(end) > 0) { + throw new IllegalArgumentException + ("Start (" + start + ") must precede end (" + end + ")"); + } + if ((start instanceof TimeValue) ^ (end instanceof TimeValue)) { + throw new IllegalArgumentException + ("Start (" + start + ") and end (" + end + + ") must both have times or neither have times."); + } + this.start = start; + this.end = end; + } + + public DateValue start() { return start; } + + public DateValue end() { return end; } + + /** true iff this period overlaps the given period. */ + public boolean intersects(PeriodValue pv) { + DateValue sa = this.start, + ea = this.end, + sb = pv.start(), + eb = pv.end(); + + return sa.compareTo(eb) < 0 && sb.compareTo(ea) < 0; + } + + /** true iff this period completely contains the given period. */ + public boolean contains(PeriodValue pv) { + DateValue sa = this.start, + ea = this.end, + sb = pv.start(), + eb = pv.end(); + + return !(sb.compareTo(sa) < 0 || ea.compareTo(eb) < 0); + } + + @Override public boolean equals(Object o) { + if (!(o instanceof PeriodValue)) { return false; } + PeriodValue that = (PeriodValue) o; + return this.start().equals(that.start()) + && this.end().equals(that.end()); + } + + @Override public int hashCode() { + return start.hashCode() ^ (31 * end.hashCode()); + } + + @Override public String toString() { + return start().toString() + "/" + end().toString(); + } + +} // PeriodValueImpl diff --git a/src/main/java/com/google/ical/values/RDateList.java b/src/main/java/com/google/ical/values/RDateList.java new file mode 100644 index 0000000..c139f47 --- /dev/null +++ b/src/main/java/com/google/ical/values/RDateList.java @@ -0,0 +1,101 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import java.text.ParseException; +import java.util.Map; +import java.util.TimeZone; + +/** + * a list of dates, as from an RDATE or EXDATE ical property. + * + *

See RFC 2445 sections 4.8.5.1 and 4.8.5.3. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class RDateList extends AbstractIcalObject { + + private TimeZone tzid; + private DateValue[] datesUtc; + private IcalValueType valueType; + + public RDateList(String icalString, TimeZone tzid) throws ParseException { + setTzid(tzid); + parse(icalString, RRuleSchema.instance()); + } + + public RDateList(TimeZone tzid) { + setTzid(tzid); + setName("RDATE"); + datesUtc = new DateValue[0]; + } + + public TimeZone getTzid() { return this.tzid; } + public void setTzid(TimeZone tzid) { + assert null != tzid; + this.tzid = tzid; + } + + public DateValue[] getDatesUtc() { + return null != this.datesUtc ? this.datesUtc.clone() : null; + } + public void setDatesUtc(DateValue[] datesUtc) { + this.datesUtc = datesUtc.clone(); + if (datesUtc.length > 0) { + setValueType((datesUtc[0] instanceof TimeValue) + ? IcalValueType.DATE_TIME + : IcalValueType.DATE); + } + } + + /** + * the type of the values contained by this list as reported by the ical + * "VALUE" parameter, typically DATE or DATE-TIME. + */ + public IcalValueType getValueType() { + return valueType; + } + + public void setValueType(IcalValueType valueType) { + this.valueType = valueType; + } + + /** returns a String containing ical content lines. */ + public String toIcal() { + StringBuilder buf = new StringBuilder(); + buf.append(this.getName().toUpperCase()); + buf.append(";TZID=\"").append(tzid.getID()).append('"'); + buf.append(";VALUE=").append(valueType.toIcal()); + if (hasExtParams()) { + for (Map.Entry param : getExtParams().entrySet()) { + String k = param.getKey(), + v = param.getValue(); + if (ICAL_SPECIALS.matcher(v).find()) { + v = "\"" + v + "\""; + } + buf.append(';').append(k).append('=').append(v); + } + } + buf.append(':'); + for (int i = 0; i < datesUtc.length; ++i) { + if (0 != i) { buf.append(','); } + DateValue v = datesUtc[i]; + buf.append(v); + if (v instanceof TimeValue) { buf.append('Z'); } + } + return buf.toString(); + } + +} diff --git a/src/main/java/com/google/ical/values/RRule.java b/src/main/java/com/google/ical/values/RRule.java new file mode 100644 index 0000000..d43bbaa --- /dev/null +++ b/src/main/java/com/google/ical/values/RRule.java @@ -0,0 +1,255 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * encapsulates an RFC 2445 RRULE or EXRULE. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class RRule extends AbstractIcalObject { + + private Frequency freq; + private Weekday wkst; + private DateValue until; + private int count; + private int interval; + private List byDay = new ArrayList(); + private int[] byMonth = NO_INTS; // in +/-[1-12] + private int[] byMonthDay = NO_INTS; // in +/-[1-31] + private int[] byWeekNo = NO_INTS; // in +/-[1-53] + private int[] byYearDay = NO_INTS; // in +/-[1-366] + private int[] byHour = NO_INTS; // in [0-23] + private int[] byMinute = NO_INTS; // in [0-59] + private int[] bySecond = NO_INTS; // in [0-60] + private int[] bySetPos = NO_INTS; // in +/-[1-366] + + private static final int[] NO_INTS = new int[0]; + + public RRule() { + this.freq = Frequency.DAILY; + setName("RRULE"); + } + + public RRule(String icalString) throws ParseException { + parse(VcalRewriter.rewriteRule(icalString), RRuleSchema.instance()); + } + + /** + * formats as an *unfolded* RFC 2445 content line. + */ + public String toIcal() { + StringBuilder buf = new StringBuilder(); + buf.append(this.getName()); + if (hasExtParams()) { + for (Map.Entry param : getExtParams().entrySet()) { + String k = param.getKey(), + v = param.getValue(); + if (ICAL_SPECIALS.matcher(v).find()) { + v = "\"" + v + "\""; + } + buf.append(';').append(k).append('=').append(v); + } + } + buf.append(":FREQ=").append(freq); + if (null != wkst) { + buf.append(";WKST=").append(wkst.toString()); + } + if (null != this.until) { + buf.append(";UNTIL=").append(until); + if (until instanceof TimeValue) { + buf.append('Z'); + } + } + if (count != 0) { + buf.append(";COUNT=").append(count); + } + if (interval != 0) { + buf.append(";INTERVAL=").append(interval); + } + if (0 != byYearDay.length) { + buf.append(";BYYEARDAY="); + writeIntList(byYearDay, buf); + } + if (0 != byMonth.length) { + buf.append(";BYMONTH="); + writeIntList(byMonth, buf); + } + if (0 != byMonthDay.length) { + buf.append(";BYMONTHDAY="); + writeIntList(byMonthDay, buf); + } + if (0 != byWeekNo.length) { + buf.append(";BYWEEKNO="); + writeIntList(byWeekNo, buf); + } + if (!byDay.isEmpty()) { + buf.append(";BYDAY="); + boolean first = true; + for (WeekdayNum day : this.byDay) { + if (!first) { + buf.append(','); + } else { + first = false; + } + buf.append(day); + } + } + if (0 != byHour.length) { + buf.append(";BYHOUR="); + writeIntList(byHour, buf); + } + if (0 != byMinute.length) { + buf.append(";BYMINUTE="); + writeIntList(byMinute, buf); + } + if (0 != bySecond.length) { + buf.append(";BYSECOND="); + writeIntList(bySecond, buf); + } + if (0 != bySetPos.length) { + buf.append(";BYSETPOS="); + writeIntList(bySetPos, buf); + } + return buf.toString(); + } + + private static void writeIntList(int[] nums, StringBuilder out) { + for (int i = 0; i < nums.length; ++i) { + if (0 != i) { out.append(','); } + out.append(nums[i]); + } + } + + /** an approximate number of days between occurences. */ + public int approximateIntervalInDays() { + int freqLengthDays; + int nPerPeriod = 0; + switch (this.freq) { + case DAILY: + freqLengthDays = 1; + break; + case WEEKLY: + freqLengthDays = 7; + if (!this.byDay.isEmpty()) { + nPerPeriod = this.byDay.size(); + } + break; + case MONTHLY: + freqLengthDays = 30; + if (!this.byDay.isEmpty()) { + for (WeekdayNum day : byDay) { + // if it's every weekday in the month, assume four of that weekday, + // otherwise there is one of that week-in-month,weekday pair + nPerPeriod += 0 != day.num ? 1 : 4; + } + } else { + nPerPeriod = this.byMonthDay.length; + } + break; + case YEARLY: + freqLengthDays = 365; + + int monthCount = 12; + if (0 != this.byMonth.length) { + monthCount = this.byMonth.length; + } + + if (!this.byDay.isEmpty()) { + for (WeekdayNum day : byDay) { + // if it's every weekend in the months in the year, + // assume 4 of that weekday per month, + // otherwise there is one of that week-in-month,weekday pair per + // month + nPerPeriod += (0 != day.num ? 1 : 4) * monthCount; + } + } else if (0 != this.byMonthDay.length) { + nPerPeriod += monthCount * this.byMonthDay.length; + } else { + nPerPeriod += this.byYearDay.length; + } + break; + default: freqLengthDays = 0; + } + if (0 == nPerPeriod) { nPerPeriod = 1; } + + return ((freqLengthDays / nPerPeriod) * this.interval); + } + + /** the frequency of repetition */ + public Frequency getFreq() { return this.freq; } + public void setFreq(Frequency freq) { + this.freq = freq; + } + /** day of the week the week starts on */ + public Weekday getWkSt() { return this.wkst; } + public void setWkSt(Weekday wkst) { + this.wkst = wkst; + } + public DateValue getUntil() { return this.until; } + public void setUntil(DateValue until) { + this.until = until; + } + public int getCount() { return this.count; } + public void setCount(int count) { + this.count = count; + } + public int getInterval() { return this.interval; } + public void setInterval(int interval) { + this.interval = interval; + } + public List getByDay() { return this.byDay; } + public void setByDay(List byDay) { + this.byDay = new ArrayList(byDay); + } + public int[] getByMonth() { return this.byMonth; } + public void setByMonth(int[] byMonth) { + this.byMonth = byMonth.clone(); + } + public int[] getByMonthDay() { return this.byMonthDay; } + public void setByMonthDay(int[] byMonthDay) { + this.byMonthDay = byMonthDay.clone(); + } + public int[] getByWeekNo() { return this.byWeekNo; } + public void setByWeekNo(int[] byWeekNo) { + this.byWeekNo = byWeekNo.clone(); + } + public int[] getByYearDay() { return this.byYearDay; } + public void setByYearDay(int[] byYearDay) { + this.byYearDay = byYearDay.clone(); + } + public int[] getBySetPos() { return this.bySetPos; } + public void setBySetPos(int[] bySetPos) { + this.bySetPos = bySetPos.clone(); + } + public int[] getByHour() { return this.byHour; } + public void setByHour(int[] byHour) { + this.byHour = byHour.clone(); + } + public int[] getByMinute() { return this.byMinute; } + public void setByMinute(int[] byMinute) { + this.byMinute = byMinute.clone(); + } + public int[] getBySecond() { return this.bySecond; } + public void setBySecond(int[] bySecond) { + this.bySecond = bySecond.clone(); + } + +} diff --git a/src/main/java/com/google/ical/values/RRuleSchema.java b/src/main/java/com/google/ical/values/RRuleSchema.java new file mode 100644 index 0000000..d5c2993 --- /dev/null +++ b/src/main/java/com/google/ical/values/RRuleSchema.java @@ -0,0 +1,570 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import com.google.ical.util.TimeUtils; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * ical schema for parsing RRULE and EXRULE content lines. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +class RRuleSchema extends IcalSchema{ + + private static final Pattern COMMA = Pattern.compile(","); + private static final Pattern SEMI = Pattern.compile(";"); + private static final Pattern X_NAME_RE = Pattern.compile( + "^X-", Pattern.CASE_INSENSITIVE); + private static final Pattern RRULE_PARTS = Pattern.compile( + "^(FREQ|UNTIL|COUNT|INTERVAL|BYSECOND|BYMINUTE|BYHOUR|BYDAY|BYMONTHDAY|" + + "BYYEARDAY|BYWEEKDAY|BYWEEKNO|BYMONTH|BYSETPOS|WKST|X-[A-Z0-9\\-]+)=" + + "(.*)", Pattern.CASE_INSENSITIVE); + private static final Pattern NUM_DAY = Pattern.compile( + "^([+\\-]?\\d\\d?)?(SU|MO|TU|WE|TH|FR|SA)$", Pattern.CASE_INSENSITIVE); + ///////////////////////////////// + // ICAL Object Schema + ///////////////////////////////// + + private static RRuleSchema instance; + static RRuleSchema instance() { + if (null == instance) { + instance = new RRuleSchema(); + } + return instance; + } + + private RRuleSchema() { + super(PARAM_RULES, CONTENT_RULES, OBJECT_RULES, XFORM_RULES); + } + + private static Map PARAM_RULES = + new HashMap(); + + private static Map CONTENT_RULES = + new HashMap(); + + private static Map OBJECT_RULES = + new HashMap(); + + private static Map XFORM_RULES = + new HashMap(); + + static { + // rrule = "RRULE" rrulparam ":" recur CRLF + // exrule = "EXRULE" exrparam ":" recur CRLF + OBJECT_RULES.put("RRULE", new ObjectRule() { + public void apply( + IcalSchema schema, Map params, String content, + IcalObject target) + throws ParseException { + schema.applyParamsSchema("rrulparam", params, target); + schema.applyContentSchema("recur", content, target); + } + }); + OBJECT_RULES.put("EXRULE", new ObjectRule() { + public void apply( + IcalSchema schema, Map params, String content, + IcalObject target) + throws ParseException { + schema.applyParamsSchema("exrparam", params, target); + schema.applyContentSchema("recur", content, target); + } + }); + + // rrulparam = *(";" xparam) + // exrparam = *(";" xparam) + ParamRule xparamsOnly = new ParamRule() { + public void apply( + IcalSchema schema, String name, String value, IcalObject out) + throws ParseException { + schema.badParam(name, value); + } + }; + PARAM_RULES.put("rrulparam", xparamsOnly); + PARAM_RULES.put("exrparam", xparamsOnly); + + /* + * recur = "FREQ"=freq *( + * + * ; either UNTIL or COUNT may appear in a 'recur', + * ; but UNTIL and COUNT MUST NOT occur in the same 'recur' + * + * ( ";" "UNTIL" "=" enddate ) / + * ( ";" "COUNT" "=" 1*DIGIT ) / + * + * ; the rest of these keywords are optional, + * ; but MUST NOT occur more than once + * + * ( ";" "INTERVAL" "=" 1*DIGIT ) / + * ( ";" "BYSECOND" "=" byseclist ) / + * ( ";" "BYMINUTE" "=" byminlist ) / + * ( ";" "BYHOUR" "=" byhrlist ) / + * ( ";" "BYDAY" "=" bywdaylist ) / + * ( ";" "BYMONTHDAY" "=" bymodaylist ) / + * ( ";" "BYYEARDAY" "=" byyrdaylist ) / + * ( ";" "BYWEEKNO" "=" bywknolist ) / + * ( ";" "BYMONTH" "=" bymolist ) / + * ( ";" "BYSETPOS" "=" bysplist ) / + * ( ";" "WKST" "=" weekday ) / + * + * ( ";" x-name "=" text ) + * ) + */ + CONTENT_RULES.put("recur", new ContentRule() { + public void apply(IcalSchema schema, String content, IcalObject target) + throws ParseException { + String[] parts = SEMI.split(content); + Map partMap = new HashMap(); + for (int i = 0; i < parts.length; ++i) { + String p = parts[i]; + Matcher m = RRULE_PARTS.matcher(p); + if (!m.matches()) { schema.badPart(p, null); } + String k = m.group(1).toUpperCase(), + v = m.group(2); + if (partMap.containsKey(k)) { schema.dupePart(p); } + partMap.put(k, v); + } + if (!partMap.containsKey("FREQ")) { + schema.missingPart("FREQ", content); + } + if (partMap.containsKey("UNTIL") && partMap.containsKey("COUNT")) { + schema.badPart(content, "UNTIL & COUNT are exclusive"); + } + for (Map.Entry part : partMap.entrySet()) { + if (X_NAME_RE.matcher(part.getKey()).matches()) { + // ignore x-name content parts + continue; + } + schema.applyContentSchema(part.getKey(), part.getValue(), target); + } + } + }); + + // exdate = "EXDATE" exdtparam ":" exdtval *("," exdtval) CRLF + OBJECT_RULES.put("EXDATE", new ObjectRule() { + public void apply( + IcalSchema schema, Map params, String content, + IcalObject target) + throws ParseException { + schema.applyParamsSchema("exdtparam", params, target); + for (String part : COMMA.split(content)) { + schema.applyContentSchema("exdtval", part, target); + } + } + }); + + // "FREQ"=freq *( + CONTENT_RULES.put("FREQ", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setFreq( + (Frequency) schema.applyXformSchema("freq", value)); + } + }); + + // ( ";" "UNTIL" "=" enddate ) / + CONTENT_RULES.put("UNTIL", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setUntil( + (DateValue) schema.applyXformSchema("enddate", value)); + } + }); + + // ( ";" "COUNT" "=" 1*DIGIT ) / + CONTENT_RULES.put("COUNT", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setCount(Integer.parseInt(value)); + } + }); + + // ( ";" "INTERVAL" "=" 1*DIGIT ) / + CONTENT_RULES.put("INTERVAL", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setInterval(Integer.parseInt(value)); + } + }); + + // ( ";" "BYSECOND" "=" byseclist ) / + CONTENT_RULES.put("BYSECOND", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setBySecond( + (int[]) schema.applyXformSchema("byseclist", value)); + } + }); + + // ( ";" "BYMINUTE" "=" byminlist ) / + CONTENT_RULES.put("BYMINUTE", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByMinute( + (int[]) schema.applyXformSchema("byminlist", value)); + } + }); + + // ( ";" "BYHOUR" "=" byhrlist ) / + CONTENT_RULES.put("BYHOUR", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByHour( + (int[]) schema.applyXformSchema("byhrlist", value)); + } + }); + + // ( ";" "BYDAY" "=" bywdaylist ) / + CONTENT_RULES.put("BYDAY", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByDay( + (List) schema.applyXformSchema("bywdaylist", value)); + } + }); + + // ( ";" "BYMONTHDAY" "=" bymodaylist ) / + CONTENT_RULES.put("BYMONTHDAY", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByMonthDay( + (int[]) schema.applyXformSchema("bymodaylist", value)); + } + }); + + // ( ";" "BYYEARDAY" "=" byyrdaylist ) / + CONTENT_RULES.put("BYYEARDAY", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByYearDay( + (int[]) schema.applyXformSchema("byyrdaylist", value)); + } + }); + + // ( ";" "BYWEEKNO" "=" bywknolist ) / + CONTENT_RULES.put("BYWEEKNO", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByWeekNo( + (int[]) schema.applyXformSchema("bywknolist", value)); + } + }); + + // ( ";" "BYMONTH" "=" bymolist ) / + CONTENT_RULES.put("BYMONTH", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setByMonth( + (int[]) schema.applyXformSchema("bymolist", value)); + } + }); + + // ( ";" "BYSETPOS" "=" bysplist ) / + CONTENT_RULES.put("BYSETPOS", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setBySetPos( + (int[]) schema.applyXformSchema("bysplist", value)); + } + }); + + // ( ";" "WKST" "=" weekday ) / + CONTENT_RULES.put("WKST", new ContentRule() { + public void apply(IcalSchema schema, String value, IcalObject target) + throws ParseException { + ((RRule) target).setWkSt( + (Weekday) schema.applyXformSchema("weekday", value)); + } + }); + + // freq = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY" + // / "WEEKLY" / "MONTHLY" / "YEARLY" + XFORM_RULES.put("freq", new XformRule() { + public Frequency apply(IcalSchema schema, String value) + throws ParseException { + return Frequency.valueOf(value); + } + }); + + // enddate = date + // enddate =/ date-time ;An UTC value + XFORM_RULES.put("enddate", new XformRule() { + public DateValue apply(IcalSchema schema, String value) + throws ParseException { + return IcalParseUtil.parseDateValue(value.toUpperCase()); + } + }); + + // byseclist = seconds / ( seconds *("," seconds) ) + // seconds = 1DIGIT / 2DIGIT ;0 to 59 + XFORM_RULES.put("byseclist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseUnsignedIntList(value, 0, 59, schema); + } + }); + + // byminlist = minutes / ( minutes *("," minutes) ) + // minutes = 1DIGIT / 2DIGIT ;0 to 59 + XFORM_RULES.put("byminlist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseUnsignedIntList(value, 0, 59, schema); + } + }); + + // byhrlist = hour / ( hour *("," hour) ) + // hour = 1DIGIT / 2DIGIT ;0 to 23 + XFORM_RULES.put("byhrlist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseUnsignedIntList(value, 0, 23, schema); + } + }); + + // plus = "+" + // minus = "-" + + // bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) ) + // weekdaynum = [([plus] ordwk / minus ordwk)] weekday + // ordwk = 1DIGIT / 2DIGIT ;1 to 53 + // weekday = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA" + // ;Corresponding to SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, + // ;FRIDAY, SATURDAY and SUNDAY days of the week. + XFORM_RULES.put("bywdaylist", new XformRule() { + public List apply(IcalSchema schema, String value) + throws ParseException { + String[] parts = COMMA.split(value); + List weekdays = new ArrayList(parts.length); + for (String p : parts) { + Matcher m = NUM_DAY.matcher(p); + if (!m.matches()) { schema.badPart(p, null); } + Weekday wday = Weekday.valueOf(m.group(2).toUpperCase()); + int n; + String numText = m.group(1); + if (null == numText || "".equals(numText)) { + n = 0; + } else { + n = Integer.parseInt(numText); + int absn = n < 0 ? -n : n; + if (!(1 <= absn && 53 >= absn)) { schema.badPart(p, null); } + } + weekdays.add(new WeekdayNum(n, wday)); + } + return weekdays; + } + }); + + XFORM_RULES.put("weekday", new XformRule() { + public Weekday apply(IcalSchema schema, String value) + throws ParseException { + return Weekday.valueOf(value.toUpperCase()); + } + }); + + // bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) ) + // monthdaynum = ([plus] ordmoday) / (minus ordmoday) + // ordmoday = 1DIGIT / 2DIGIT ;1 to 31 + XFORM_RULES.put("bymodaylist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseIntList(value, 1, 31, schema); + } + }); + + // byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) ) + // yeardaynum = ([plus] ordyrday) / (minus ordyrday) + // ordyrday = 1DIGIT / 2DIGIT / 3DIGIT ;1 to 366 + XFORM_RULES.put("byyrdaylist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseIntList(value, 1, 366, schema); + } + }); + + // bywknolist = weeknum / ( weeknum *("," weeknum) ) + // weeknum = ([plus] ordwk) / (minus ordwk) + XFORM_RULES.put("bywknolist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseIntList(value, 1, 53, schema); + } + }); + + // bymolist = monthnum / ( monthnum *("," monthnum) ) + // monthnum = 1DIGIT / 2DIGIT ;1 to 12 + XFORM_RULES.put("bymolist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseIntList(value, 1, 12, schema); + } + }); + + // bysplist = setposday / ( setposday *("," setposday) ) + // setposday = yeardaynum + XFORM_RULES.put("bysplist", new XformRule() { + public int[] apply(IcalSchema schema, String value) + throws ParseException { + return parseIntList(value, 1, 366, schema); + } + }); + + + // rdate = "RDATE" rdtparam ":" rdtval *("," rdtval) CRLF + OBJECT_RULES.put("RDATE", new ObjectRule() { + public void apply( + IcalSchema schema, Map params, String content, + IcalObject target) + throws ParseException { + schema.applyParamsSchema("rdtparam", params, target); + schema.applyContentSchema("rdtval", content, target); + } + }); + // exdate = "EXDATE" exdtparam ":" exdtval *("," exdtval) CRLF + // exdtparam = rdtparam + // exdtval = rdtval + OBJECT_RULES.put("EXDATE", new ObjectRule() { + public void apply( + IcalSchema schema, Map params, String content, + IcalObject target) + throws ParseException { + schema.applyParamsSchema("rdtparam", params, target); + schema.applyContentSchema("rdtval", content, target); + } + }); + + // rdtparam = *( + + // ; the following are optional, + // ; but MUST NOT occur more than once + + // (";" "VALUE" "=" ("DATE-TIME" / "DATE" / "PERIOD")) / + // (";" tzidparam) / + + // ; the following is optional, + // ; and MAY occur more than once + + // (";" xparam) + + // ) + + + // tzidparam = "TZID" "=" [tzidprefix] paramtext CRLF + // tzidprefix = "/" + PARAM_RULES.put( + "rdtparam", + new ParamRule() { + public void apply( + IcalSchema schema, String name, String value, IcalObject out) + throws ParseException { + if ("value".equalsIgnoreCase(name)) { + if ("date-time".equalsIgnoreCase(value) + || "date".equalsIgnoreCase(value) + || "period".equalsIgnoreCase(value)) { + ((RDateList) out).setValueType(IcalValueType.fromIcal(value)); + } else { + schema.badParam(name, value); + } + } else if ("tzid".equalsIgnoreCase(name)) { + if (value.startsWith("/")) { + // is globally defined name. We treat all as globally defined. + value = value.substring(1).trim(); + } + // TODO(msamuel): proper timezone lookup, and warn on failure + TimeZone tz = TimeUtils.timeZoneForName( + value.replaceAll(" ", "_")); + if (null == tz) { schema.badParam(name, value); } + ((RDateList) out).setTzid(tz); + } else { + schema.badParam(name, value); + } + } + }); + PARAM_RULES.put("rrulparam", xparamsOnly); + PARAM_RULES.put("exrparam", xparamsOnly); + + // rdtval = date-time / date / period ;Value MUST match value type + CONTENT_RULES.put("rdtval", new ContentRule() { + public void apply(IcalSchema schema, String content, IcalObject target) + throws ParseException { + RDateList rdates = (RDateList) target; + String[] parts = COMMA.split(content); + DateValue[] datesUtc = new DateValue[parts.length]; + for (int i = 0; i < parts.length; ++i) { + String part = parts[i]; + // TODO(msamuel): figure out what to do with periods. + datesUtc[i] = IcalParseUtil.parseDateValue(part, rdates.getTzid()); + } + rdates.setDatesUtc(datesUtc); + } + }); + + } + + + ///////////////////////////////// + // Parser Helper functions and classes + ///////////////////////////////// + + private static int[] parseIntList( + String commaSeparatedString, int absmin, int absmax, IcalSchema schema) + throws ParseException { + + String[] parts = COMMA.split(commaSeparatedString); + int[] out = new int[parts.length]; + for (int i = parts.length; --i >= 0;) { + try { + int n = Integer.parseInt(parts[i]); + int absn = Math.abs(n); + if (!(absmin <= absn && absmax >= absn)) { + schema.badPart(commaSeparatedString, null); + } + out[i] = n; + } catch (NumberFormatException ex) { + schema.badPart(commaSeparatedString, ex.getMessage()); + } + } + return out; + } + + private static int[] parseUnsignedIntList( + String commaSeparatedString, int min, int max, IcalSchema schema) + throws ParseException { + + String[] parts = COMMA.split(commaSeparatedString); + int[] out = new int[parts.length]; + for (int i = parts.length; --i >= 0;) { + try { + int n = Integer.parseInt(parts[i]); + if (!(min <= n && max >= n)) { + schema.badPart(commaSeparatedString, null); + } + out[i] = n; + } catch (NumberFormatException ex) { + schema.badPart(commaSeparatedString, ex.getMessage()); + } + } + return out; + } + +} diff --git a/src/main/java/com/google/ical/values/TimeValue.java b/src/main/java/com/google/ical/values/TimeValue.java new file mode 100644 index 0000000..a222058 --- /dev/null +++ b/src/main/java/com/google/ical/values/TimeValue.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2006 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * All Rights Reserved. + */ + +package com.google.ical.values; + +/** + * A time of day. + * + * @author Neal Gafter + */ +public interface TimeValue { + + /** The hour in the range 0 through 24. */ + int hour(); + + /** The minute in the range 0 through 59. If hour()==24, then minute() == 0 */ + int minute(); + + /** The second in the range 0 through 59. If hour()==24, then second() == 0 */ + int second(); +} diff --git a/src/main/java/com/google/ical/values/VcalRewriter.java b/src/main/java/com/google/ical/values/VcalRewriter.java new file mode 100644 index 0000000..8ab05ce --- /dev/null +++ b/src/main/java/com/google/ical/values/VcalRewriter.java @@ -0,0 +1,297 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * converts vcal recurrence rules to ical. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +final class VcalRewriter { + + private static final String DATE = "[0-9]{8,}(?:T[0-9]{6}Z?)?"; + /** + * http://www.imc.org/pdi/vcal-10.txt + *

+ * Grammar + * {} 0 or more + * [] 0 or 1 + * + * start ::= <daily> [<enddate>] | + * <weekly> [<enddate>] | + * <monthlybypos> [<enddate>] | + * <monthlybyday> [<enddate>] | + * <yearlybymonth> [<enddate>] | + * <yearlybyday> [<enddate>] + * digit ::= <0|1|2|3|4|5|6|7|8|9> + * digits ::= <digit> {<digits>} + * enddate ::= ISO 8601_date_time value(e.g., 19940712T101530Z) + * interval ::= <digits> + * duration ::= #<digits> + * lastday ::= LD + * plus ::= + + * minus ::= - + * daynumber ::= <1-31> [<plus>|<minus>]| <lastday> + * daynumberlist ::= daynumber {<daynumberlist>} + * month ::= <1-12> + * monthlist ::= <month> {<monthlist>} + * day ::= <1-366> + * daylist ::= <day> {<daylist>} + * occurrence ::= <1-5><plus> | <1-5><minus> + * occurrencelist ::= <occurrence> {<occurrencelist>} + * weekday ::= <SU|MO|TU|WE|TH|FR|SA> + * weekdaylist ::= <weekday> {<weekdaylist>} + * daily ::= D<interval> [<duration>] + * weekly ::= W<interval> [<weekdaylist>] [<duration>] + * monthlybypos ::= MP<interval> [<occurrencelist> <weekdaylist>] + * [<duration>] + * monthlybyday ::= MD<interval> [<daynumberlist>] [<duration>] + * yearlybymonth ::= YM<interval> [<monthlist>] [<duration>] + * yearlybyday ::= YD<interval> [<daylist>] [<duration>] + * + * + * Glossary + * enddate Controls when a repeating event terminates. The enddate is + * the last time an event can occur. + * interval Defines the frequency in which a rule repeats. + * duration Controls the number of events a rule generates. + * lastday Can be used as a replacement to daynumber to indicate the + * last day of the month. + * daynumber A number representing a day of the month. + * month A number representing a month of the year. + * day A number representing a day of the year. + * occurrence Controls which week of the month a particular weekday event + * occurs. + * weekday A symbol representing a day of the week. + * daily Defines a rule that repeats on a daily basis. + * weekly Defines a rule that repeats on a weekly basis. + * monthlybypos Defines a rule that repeats on a monthly basis on a + * relative day and week. + * monthlybyday Defines a rule that repeats on a monthly basis on an + * absolute day. + * yearlybymonth Defines a rule that repeats on specific months of the year. + * yearlybyday Defines a rule that repeats on specific days of the year. + * + * + * Policies + * The duration portion of a rule defines the total number of events the rule + * generates, including the first event. + * Information, not contained in the rule, necessary to determine the next + * event time and date is derived from the Start Time entry attribute. + * If an end date and a duration is specified in the rule, the recurring event + * ceases when the end date is reached or the number of events indicated in + * the duration occur; whichever comes first. + * If the duration or an end date is not established in the rule (e.g., D4) + * the event occurs twice. That is D4 is equivalent to D4 #2. + * A duration of #0 means repeat this event forever. + * Using the occurrence specifier 5+ (e.g. 5th Friday) or 5- (e.g. 5th from + * last Friday) in a month that does not contain 5 weeks does not generate an + * event and thus does not count against the duration. The same applies to + * providing a day of the month that does not occur in the month. For example + * the 30th or 31st . + * The start time and date of an entry must be synchronized with one of the + * repeating events defined by its recurrence rule. The following is not + * allowed: + * + * Initial Appt Date: 7/1/94 (Friday) + * Recurrence Rule: W1 MO TH #5 + * + * The following is acceptable: + * + * Initial Appt Date: 7/1/94 (Friday) + * Recurrence Rule: W1 MO FR #5 or W1 #5 + * If the optional <occurrencelist> and <weekdaylist> information is missing + * from a <monthlybypos> occurrence the information is derived from the entry + * attributes. The <occurrence> used in the recurring event is a count from + * the beginning of the month to the entry date and the <weekday> used is the + * day of the week the entry is scheduled to occur on. + * + */ + private static final Pattern VCAL_RRULE = Pattern.compile( + "^" + // name and parameters + + "(" + + "(?:RRULE|EXRULE)" + + "(?:;[\\w-]+=" + + "(?:\"[^\"]*\"" + + "|[^;:\"]*)" + + ")*" + + ":" + + ")" + // frequency + + "(" + + "D" // daily + + "|W" // weekly + + "|M[DP]" // monthly by day or by position + + "|Y[DM]" // yearly by day or by month + + ")" + + "([0-9]*)" // interval + // frequency modifier + + "(" + + "(?:\\s+" + + "(?:MO|TU|WE|TH|FR|SA|SU|LD|(?:[0-9]{1,3}[+-]?))" + + ")*" + + ")" + // duration + + "(?:\\s+" + + "(?:" + + "#([0-9]+)" // count + + "|(" + DATE + ")" // until + + ")" + + ")?" + + "$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern WHITESPACE = Pattern.compile("\\s+"); + + /** + * rewrite a vcal rrule to an ical rrule. + * http://www.shuchow.com/vCalAddendum.html + */ + static String rewriteRule(String vcalText) { + Matcher m = VCAL_RRULE.matcher(vcalText.trim()); + if (!m.matches()) { return vcalText; } + StringBuilder sb = new StringBuilder(); + String nameAndParams = m.group(1), + freq = m.group(2).toUpperCase(), + interval = m.group(3), + modifier = m.group(4).trim().toUpperCase(), + count = m.group(5), + until = m.group(6); + sb.append(nameAndParams); + Frequency f; + switch (freq.charAt(0)) { + case 'Y': + f = Frequency.YEARLY; + break; + case 'M': + f = Frequency.MONTHLY; + break; + case 'W': + f = Frequency.WEEKLY; + break; + case 'D': + f = Frequency.DAILY; + break; + default: + throw new AssertionError(); + } + sb.append("FREQ=").append(f.name()); + if (!"".equals(interval) && !"1".equals(interval)) { + sb.append(";INTERVAL=").append(interval); + } + + if (!"".equals(modifier)) { + String[] parts = WHITESPACE.split(modifier); + for (int i = 0; i < parts.length; ++i) { + String p = parts[i]; + char lastchar = p.charAt(p.length() - 1); + switch (lastchar) { + case '+': + parts[i] = p.substring(0, p.length() - 1); + break; + case '-': + parts[i] = lastchar + p.substring(0, p.length() - 1); + break; + } + if (p.equals("LD")) { parts[i] = "-1"; } // abbrev for last day + } + switch (f) { + case YEARLY: + if ('D' == freq.charAt(1)) { + sb.append(";BYYEARDAY="); + join(sb, ",", parts); + } else { + sb.append(";BYMONTH="); + join(sb, ",", parts); + } + break; + case MONTHLY: + if ('P' == freq.charAt(1)) { // byday (position) + int pos = 0; + boolean comma = false; + sb.append(";BYDAY="); + for (int i = 0; i < parts.length; ++i) { + if (Character.isLetter(parts[i].charAt(0))) { + // a day name + if (i > pos) { + for (int j = pos; j < i; ++j) { + // week number followed by day of week + if (comma) { + sb.append(','); + } else { + comma = true; + } + sb.append(parts[j]).append(parts[i]); + } + } else { + if (comma) { + sb.append(','); + } else { + comma = true; + } + sb.append(parts[i]); + } + pos = i + 1; + } + } + } else { // bymonthday + sb.append(";BYMONTHDAY="); + join(sb, ",", parts); + } + break; + case WEEKLY: + sb.append(";BYDAY="); + join(sb, ",", parts); + break; + default: + } + } + + if (null != count) { + if ("0".equals(count)) { + // means forever + } else { + sb.append(";COUNT=").append(count); + } + } else if (null != until) { + until = until.toUpperCase(); + sb.append(";UNTIL=").append(until); + // treat as UTC if not already + if (!until.endsWith("Z") && until.indexOf('T') >= 0) { + sb.append('Z'); + } + } + return sb.toString(); + } + + private static void join(StringBuilder out, String delim, String[] parts) { + if (0 != parts.length) { + out.append(parts[0]); + for (int i = 1; i < parts.length; ++i) { + out.append(delim).append(parts[i]); + } + } + } + + private VcalRewriter() { + // uninstantiable + } + +} diff --git a/src/main/java/com/google/ical/values/Weekday.java b/src/main/java/com/google/ical/values/Weekday.java new file mode 100644 index 0000000..873d7dc --- /dev/null +++ b/src/main/java/com/google/ical/values/Weekday.java @@ -0,0 +1,76 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +import com.google.ical.util.TimeUtils; + +/** + * days of the week enum. Names correspond to RFC2445 literals. + * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public enum Weekday { + SU(0), + MO(1), + TU(2), + WE(3), + TH(4), + FR(5), + SA(6), + ; + + /** + * agrees with values returned by the javascript builtin Date.getDay and the + * corresponding ical.js function/method, but is one less than the Java + * Calendar's DAY_OF_WEEK values. + */ + public final int jsDayNum; + /** + * agrees with the java weekday values as found in java.util.Calendar. + */ + public final int javaDayNum; + + Weekday(int wDayNum) { + this.jsDayNum = wDayNum; + this.javaDayNum = 1 + wDayNum; + } + + private static Weekday[] VALUES = new Weekday[7]; + + static { + System.arraycopy(values(), 0, VALUES, 0, 7); + } + + public static Weekday valueOf(DateValue dv) { + int dayIndex = + TimeUtils.fixedFromGregorian(dv.year(), dv.month(), dv.day()) % 7; + if (dayIndex < 0) { dayIndex += 7; } + return VALUES[dayIndex]; + } + + public static Weekday firstDayOfWeekInMonth(int year, int month) { + int result = TimeUtils.fixedFromGregorian(year, month, 1) % 7; + return VALUES[(result >= 0) ? result : result + 7]; + } + + public Weekday successor() { + return VALUES[(ordinal() + 1) % 7]; + } + + public Weekday predecessor() { + return VALUES[(ordinal() - 1 + 7) % 7]; + } + +} diff --git a/src/main/java/com/google/ical/values/WeekdayNum.java b/src/main/java/com/google/ical/values/WeekdayNum.java new file mode 100644 index 0000000..aa7b23f --- /dev/null +++ b/src/main/java/com/google/ical/values/WeekdayNum.java @@ -0,0 +1,70 @@ +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.ical.values; + +/** + * represents a day of the week in a month or year such as the + * third monday of the month. A negative num indicates it counts from the + * end of the month or year. + * + *
+ * Each BYDAY value can also be preceded by a positive (+n) or negative + * (-n) integer. If present, this indicates the nth occurrence of the + * specific day within the MONTHLY or YEARLY RRULE. For example, within + * a MONTHLY rule, +1MO (or simply 1MO) represents the first Monday + * within the month, whereas -1MO represents the last Monday of the + * month. If an integer modifier is not present, it means all days of + * this type within the specified frequency. For example, within a + * MONTHLY rule, MO represents all Mondays within the month. + *
+ * + * @author mikesamuel+svn@gmail.com (Mike Samuel) + */ +public class WeekdayNum { + public final int num; + public final Weekday wday; + + /** + * @param num in -53,53 + * @param wday non null. + */ + public WeekdayNum(int num, Weekday wday) { + if (!(-53 <= num && 53 >= num && null != wday)) { + throw new IllegalArgumentException(); + } + this.num = num; + this.wday = wday; + } + public String toIcal() { + return (0 != this.num) + ? String.valueOf(this.num) + this.wday + : this.wday.toString(); + } + @Override + public String toString() { + return toIcal(); + } + @Override + public boolean equals(Object o) { + if (!(o instanceof WeekdayNum)) { return false; } + WeekdayNum wdn = (WeekdayNum) o; + return this.num == wdn.num && this.wday == wdn.wday; + } + + @Override + public int hashCode() { + return num ^ (53 * wday.hashCode()); + } +} diff --git a/src/main/java/com/google/ical/values/package.html b/src/main/java/com/google/ical/values/package.html new file mode 100644 index 0000000..1da2bc7 --- /dev/null +++ b/src/main/java/com/google/ical/values/package.html @@ -0,0 +1,3 @@ + +

value types for dates, date ranges, periods of time, etc.

+ \ No newline at end of file diff --git a/src/main/java/us/k5n/journal/DataFile.java b/src/main/java/us/k5n/journal/DataFile.java deleted file mode 100644 index cdf8460..0000000 --- a/src/main/java/us/k5n/journal/DataFile.java +++ /dev/null @@ -1,142 +0,0 @@ -package us.k5n.journal; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.IOException; - -import us.k5n.ical.Constants; -import us.k5n.ical.DataStore; -import us.k5n.ical.ICalendarParser; -import us.k5n.ical.Journal; -import us.k5n.ical.ParseError; - -/** - * Extend the File class to include iCalendar data created from parsing the - * file. Normally, the application will just store a single Journal entry in - * each file. However, if a user copies an ICS file into their directory, we - * don't want to loose track of the original filename to avoid creating - * duplicates. - * - * @author Craig Knudsen, craig@k5n.us - */ -public class DataFile extends File implements Constants { - ICalendarParser parser; - DataStore dataStore; - - public DataFile(String filename) { - this(filename, false); - } - - /** - * Create a DataFile object. If the specified filename exists, then it will be - * parsed and all entries loaded into the default DataStore. If the filename - * does not exists, then no parsing/loading will take place. - * - * @param filename - * The filename (YYYYMMDD.ics as in "19991231.ics") - * @param strictParsing - */ - public DataFile(String filename, boolean strictParsing) { - super(filename); - parser = new ICalendarParser(strictParsing ? PARSE_STRICT : PARSE_LOOSE); - if (this.exists()) { - BufferedReader reader = null; - try { - reader = new BufferedReader(new FileReader(this)); - parser.parse(reader); - reader.close(); - } catch (IOException e) { - System.err.println("Error opening " + toString() + ": " + e); - } - } - dataStore = parser.getDataStoreAt(0); - // Store this DataFile object in the user data object of each - // Journal entry so we can get back to this object if the user - // edits and saves a Journal entry. - for (int i = 0; i < getJournalCount(); i++) { - Journal j = journalEntryAt(i); - j.setUserData(this); - } - } - - public void addJournal(Journal journal) { - journal.setUserData(this); - dataStore.storeJournal(journal); - } - - private DataFile(ICalendarParser parser, String filename) { - super(filename); - dataStore = parser.getDataStoreAt(0); - // Store this DataFile object in the user data object of each - // Journal entry so we can get back to this object if the user - // edits and saves a Journal entry. - for (int i = 0; i < getJournalCount(); i++) { - Journal j = journalEntryAt(i); - j.setUserData(this); - } - } - - /** - * Return the number of journal entries in this file. - * - * @return - */ - public int getJournalCount() { - return dataStore.getAllJournals().size(); - } - - /** - * Get the Journal entry at the specified location. - * - * @param ind - * The index number (0 is first) - * @return - */ - public Journal journalEntryAt(int ind) { - return (Journal) dataStore.getAllJournals().get(ind); - } - - /** - * Remove the Journal object at the specified location in the List of - * entries. - * - * @param ind - * @return true if found and deleted - */ - public boolean removeJournal(Journal journal) { - return dataStore.getAllJournals().remove(journal); - } - - /** - * Get the number of parse errors found in the file. - * - * @return - */ - public int getParseErrorCount() { - return parser.getAllErrors().size(); - } - - /** - * Get the parse error at the specified location - * - * @param ind - * @return - */ - public ParseError getParseErrorAt(int ind) { - return parser.getAllErrors().get(ind); - } - - /** - * Write this DataFile object. - * - * @throws IOException - */ - public void write() throws IOException { - FileWriter writer = null; - writer = new FileWriter(this); - writer.write(parser.toICalendar()); - writer.close(); - } -} diff --git a/src/main/java/us/k5n/journal/DateTimeSelectionDialog.java b/src/main/java/us/k5n/journal/DateTimeSelectionDialog.java deleted file mode 100644 index 69075ea..0000000 --- a/src/main/java/us/k5n/journal/DateTimeSelectionDialog.java +++ /dev/null @@ -1,219 +0,0 @@ -package us.k5n.journal; - -import java.awt.BorderLayout; -import java.awt.FlowLayout; -import java.awt.GridLayout; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.util.ArrayList; -import java.util.List; - -import javax.swing.BorderFactory; -import javax.swing.JButton; -import javax.swing.JCheckBox; -import javax.swing.JComboBox; -import javax.swing.JDialog; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.JOptionPane; -import javax.swing.JPanel; -import javax.swing.JTextField; - -import us.k5n.ical.BogusDataException; -import us.k5n.ical.Date; - -public class DateTimeSelectionDialog extends JDialog { - JTextField dayOfMonth, year; - JComboBox month; - JTextField hour, minute, second; - JCheckBox timeEnabled; - Date date; - boolean userAccepted = false; - - public static Date showDateTimeSelectionDialog(JFrame parent, Date date) { - DateTimeSelectionDialog dts = new DateTimeSelectionDialog(parent, date); - Date d = dts.userAccepted ? dts.date : null; - return d; - } - - public DateTimeSelectionDialog(JFrame parent, Date date) { - super((JFrame) null); - setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); - this.setModal(true); - if (date == null) - date = Date.getCurrentDateTime("DTSTART"); - this.date = date; - this.getContentPane().setLayout(new BorderLayout()); - JPanel buttonPanel = new JPanel(); - buttonPanel.setLayout(new FlowLayout()); - JButton cancelButton = new JButton("Cancel"); - cancelButton.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - cancel(); - } - }); - buttonPanel.add(cancelButton); - - JButton okButton = new JButton("Ok"); - okButton.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - ok(); - } - }); - buttonPanel.add(okButton); - - this.getContentPane().add(buttonPanel, BorderLayout.SOUTH); - - JPanel topPanel = createDateTimeSelection(date); - - this.getContentPane().add(topPanel, BorderLayout.CENTER); - - this.pack(); - this.setVisible(true); - } - - private JPanel createDateTimeSelection(Date date) { - int[] vProportions = { 1, 2 }; - int[] hProportions = { 1, 3 }; - - ProportionalLayout vLayout = new ProportionalLayout(vProportions, - ProportionalLayout.VERITCAL_LAYOUT); - JPanel panel = new JPanel(); - panel.setLayout(vLayout); - - JPanel datePanel = new JPanel(); - ProportionalLayout hLayout1 = new ProportionalLayout(hProportions, - ProportionalLayout.HORIZONTAL_LAYOUT); - datePanel.add(new JLabel("Date: ")); - JPanel dateSelPanel = new JPanel(); - dateSelPanel.setLayout(new FlowLayout()); - - dayOfMonth = new JTextField(); - dayOfMonth.setColumns(2); - dayOfMonth.setText("" + date.getDay()); - dateSelPanel.add(dayOfMonth); - - List sortOptions = new ArrayList(); - sortOptions.add("Jan"); - sortOptions.add("Feb"); - sortOptions.add("Mar"); - sortOptions.add("Apr"); - sortOptions.add("May"); - sortOptions.add("Jun"); - sortOptions.add("Jul"); - sortOptions.add("Aug"); - sortOptions.add("Sep"); - sortOptions.add("Oct"); - sortOptions.add("Nov"); - sortOptions.add("Dec"); - month = new JComboBox<>(sortOptions.toArray(new String[0])); - month.setSelectedIndex(date.getMonth() - 1); - dateSelPanel.add(month); - - year = new JTextField(); - year.setColumns(4); - year.setText("" + date.getYear()); - dateSelPanel.add(year); - - datePanel.add(dateSelPanel); - panel.add(datePanel); - - JPanel timePanel = new JPanel(); - timePanel.setLayout(new GridLayout(2, 1)); - - JPanel enabledPanel = new JPanel(); - enabledPanel.setLayout(new ProportionalLayout(hProportions, - ProportionalLayout.HORIZONTAL_LAYOUT)); - enabledPanel.add(new JLabel("Time: ")); - - timeEnabled = new JCheckBox("Enabled"); - timeEnabled.setSelected(!date.isDateOnly()); - enabledPanel.add(timeEnabled); - - timePanel.add(enabledPanel); - - JPanel timeSubPanel = new JPanel(); - timeSubPanel.setLayout(new ProportionalLayout(hProportions, - ProportionalLayout.HORIZONTAL_LAYOUT)); - timeSubPanel.add(new JLabel(" ")); - - JPanel hmsPanel = new JPanel(); - FlowLayout leftFlow = new FlowLayout(); - leftFlow.setAlignment(FlowLayout.LEFT); - hmsPanel.setLayout(leftFlow); - hour = new JTextField(); - hour.setColumns(2); - hour.setText("" + date.getHour()); - hmsPanel.add(hour); - hmsPanel.add(new JLabel(":")); - minute = new JTextField(); - minute.setColumns(2); - minute.setText("" + date.getMinute()); - hmsPanel.add(minute); - hmsPanel.add(new JLabel(":")); - second = new JTextField(); - second.setColumns(2); - second.setText("" + date.getSecond()); - hmsPanel.add(second); - timeSubPanel.add(hmsPanel); - - timePanel.add(timeSubPanel); - - panel.add(timePanel); - - panel.setBorder(BorderFactory.createEtchedBorder()); - - return panel; - } - - protected void cancel() { - this.dispose(); - } - - protected int getIntValue(JTextField field, String fieldName, int minVal, - int maxVal) { - int ret = -1; - try { - ret = Integer.parseInt(field.getText()); - } catch (Exception e) { - // invalid value - } - if (ret < minVal || ret > maxVal) { - JOptionPane.showMessageDialog(this, "Invalid value for " + fieldName, - "Invalid Date", JOptionPane.ERROR_MESSAGE); - ret = -1; - } - return ret; - } - - protected void ok() { - Date d = null; - int Y, M, D, h, m, s; - if ((Y = getIntValue(year, "year", 1900, 3000)) < 0) - return; - M = month.getSelectedIndex() + 1; - if ((D = getIntValue(dayOfMonth, "day of month", 1, 31)) < 0) - return; - try { - if (timeEnabled.isSelected()) { - if ((h = getIntValue(hour, "hour", 0, 23)) < 0) - return; - if ((m = getIntValue(minute, "minute", 0, 59)) < 0) - return; - if ((s = getIntValue(second, "second", 0, 59)) < 0) - return; - d = new Date(this.date.getName(), Y, M, D, h, m, s); - } else { - d = new Date(this.date.getName(), Y, M, D); - } - } catch (BogusDataException e1) { - JOptionPane.showMessageDialog(this, - "Invalid date: " + e1.getMessage(), "Invalid Date", - JOptionPane.ERROR_MESSAGE); - return; - } - this.userAccepted = true; - this.date = d; - this.dispose(); - } -} diff --git a/src/main/java/us/k5n/journal/DisplayDate.java b/src/main/java/us/k5n/journal/DisplayDate.java deleted file mode 100644 index f073746..0000000 --- a/src/main/java/us/k5n/journal/DisplayDate.java +++ /dev/null @@ -1,57 +0,0 @@ -package us.k5n.journal; - -import java.text.SimpleDateFormat; - -import us.k5n.ical.Date; - -/** - * Repackage a us.k5n.ical.Date object so that we can format the date for - * display using SimpleDateFormat. - * - * @author Craig Knudsen, craig@k5n.us - */ -public class DisplayDate implements Comparable { - // Date formats are specified in the Java API doc. - // TODO: allow setting this format in user preferences - private static String dateOnlyFormat = "EEE, d MMM yyyy"; - private static String dateTimeFormat = "EEE, d MMM yyyy h:mm a"; - private java.util.Date javaDate; - boolean hasTime; - Object userData; - - public DisplayDate(Date d) { - this ( d, null ); - } - - public DisplayDate(Date d, Object userData) { - if ( d == null ) { - javaDate = null; - hasTime = false; - } else { - javaDate = d.toCalendar ().getTime (); - hasTime = !d.isDateOnly (); - } - this.userData = userData; - } - - public String toString () { - SimpleDateFormat format = null; - if ( javaDate == null ) - return "Unknown Date"; - if ( hasTime ) - format = new SimpleDateFormat ( dateTimeFormat ); - else - format = new SimpleDateFormat ( dateOnlyFormat ); - return format.format ( javaDate ); - } - - public Object getUserData () { - return this.userData; - } - - public int compareTo ( Object arg0 ) { - DisplayDate d2 = (DisplayDate) arg0; - return javaDate.compareTo ( d2.javaDate ); - } - -} diff --git a/src/main/java/us/k5n/journal/EditWindow.java b/src/main/java/us/k5n/journal/EditWindow.java deleted file mode 100644 index 43f9fc4..0000000 --- a/src/main/java/us/k5n/journal/EditWindow.java +++ /dev/null @@ -1,215 +0,0 @@ -package us.k5n.journal; - -import java.awt.BorderLayout; -import java.awt.Dimension; -import java.awt.FlowLayout; -import java.awt.GridLayout; -import java.awt.Insets; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.io.IOException; - -import javax.swing.BorderFactory; -import javax.swing.JButton; -import javax.swing.JDialog; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JScrollPane; -import javax.swing.JTextArea; -import javax.swing.JTextField; -import javax.swing.SwingConstants; - -import us.k5n.ical.Categories; -import us.k5n.ical.Date; -import us.k5n.ical.Description; -import us.k5n.ical.Journal; -import us.k5n.ical.Sequence; -import us.k5n.ical.Summary; - -/** - * Create a Journal entry edit window. - * - * @author Craig Knudsen, craig@k5n.us - */ -public class EditWindow extends JDialog { - Repository repo; - Journal journal; - Sequence seq = null; - JFrame parent; - JTextField subject; - JTextField categories; - JLabel startDate; - JTextArea description; - - public EditWindow(JFrame parent, Dimension preferredSize, Repository repo, - Journal journal) { - super ( parent ); - super.setSize ( preferredSize ); - // TODO: don't make this modal once we add code to check - // things like deleting this entry in the main window, etc. - // super.setModal ( true ); - setDefaultCloseOperation ( JDialog.DISPOSE_ON_CLOSE ); - - this.parent = parent; - this.repo = repo; - this.journal = journal; - - if ( this.journal == null ) { - this.journal = new Journal ( "", "", Date.getCurrentDateTime ( "DTSTART" ) ); - } else { - // Create an updated sequence number for use only if we save - // (So don't put it in the original Journal object yet) - if ( this.journal.getSequence () == null ) - seq = new Sequence ( 1 ); - else - seq = new Sequence ( this.journal.getSequence ().getNum () + 1 ); - } - // Make sure there is a Summary and Description - if ( this.journal.getSummary () == null ) - this.journal.setSummary ( new Summary () ); - if ( this.journal.getDescription () == null ) - this.journal.setDescription ( new Description () ); - if ( this.journal.getCategories () == null ) - this.journal.setCategories ( new Categories () ); - - createWindow (); - setVisible ( true ); - } - - private void createWindow () { - this.getContentPane ().setLayout ( new BorderLayout () ); - - JPanel buttonPanel = new JPanel (); - buttonPanel.setLayout ( new FlowLayout () ); - JButton saveButton = new JButton ( "Save" ); - saveButton.addActionListener ( new ActionListener () { - public void actionPerformed ( ActionEvent event ) { - // Save (write file) - save (); - } - } ); - buttonPanel.add ( saveButton ); - JButton closeButton = new JButton ( "Close" ); - closeButton.addActionListener ( new ActionListener () { - public void actionPerformed ( ActionEvent event ) { - close (); - } - } ); - buttonPanel.add ( closeButton ); - getContentPane ().add ( buttonPanel, BorderLayout.SOUTH ); - - JPanel allButButtons = new JPanel (); - allButButtons.setLayout ( new BorderLayout () ); - allButButtons.setBorder ( BorderFactory.createEmptyBorder ( 5, 5, 5, 5 ) ); - - JPanel upperPanel = new JPanel (); - upperPanel.setBorder ( BorderFactory.createEtchedBorder () ); - GridLayout grid = new GridLayout ( 3, 1 ); - grid.setHgap ( 15 ); - grid.setVgap ( 5 ); - upperPanel.setLayout ( grid ); - int[] proportions = { 20, 80 }; - - JPanel subjectPanel = new JPanel (); - subjectPanel.setLayout ( new ProportionalLayout ( proportions, - ProportionalLayout.HORIZONTAL_LAYOUT ) ); - JLabel prompt = new JLabel ( "Subject: " ); - prompt.setHorizontalAlignment ( SwingConstants.RIGHT ); - subjectPanel.add ( prompt ); - subject = new JTextField (); - if ( journal != null && journal.getSummary () != null ) - subject.setText ( journal.getSummary ().getValue () ); - subjectPanel.add ( subject ); - upperPanel.add ( subjectPanel ); - - JPanel datePanel = new JPanel (); - datePanel.setLayout ( new ProportionalLayout ( proportions, - ProportionalLayout.HORIZONTAL_LAYOUT ) ); - prompt = new JLabel ( "Date: " ); - prompt.setHorizontalAlignment ( SwingConstants.RIGHT ); - datePanel.add ( prompt ); - JPanel subDatePanel = new JPanel (); - FlowLayout flow = new FlowLayout (); - flow.setAlignment ( FlowLayout.LEFT ); - subDatePanel.setLayout ( flow ); - startDate = new JLabel (); - DisplayDate d = new DisplayDate ( journal.getStartDate () ); - startDate.setText ( d.toString () ); - subDatePanel.add ( startDate ); - JButton dateSel = new JButton ( "..." ); - dateSel.addActionListener ( new ActionListener () { - public void actionPerformed ( ActionEvent event ) { - Date newDate = DateTimeSelectionDialog.showDateTimeSelectionDialog ( - parent, journal.getStartDate () ); - if ( newDate != null ) { - journal.setStartDate ( newDate ); - DisplayDate d = new DisplayDate ( journal.getStartDate () ); - startDate.setText ( d.toString () ); - } - } - } ); - dateSel.setMargin ( new Insets ( 0, 5, 0, 5 ) ); - subDatePanel.add ( dateSel ); - datePanel.add ( subDatePanel ); - upperPanel.add ( datePanel ); - - JPanel catPanel = new JPanel (); - catPanel.setLayout ( new ProportionalLayout ( proportions, - ProportionalLayout.HORIZONTAL_LAYOUT ) ); - prompt = new JLabel ( "Categories: " ); - prompt.setHorizontalAlignment ( SwingConstants.RIGHT ); - catPanel.add ( prompt ); - categories = new JTextField (); - if ( journal != null && journal.getCategories () != null ) - categories.setText ( journal.getCategories ().getValue () ); - catPanel.add ( categories ); - upperPanel.add ( catPanel ); - - allButButtons.add ( upperPanel, BorderLayout.NORTH ); - - // TODO: eventually add some edit buttons/icons here when - // we support more than plain text. - JPanel descrPanel = new JPanel (); - descrPanel.setLayout ( new BorderLayout () ); - description = new JTextArea (); - description.setLineWrap ( true ); - description.setWrapStyleWord ( true ); - if ( journal != null && journal.getDescription () != null ) - description.setText ( journal.getDescription ().getValue () ); - description.setCaretPosition ( 0 ); - JScrollPane scrollPane = new JScrollPane ( description ); - descrPanel.add ( scrollPane, BorderLayout.CENTER ); - allButButtons.add ( descrPanel, BorderLayout.CENTER ); - - getContentPane ().add ( allButButtons, BorderLayout.CENTER ); - } - - void save () { - // Note: LAST-MODIFIED gets updated by call to saveJournal - if ( seq != null ) { - journal.setSequence ( seq ); - seq = null; - } - try { - this.journal.getDescription ().setValue ( description.getText () ); - this.journal.getSummary ().setValue ( subject.getText ().trim () ); - this.journal.getCategories ().setValue ( categories.getText ().trim () ); - repo.saveJournal ( this.journal ); - } catch ( IOException e2 ) { - // TODO: add error handler that pops up a window here - e2.printStackTrace (); - } - this.dispose (); - } - - void chooseDate () { - DateTimeSelectionDialog dts = new DateTimeSelectionDialog ( parent, journal - .getStartDate () ); - } - - void close () { - // TODO: check for unsaved changes - this.dispose (); - } -} diff --git a/src/main/java/us/k5n/journal/IcsFileFilter.java b/src/main/java/us/k5n/journal/IcsFileFilter.java deleted file mode 100644 index cebbded..0000000 --- a/src/main/java/us/k5n/journal/IcsFileFilter.java +++ /dev/null @@ -1,21 +0,0 @@ -package us.k5n.journal; - -import java.io.File; -import java.io.FileFilter; - -/** - * A FileFilter implementation that will include only ".ics" filenames. - * - * @author Craig Knudsen, craig@k5n.us - */ -public class IcsFileFilter implements FileFilter { - - public boolean accept ( File pathname ) { - return pathname.toString ().toUpperCase ().endsWith ( ".ICS" ); - } - - public String getDescription () { - return "*.ics (iCalendar files)"; - } - -} diff --git a/src/main/java/us/k5n/journal/JournalViewPanel.java b/src/main/java/us/k5n/journal/JournalViewPanel.java deleted file mode 100644 index fc25098..0000000 --- a/src/main/java/us/k5n/journal/JournalViewPanel.java +++ /dev/null @@ -1,100 +0,0 @@ -package us.k5n.journal; - -import java.awt.BorderLayout; -import java.awt.GridLayout; - -import javax.swing.BorderFactory; -import javax.swing.JLabel; -import javax.swing.JPanel; -import javax.swing.JScrollPane; -import javax.swing.JTextArea; - -import us.k5n.ical.Journal; -import us.k5n.ical.Summary; - -public class JournalViewPanel extends JPanel { - private Journal journal; - private JLabel date; - private JLabel subject; - private JLabel categories; - private JTextArea text; - - public JournalViewPanel() { - super (); - - journal = null; - - setLayout ( new BorderLayout () ); - - JPanel topPanel = new JPanel (); - topPanel.setLayout ( new GridLayout ( 3, 1 ) ); - topPanel.setBorder ( BorderFactory.createEmptyBorder ( 2, 4, 2, 4 ) ); - - JPanel subpanel = new JPanel (); - subpanel.setLayout ( new BorderLayout () ); - subpanel.add ( new JLabel ( "Date: " ), BorderLayout.WEST ); - date = new JLabel (); - subpanel.add ( date, BorderLayout.CENTER ); - topPanel.add ( subpanel ); - - subpanel = new JPanel (); - subpanel.setLayout ( new BorderLayout () ); - subpanel.add ( new JLabel ( "Subject: " ), BorderLayout.WEST ); - subject = new JLabel (); - subpanel.add ( subject, BorderLayout.CENTER ); - topPanel.add ( subpanel ); - - subpanel = new JPanel (); - subpanel.setLayout ( new BorderLayout () ); - subpanel.add ( new JLabel ( "Categories: " ), BorderLayout.WEST ); - categories = new JLabel (); - subpanel.add ( categories, BorderLayout.CENTER ); - topPanel.add ( subpanel ); - - add ( topPanel, BorderLayout.NORTH ); - - text = new JTextArea (); - text.setLineWrap ( true ); - text.setWrapStyleWord ( true ); - text.setEditable ( false ); - JScrollPane scrollPane = new JScrollPane ( text ); - scrollPane - .setVerticalScrollBarPolicy ( JScrollPane.VERTICAL_SCROLLBAR_ALWAYS ); - - add ( scrollPane, BorderLayout.CENTER ); - } - - public void clear () { - date.setText ( "" ); - subject.setText ( "" ); - categories.setText ( "" ); - text.setText ( "" ); - } - - public void setJournal ( Journal j ) { - this.journal = j; - if ( j.getStartDate () != null ) { - DisplayDate d = new DisplayDate ( j.getStartDate () ); - date.setText ( d.toString () ); - } else { - date.setText ( "None" ); - } - Summary s = j.getSummary (); - if ( s != null ) { - subject.setText ( s.getValue () ); - } else { - subject.setText ( "(None)" ); - } - if ( j.getCategories () != null ) { - categories.setText ( j.getCategories ().getValue () ); - } else { - categories.setText ( "(None)" ); - } - if ( j.getDescription () != null ) { - text.setText ( j.getDescription ().getValue () ); - text.setCaretPosition ( 0 ); - } else { - text.setText ( "" ); - } - } -} diff --git a/src/main/java/us/k5n/journal/Main.java b/src/main/java/us/k5n/journal/Main.java deleted file mode 100644 index 48c36ee..0000000 --- a/src/main/java/us/k5n/journal/Main.java +++ /dev/null @@ -1,842 +0,0 @@ -package us.k5n.journal; - -import java.awt.BorderLayout; -import java.awt.Component; -import java.awt.Container; -import java.awt.Dimension; -import java.awt.Frame; -import java.awt.Toolkit; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.awt.event.MouseListener; -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.io.PrintWriter; -import java.net.URL; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import javax.swing.BorderFactory; -import javax.swing.Box; -import javax.swing.ImageIcon; -import javax.swing.JButton; -import javax.swing.JFileChooser; -import javax.swing.JFrame; -import javax.swing.JLabel; -import javax.swing.JMenu; -import javax.swing.JMenuBar; -import javax.swing.JMenuItem; -import javax.swing.JOptionPane; -import javax.swing.JPanel; -import javax.swing.JScrollPane; -import javax.swing.JSplitPane; -import javax.swing.JTabbedPane; -import javax.swing.JTextField; -import javax.swing.JToolBar; -import javax.swing.JTree; -import javax.swing.KeyStroke; -import javax.swing.LookAndFeel; -import javax.swing.UIManager; -import javax.swing.event.ListSelectionEvent; -import javax.swing.event.ListSelectionListener; -import javax.swing.tree.DefaultMutableTreeNode; -import javax.swing.tree.DefaultTreeModel; -import javax.swing.tree.TreePath; - -import us.k5n.ical.Categories; -import us.k5n.ical.Constants; -import us.k5n.ical.DataStore; -import us.k5n.ical.Description; -import us.k5n.ical.ICalendarParser; -import us.k5n.ical.Journal; -import us.k5n.ical.Summary; - -/** - * Main class for k5njournal application. - * - * @author Craig Knudsen, craig@k5n.us - */ -public class Main extends JFrame implements Constants, RepositoryChangeListener { - public static final String DEFAULT_DIR_NAME = "k5njournal"; - public static final String VERSION = "0.2.6 (24 Apr 2007)"; - JFrame parent; - JLabel messageArea; - Repository dataRepository; - JTree dateTree; - DefaultMutableTreeNode dateTreeAllNode; - ReadOnlyTable journalListTable; - ReadOnlyTabelModel journalListTableModel; - JournalViewPanel journalView = null; - // filteredJournalEntries is the List of Journal objects filtered - // by dates selected by the user. (Not yet filtered by search text.) - List filteredJournalEntries; - // filteredSearchedJournalEntries is filtered by both date selection - // and text search. - List filteredSearchedJournalEntries; - final static String[] journalListTableHeader = { "Date", "Subject" }; - final static String[] monthNames = { "", "January", "February", "March", - "April", "May", "June", "July", "August", "September", "October", - "November", "December" }; - JButton newButton, editButton, deleteButton; - JMenuItem exportSelected; - JTextField searchTextField; - String searchText = null; - private static File lastExportDirectory = null; - - class DateFilterTreeNode extends DefaultMutableTreeNode { - public int year, month, day; - public String label; - - public DateFilterTreeNode(String l, int y, int m, int d, int count) { - super(l); - this.year = y; - this.month = m; - this.day = d; - this.label = l + " (" + count + ")"; - } - - public String toString() { - return label; - } - } - - public Main() { - this(600, 600); - } - - public Main(int w, int h) { - super("k5njournal"); - setWindowsLAF(); - this.parent = this; - // TODO: save user's preferred size on exit and set here - setSize(w, h); - - setDefaultCloseOperation(EXIT_ON_CLOSE); - Container contentPane = getContentPane(); - - // Load data - dataRepository = new Repository(getDataDirectory(), false); - // Ask to be notified when the repository changes (user adds/edits - // an entry) - dataRepository.addChangeListener(this); - - // Create a menu bar - setJMenuBar(createMenu()); - - contentPane.setLayout(new BorderLayout()); - - // Add message/status bar at bottom - JPanel messagePanel = new JPanel(); - messagePanel.setLayout(new BorderLayout()); - messagePanel.setBorder(BorderFactory.createEmptyBorder(2, 4, 2, 4)); - messageArea = new JLabel("Welcome to k5njournal..."); - messagePanel.add(messageArea, BorderLayout.CENTER); - contentPane.add(messagePanel, BorderLayout.SOUTH); - - contentPane.add(createToolBar(), BorderLayout.NORTH); - - JPanel navArea = createJournalSelectionPanel(); - journalView = new JournalViewPanel(); - - JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, navArea, - journalView); - splitPane.setOneTouchExpandable(true); - splitPane.setResizeWeight(0.5); - // TODO: get this value from last session - splitPane.setDividerLocation(200); - contentPane.add(splitPane, BorderLayout.CENTER); - - // Populate Date JTree - updateDateTree(); - handleDateFilterSelection(0, null); - // filteredJournalEntries = dataRepository.getAllEntries (); - // updateFilteredJournalList (); - - this.setVisible(true); - } - - JToolBar createToolBar() { - JToolBar toolbar = new JToolBar(); - newButton = makeNavigationButton(null, "new", "Add new Journal entry", - "New..."); - newButton.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - new EditWindow(parent, new Dimension(500, 500), dataRepository, - null); - } - }); - toolbar.add(newButton); - - editButton = makeNavigationButton(null, "edit", "Edit Journal entry", - "Edit..."); - toolbar.add(editButton); - editButton.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - // Get selected item and open edit window - int ind = journalListTable.getSelectedRow(); - if (ind >= 0 && filteredSearchedJournalEntries != null - && ind < filteredSearchedJournalEntries.size()) { - DisplayDate dd = (DisplayDate) journalListTable.getValueAt(ind, 0); - Journal j = (Journal) dd.getUserData(); - new EditWindow(parent, new Dimension(500, 500), dataRepository, - j); - } - } - }); - - deleteButton = makeNavigationButton(null, "delete", - "deleteButton Journal entry", "Delete"); - toolbar.add(deleteButton); - deleteButton.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - // Get selected item and open edit window - int ind = journalListTable.getSelectedRow(); - if (ind >= 0 && filteredSearchedJournalEntries != null - && ind < filteredSearchedJournalEntries.size()) { - Journal j = (Journal) filteredSearchedJournalEntries.get(ind); - if (JOptionPane.showConfirmDialog(parent, - "Are you sure you want\nto delete this entry?", "Confirm Delete", - JOptionPane.YES_NO_OPTION) == 0) { - try { - dataRepository.deleteJournal(j); - } catch (IOException e1) { - showError("Error deleting."); - e1.printStackTrace(); - } - } - } else { - System.err.println("Index out of range: " + ind); - } - } - }); - - return toolbar; - } - - void updateToolbar(int numSelected) { - editButton.setEnabled(numSelected == 1); - deleteButton.setEnabled(numSelected == 1); - exportSelected.setEnabled(numSelected >= 1); - } - - public void setMessage(String msg) { - this.messageArea.setText(msg); - } - - public JMenuBar createMenu() { - JMenuItem item; - - JMenuBar bar = new JMenuBar(); - - JMenu fileMenu = new JMenu("File"); - - JMenu exportMenu = new JMenu("Export"); - // exportMenu.setMnemonic ( 'X' ); - fileMenu.add(exportMenu); - - item = new JMenuItem("All"); - item.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - exportAll(); - } - }); - exportMenu.add(item); - item = new JMenuItem("Visible"); - item.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - exportVisible(); - } - }); - exportMenu.add(item); - item = new JMenuItem("Selected"); - item.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - exportSelected(); - } - }); - exportMenu.add(item); - exportSelected = item; - - fileMenu.addSeparator(); - - item = new JMenuItem("Exit"); - item.setAccelerator(KeyStroke.getKeyStroke('Q', Toolkit - .getDefaultToolkit().getMenuShortcutKeyMask())); - item.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - // TODO: check for unsaved changes - // TODO: save current size of main window for use next time - System.exit(0); - } - }); - fileMenu.add(item); - - bar.add(fileMenu); - - /* - * commented out because of bug in JDK that causes NullPointerException when - * we update the UI L&F. JMenu settingsMenu = new JMenu ( "Settings" ); - * - * item = new JMenuItem ( "Look & Feel" ); item.addActionListener ( new - * ActionListener () { public void actionPerformed ( ActionEvent event ) { - * selectLookAndFeel ( parent, parent ); } } ); settingsMenu.add ( item ); - * bar.add ( settingsMenu ); - */ - - // Add help bar to right end of menubar - bar.add(Box.createHorizontalGlue()); - - JMenu helpMenu = new JMenu("Help"); - - item = new JMenuItem("About..."); - item.setAccelerator(KeyStroke.getKeyStroke('A', Toolkit - .getDefaultToolkit().getMenuShortcutKeyMask())); - item.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - // TODO: add logo, etc... - JOptionPane.showMessageDialog(parent, "k5njournal\nVersion " - + VERSION + "\n\nDeveloped by k5n.us\n\n" - + "Go to www.k5n.us for more info."); - } - }); - helpMenu.add(item); - - bar.add(helpMenu); - - return bar; - } - - /** - * Create the file selection area on the top side of the window. This will - * include a split pane where the left will allow navigation and selection of - * dates and the right will allow the selection of a specific entry. - * - * @return - */ - protected JPanel createJournalSelectionPanel() { - JPanel topPanel = new JPanel(); - topPanel.setLayout(new BorderLayout()); - - JTabbedPane tabbedPane = new JTabbedPane(); - - JPanel byDate = new JPanel(); - byDate.setLayout(new BorderLayout()); - tabbedPane.addTab("Date", byDate); - dateTreeAllNode = new DefaultMutableTreeNode("All"); - dateTree = new JTree(dateTreeAllNode); - - MouseListener ml = new MouseAdapter() { - public void mousePressed(MouseEvent e) { - int selRow = dateTree.getRowForLocation(e.getX(), e.getY()); - TreePath selPath = dateTree.getPathForLocation(e.getX(), e.getY()); - if (selRow != -1) { - if (e.getClickCount() == 1) { - handleDateFilterSelection(selRow, selPath); - } else if (e.getClickCount() == 2) { - // Do something for double-click??? - } - } - } - }; - dateTree.addMouseListener(ml); - - JScrollPane scrollPane = new JScrollPane(dateTree); - byDate.add(scrollPane, BorderLayout.CENTER); - - // TODO: add category tab filter - // JPanel byCategory = new JPanel (); - // tabbedPane.addTab ( "Category", byCategory ); - - JPanel journalListPane = new JPanel(); - journalListPane.setLayout(new BorderLayout()); - - JPanel searchPanel = new JPanel(); - searchPanel.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); - searchPanel.setLayout(new BorderLayout()); - searchPanel.add(new JLabel("Search: "), BorderLayout.WEST); - searchTextField = new SearchTextField(); - searchTextField.addActionListener(new ActionListener() { - public void actionPerformed(ActionEvent event) { - searchUpdated(); - } - }); - searchPanel.add(searchTextField, BorderLayout.CENTER); - journalListPane.add(searchPanel, BorderLayout.NORTH); - - journalListTableModel = new ReadOnlyTabelModel(journalListTableHeader, 0, - 2); - TableSorter sorter = new TableSorter(journalListTableModel); - journalListTable = new ReadOnlyTable(sorter); - sorter.setTableHeader(journalListTable.getTableHeader()); - // journalListTable.setAutoResizeMode ( JTable.AUTO_RESIZE_OFF ); - journalListTable.setRowSelectionAllowed(true); - - // Add selection listener to table - journalListTable.getSelectionModel().addListSelectionListener( - new ListSelectionListener() { - public void valueChanged(ListSelectionEvent event) { - if (journalView != null) { - int ind = journalListTable.getSelectedRow(); - int numSel = journalListTable.getSelectedRowCount(); - updateToolbar(numSel); - if (numSel == 0) { - journalView.clear(); - } else if (!event.getValueIsAdjusting() && ind >= 0 - && filteredSearchedJournalEntries != null - && ind < filteredSearchedJournalEntries.size()) { - int[] selRows = journalListTable.getSelectedRows(); - // The call below might actually belong in ReadOnlyTable. - // However, we would need to add a MouseListener to - // ReadOnlyTable - // and make sure that one got called before this one. - journalListTable.setHighlightedRows(selRows); - if (selRows != null && selRows.length == 1) { - DisplayDate dd = (DisplayDate) journalListTable.getValueAt( - ind, 0); - Journal journal = (Journal) dd.getUserData(); - if (journal != null) - journalView.setJournal(journal); - } else { - // more than one selected - journalView.clear(); - } - } else { - journalListTable.clearHighlightedRows(); - } - } - } - }); - - JScrollPane journalListTableScroll = new JScrollPane(journalListTable); - journalListPane.add(journalListTableScroll, BorderLayout.CENTER); - - JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, - tabbedPane, journalListPane); - splitPane.setOneTouchExpandable(true); - splitPane.setDividerLocation(185); - - topPanel.add(splitPane, BorderLayout.CENTER); - - return topPanel; - } - - void handleDateFilterSelection(int row, TreePath path) { - int year = -1; - int month = -1; - if (path == null || path.getPathCount() < 2) { - // "All" - } else { - DateFilterTreeNode dateFilter = (DateFilterTreeNode) path - .getLastPathComponent(); - // System.out.println ( "Showing entries for " + dateFilter.year + "/" - // + dateFilter.month ); - year = dateFilter.year; - if (dateFilter.month > 0) - month = dateFilter.month; - } - if (year < 0) { - filteredJournalEntries = dataRepository.getAllEntries(); - } else if (month < 0) { - filteredJournalEntries = dataRepository.getEntriesByYear(year); - } else { - filteredJournalEntries = dataRepository.getEntriesByMonth(year, month); - } - this.updateFilteredJournalList(); - } - - // Rebuild the Date JTree. - // TODO: What we should really be doing is updating the JTree so that - // we can preserve what year nodes were open and what objects were - // selected. - void updateDateTree() { - dateTree.setShowsRootHandles(true); - // Remove all old entries - dateTreeAllNode.removeAllChildren(); - // Get entries, starting with years - int[] years = dataRepository.getYears(); - for (int i = years.length - 1; years != null && i >= 0; i--) { - List yearEntries = dataRepository.getEntriesByYear(years[i]); - DateFilterTreeNode yearNode = new DateFilterTreeNode("" + years[i], - years[i], 0, 0, yearEntries == null ? 0 : yearEntries.size()); - dateTreeAllNode.add(yearNode); - int[] months = dataRepository.getMonthsForYear(years[i]); - for (int j = 0; months != null && j < months.length; j++) { - List monthEntries = dataRepository.getEntriesByMonth(years[i], - months[j]); - DateFilterTreeNode monthNode = new DateFilterTreeNode( - monthNames[months[j]], years[i], months[j], 0, - monthEntries == null ? 0 : monthEntries.size()); - yearNode.add(monthNode); - } - } - DefaultTreeModel dtm = (DefaultTreeModel) dateTree.getModel(); - dtm.nodeStructureChanged(dateTreeAllNode); - dateTree.expandRow(0); - // Select "All" by default - dateTree.setSelectionRow(0); - dateTree.repaint(); - updateToolbar(0); - } - - /** - * User pressed the Enter key in the search text. - */ - void searchUpdated() { - searchText = searchTextField.getText(); - if (searchText != null && searchText.trim().length() == 0) - searchText = null; - updateFilteredJournalList(); - } - - // Filter the specified List of Journal objects by - // the searchText using a regular expression. - private List filterSearchText(List entries) { - List ret; - Pattern pat; - Matcher m; - - if (searchText == null || searchText.trim().length() == 0) - return entries; - - // remove any characters that are not regular expression safe - StringBuffer sb = new StringBuffer(); - for (int i = 0; i < searchText.length(); i++) { - char ch = searchText.charAt(i); - if (ch >= 'a' || ch <= 'Z' || ch >= 'A' || ch <= 'Z' || ch >= '0' - || ch <= '9' || ch == ' ') { - sb.append(ch); - } - } - if (sb.length() == 0) - return entries; - - ret = new ArrayList(); - pat = Pattern.compile(sb.toString(), Pattern.CASE_INSENSITIVE); - // System.out.println ( "Pattern: " + pat ); - for (int i = 0; i < entries.size(); i++) { - Journal j = entries.get(i); - Description d = j.getDescription(); - boolean matches = false; - // Search summary, categories, and description - Summary summary = j.getSummary(); - if (summary != null) { - m = pat.matcher(summary.getValue()); - if (m.find()) { - matches = true; - } - } - if (!matches) { - Categories cats = j.getCategories(); - if (cats != null) { - m = pat.matcher(cats.getValue()); - if (m.find()) { - matches = true; - } - } - } - if (!matches) { - if (d != null) { - m = pat.matcher(d.getValue()); - if (m.find()) { - matches = true; - } - } - } - if (matches) { - ret.add(j); - } - } - return ret; - } - - /** - * Update the JTable of Journal entries based on the Journal objects in the - * filteredJournalEntries List. - */ - void updateFilteredJournalList() { - filteredSearchedJournalEntries = filterSearchText(filteredJournalEntries); - journalListTableModel - .setRowCount(filteredSearchedJournalEntries == null ? 0 - : filteredSearchedJournalEntries.size()); - journalListTable.clearHighlightedRows(); - for (int i = 0; filteredSearchedJournalEntries != null - && i < filteredSearchedJournalEntries.size(); i++) { - Journal entry = filteredSearchedJournalEntries.get(i); - if (entry.getStartDate() != null) { - journalListTable.setValueAt(new DisplayDate(entry.getStartDate(), - entry), i, 0); - } else { - journalListTable.setValueAt(new DisplayDate(null, entry), i, 0); - } - Summary summary = entry.getSummary(); - journalListTable.setValueAt( - summary == null ? "-" : summary.getValue(), i, 1); - } - this.showStatusMessage("" + filteredSearchedJournalEntries.size() - + " entries " - + (searchText == null ? "" : "matched '" + searchText + "'")); - - journalListTable.repaint(); - } - - /** - * Get the data directory that data files for this application will be stored - * in. - * - * @return - */ - // TODO: allow user preferences to override this setting - File getDataDirectory() { - String s = (String) System.getProperty("user.home"); - if (s == null) { - System.err.println("Could not find user.home setting."); - System.err.println("Using current directory instead."); - s = "."; - } - File f = new File(s); - if (!f.exists()) - fatalError("Home directory '" + f + "' does not exist."); - if (!f.isDirectory()) - fatalError("Home directory '" + f + "'is not a directory"); - // Use the home directory as the base. Data files will - // be stored in a subdirectory. - File dir = new File(f, DEFAULT_DIR_NAME); - if (!dir.exists()) { - if (!dir.mkdirs()) - fatalError("Unable to create data directory: " + dir); - showMessage("The following directory was created\n" - + "to store data files:\n\n" + dir); - } - if (!dir.isDirectory()) - fatalError("Not a directory: " + dir); - return dir; - } - - void showStatusMessage(String string) { - this.messageArea.setText(string); - } - - void showMessage(String message) { - JOptionPane.showMessageDialog(parent, message, "Notice", - JOptionPane.INFORMATION_MESSAGE); - } - - void showError(String message) { - System.err.println("Error: " + message); - JOptionPane - .showMessageDialog(parent, message, "Error", JOptionPane.ERROR); - } - - void fatalError(String message) { - System.err.println("Fatal error: " + message); - JOptionPane.showMessageDialog(parent, message, "Fatal Error", - JOptionPane.ERROR); - System.exit(1); - } - - protected JButton makeNavigationButton(String imageName, - String actionCommand, String toolTipText, String altText) { - JButton button; - - // Look for the image. - String imgLocation = "images/" + imageName; - URL imageURL = this.getClass().getResource(imgLocation); - - if (imageURL != null) { // image found - button = new JButton(); - button.setIcon(new ImageIcon(imageURL, altText)); - } else { - // no image found - button = new JButton(altText); - if (imageName != null) - System.err.println("Resource not found: " + imgLocation); - } - - button.setActionCommand(actionCommand); - button.setToolTipText(toolTipText); - - return button; - } - - /** - * Set the Look and Feel to be Windows. - */ - public static void setWindowsLAF() { - try { - UIManager - .setLookAndFeel("com.sun.java.swing.plaf.windows.WindowsLookAndFeel"); - } catch (Exception e) { - System.err.println("Unabled to load Windows UI: " + e.toString()); - } - } - - public void selectLookAndFeel(Component toplevel, Frame dialogParent) { - LookAndFeel lafCurrent = UIManager.getLookAndFeel(); - System.out.println("Current L&F: " + lafCurrent); - UIManager.LookAndFeelInfo[] info = UIManager.getInstalledLookAndFeels(); - String[] choices = new String[info.length]; - int sel = 0; - for (int i = 0; i < info.length; i++) { - System.out.println(" " + info[i].toString()); - choices[i] = info[i].getClassName(); - if (info[i].getClassName().equals(lafCurrent.getClass().getName())) - sel = i; - } - Object uiSelection = JOptionPane.showInputDialog(dialogParent, - "Select Look and Feel", "Look and Feel", - JOptionPane.INFORMATION_MESSAGE, null, choices, choices[sel]); - UIManager.LookAndFeelInfo selectedLAFInfo = null; - for (int i = 0; i < info.length; i++) { - if (uiSelection.equals(choices[i])) - selectedLAFInfo = info[i]; - } - if (selectedLAFInfo != null) { - try { - System.out.println("Changing L&F: " + selectedLAFInfo); - UIManager.setLookAndFeel(selectedLAFInfo.getClassName()); - // SwingUtilities.updateComponentTreeUI ( parent ); - // parent.pack (); - } catch (Exception e) { - System.err.println("Unabled to load L&F: " + e.toString()); - } - } else { - System.err.println("No L&F selected"); - } - } - - protected void exportAll() { - export("Export All", dataRepository.getAllEntries()); - } - - protected void exportVisible() { - export("Export Visible", filteredJournalEntries); - } - - protected void exportSelected() { - List selected = new ArrayList(); - int[] sel = journalListTable.getSelectedRows(); - if (sel == null || sel.length == 0) { - showError("You have not selected any entries"); - return; - } - for (int i = 0; i < sel.length; i++) { - DisplayDate dd = (DisplayDate) journalListTable.getValueAt(i, 0); - Journal journal = (Journal) dd.getUserData(); - selected.add(journal); - } - export("Export Selected", selected); - } - - private void export(String title, List journalEntries) { - JFileChooser fileChooser; - File outFile = null; - - if (lastExportDirectory == null) - fileChooser = new JFileChooser(); - else - fileChooser = new JFileChooser(lastExportDirectory); - fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY); - fileChooser.setFileFilter(new ICSFileChooserFilter()); - fileChooser.setDialogTitle("Select Output File for " + title); - fileChooser.setApproveButtonText("Save as ICS File"); - fileChooser - .setApproveButtonToolTipText("Export entries to iCalendar file"); - int ret = fileChooser.showOpenDialog(this); - if (ret == JFileChooser.APPROVE_OPTION) { - outFile = fileChooser.getSelectedFile(); - } else { - // Cancel - return; - } - // If no file extension provided, use ".ics - String basename = outFile.getName(); - if (basename.indexOf('.') < 0) { - // No filename extension provided, so add ".csv" to it - outFile = new File(outFile.getParent(), basename + ".ics"); - } - System.out.println("Selected File: " + outFile.toString()); - lastExportDirectory = outFile.getParentFile(); - if (outFile.exists() && !outFile.canWrite()) { - JOptionPane.showMessageDialog(parent, - "You do not have the proper\npermissions to write to:\n\n" - + outFile.toString() + "\n\nPlease select another file.", - "Save Error", JOptionPane.PLAIN_MESSAGE); - return; - } - if (outFile.exists()) { - if (JOptionPane.showConfirmDialog(parent, - "Overwrite existing file?\n\n" + outFile.toString(), - "Overwrite Confirm", JOptionPane.YES_NO_OPTION) != 0) { - JOptionPane.showMessageDialog(parent, "Export canceled.", - "Export canceled", JOptionPane.PLAIN_MESSAGE); - return; - } - } - try { - PrintWriter writer = new PrintWriter(new FileWriter(outFile)); - // Now write! - ICalendarParser p = new ICalendarParser(PARSE_LOOSE); - DataStore dataStore = p.getDataStoreAt(0); - for (int i = 0; i < journalEntries.size(); i++) { - Journal j = journalEntries.get(i); - dataStore.storeJournal(j); - } - writer.write(p.toICalendar()); - writer.close(); - JOptionPane.showMessageDialog(parent, "Exported to:\n\n" - + outFile.toString(), "Export", JOptionPane.PLAIN_MESSAGE); - } catch (IOException e) { - JOptionPane.showMessageDialog(parent, - "An error was encountered\nwriting to the file:\n\n" - + e.getMessage(), - "Save Error", JOptionPane.PLAIN_MESSAGE); - e.printStackTrace(); - } - } - - public void journalAdded(Journal journal) { - this.updateDateTree(); - handleDateFilterSelection(0, null); - } - - public void journalUpdated(Journal journal) { - this.updateDateTree(); - handleDateFilterSelection(0, null); - } - - public void journalDeleted(Journal journal) { - this.updateDateTree(); - handleDateFilterSelection(0, null); - } - - /** - * @param args - */ - public static void main(String[] args) { - new Main(); - } - -} - -/** - * Create a class to use as a file filter for exporting to ics. - */ -class ICSFileChooserFilter extends javax.swing.filechooser.FileFilter { - public boolean accept(File f) { - if (f.isDirectory()) - return true; - String name = f.getName(); - if (name.toLowerCase().endsWith(".ics")) - return true; - return false; - } - - public String getDescription() { - return "*.ics (iCalendar Files)"; - } -} diff --git a/src/main/java/us/k5n/journal/ProportionalLayout.java b/src/main/java/us/k5n/journal/ProportionalLayout.java deleted file mode 100644 index 9593d56..0000000 --- a/src/main/java/us/k5n/journal/ProportionalLayout.java +++ /dev/null @@ -1,145 +0,0 @@ -package us.k5n.journal; - -import java.awt.Component; -import java.awt.Container; -import java.awt.Dimension; -import java.awt.Insets; -import java.awt.LayoutManager; - -/** - * The ProportionalLayout class implements a LayoutManager where the caller can - * specify which proportion each component should be allocated. - * - * @author cknudsen - * - */ -public class ProportionalLayout implements LayoutManager { - private int[] proportions; - private int total; // The total of the proportions - private int num; // The number in the array - public final static int HORIZONTAL_LAYOUT = 1; - public final static int VERITCAL_LAYOUT = 2; - private int orientation; - - /** - * Constructs a ProportinalLayout instance with the specified horizontal - * component proportions - * - * @param proportions - * An int array of values indicating horizontal proportions. An array - * of 2,1,1 would give the first component added half the space - * horizontally, the second and the third would each get a quarter. - * More components would not be given any space at all. When there - * are less than the expected number of components the unused values - * in the proportions array will correspond to blank space in the - * layout. - * @param orientation - * specified whether the layout is vertical (VERTICAL_LAYOUT) or - * horizontal (HORIZONTAL_LAYOUT). - */ - public ProportionalLayout(int[] proportions, int orientation) { - this.proportions = proportions; - this.orientation = orientation; - num = proportions.length; - for ( int i = 0; i < num; i++ ) { - int prop = proportions[i]; - total += prop; - } - } - - private Dimension layoutSize ( Container parent, boolean minimum ) { - Dimension dim = new Dimension ( 0, 0 ); - synchronized ( parent.getTreeLock () ) { - int n = parent.getComponentCount (); - int cnt = 0; - for ( int i = 0; i < n; i++ ) { - Component c = parent.getComponent ( i ); - if ( c.isVisible () ) { - Dimension d = ( minimum ) ? c.getMinimumSize () : c - .getPreferredSize (); - if ( this.orientation == HORIZONTAL_LAYOUT ) { - dim.width += d.width; - if ( d.height > dim.height ) - dim.height = d.height; - } else { - dim.height += d.height; - if ( d.width > dim.width ) - dim.width = d.width; - } - } - cnt++; - if ( cnt == num ) - break; - } - } - Insets insets = parent.getInsets (); - dim.width += insets.left + insets.right; - dim.height += insets.top + insets.bottom; - return dim; - } - - /** - * Lays out the container. - */ - public void layoutContainer ( Container parent ) { - Insets insets = parent.getInsets (); - synchronized ( parent.getTreeLock () ) { - int n = parent.getComponentCount (); - Dimension pd = parent.getSize (); - // do layout - int cnt = 0; - int totalwid = pd.width - insets.left - insets.right; - int totalhei = pd.height - insets.top - insets.bottom; - int x = insets.left; - int y = insets.top; - for ( int i = 0; i < n; i++ ) { - Component c = parent.getComponent ( i ); - if ( this.orientation == HORIZONTAL_LAYOUT ) { - int wid = proportions[i] * totalwid / total; - c.setBounds ( x, y, wid, pd.height - insets.bottom - insets.top ); - x += wid; - } else { - int hei = proportions[i] * totalhei / total; - c.setBounds ( x, y, pd.width - insets.left - insets.right, hei ); - y += hei; - } - cnt++; - if ( cnt == num ) - break; - } - } - } - - public Dimension minimumLayoutSize ( Container parent ) { - return layoutSize ( parent, false ); - } - - public Dimension preferredLayoutSize ( Container parent ) { - return layoutSize ( parent, false ); - } - - /** - * Not used by this class - */ - public void addLayoutComponent ( String name, Component comp ) { - } - - /** - * Not used by this class - */ - public void removeLayoutComponent ( Component comp ) { - } - - public String toString () { - StringBuffer sb = new StringBuffer (); - sb.append ( getClass ().getName () ).append ( '[' ); - int len = proportions.length; - for ( int i = 0; i < len; i++ ) { - sb.append ( 'p' ).append ( i ).append ( '=' ).append ( proportions[i] ); - if ( i != len - 1 ) - sb.append ( ',' ); - } - sb.append ( ']' ); - return sb.toString (); - } -} diff --git a/src/main/java/us/k5n/journal/ReadOnlyTabelModel.java b/src/main/java/us/k5n/journal/ReadOnlyTabelModel.java deleted file mode 100644 index e756bce..0000000 --- a/src/main/java/us/k5n/journal/ReadOnlyTabelModel.java +++ /dev/null @@ -1,78 +0,0 @@ -package us.k5n.journal; - -import javax.swing.table.AbstractTableModel; - -/** - * Implement a TableModel so we can sort the table by clicking on the column - * header. - * - * @author Craig Knudsen, craig@kn.us. - */ -public class ReadOnlyTabelModel extends AbstractTableModel { - private String[] columnNames = null; - private Object[][] data = null; - private int rows, cols; - - public ReadOnlyTabelModel(String[] header, int rows, int cols) { - this.columnNames = header; - this.data = new Object[rows][cols]; - this.rows = rows; - this.cols = cols; - } - - /** - * Reset the size. The caller needs to call setValueAt for each row and column - * after this call. - * - * @param r - */ - public void setRowCount ( int r ) { - this.rows = r; - this.data = new Object[rows][cols]; - super.fireTableDataChanged (); - } - - public int getColumnCount () { - return columnNames.length; - } - - public int getRowCount () { - return data.length; - } - - public String getColumnName ( int col ) { - return columnNames[col]; - } - - public Object getValueAt ( int row, int col ) { - return data[row][col]; - } - - /* - * JTable uses this method to determine the default renderer/ editor for each - * cell. If we didn't implement this method, then the last column would - * contain text ("true"/"false"), rather than a check box. - */ - public Class getColumnClass ( int c ) { - Object o = getValueAt ( 0, c ); - if ( o == null ) - return null; - else - return o.getClass (); - } - - /* - * Don't need to implement this method unless your table's editable. - */ - public boolean isCellEditable ( int row, int col ) { - return false; - } - - /* - * Don't need to implement this method unless your table's data can change. - */ - public void setValueAt ( Object value, int row, int col ) { - data[row][col] = value; - } - -} diff --git a/src/main/java/us/k5n/journal/ReadOnlyTable.java b/src/main/java/us/k5n/journal/ReadOnlyTable.java deleted file mode 100644 index 6fef7d3..0000000 --- a/src/main/java/us/k5n/journal/ReadOnlyTable.java +++ /dev/null @@ -1,140 +0,0 @@ -package us.k5n.journal; - -import java.awt.Color; -import java.awt.Component; -import java.awt.Dimension; -import java.awt.FontMetrics; -import java.awt.Graphics; -import java.awt.event.MouseEvent; - -import javax.swing.JTable; -import javax.swing.table.JTableHeader; -import javax.swing.table.TableCellRenderer; -import javax.swing.table.TableColumn; -import javax.swing.table.TableModel; - -/** - * Overide methods of JTable to customize: no cell editing, alternating - * background colors for odd/even rows, resize to fit - * - * @author Craig Knudsen, craig@k5n.us - */ -public class ReadOnlyTable extends JTable { - int[] highlightedRows = null; - Color lightGray; - private boolean firstPaint = true; - - public void clearHighlightedRows () { - this.highlightedRows = null; - } - - public void setHighlightedRow ( int row ) { - this.highlightedRows = new int[1]; - highlightedRows[0] = row; - repaint (); - } - - public void setHighlightedRows ( int[] rows ) { - this.highlightedRows = rows; - repaint (); - } - - private boolean rowIsHighlighted ( int row ) { - if ( highlightedRows == null ) - return false; - for ( int i = 0; i < highlightedRows.length; i++ ) { - if ( highlightedRows[i] == row ) - return true; - } - return false; - } - - public Component prepareRenderer ( TableCellRenderer renderer, int rowIndex, - int vColIndex ) { - Component c = super.prepareRenderer ( renderer, rowIndex, vColIndex ); - if ( rowIsHighlighted ( rowIndex ) ) { - c.setBackground ( Color.blue ); - c.setForeground ( Color.white ); - // } else if ( rowIndex % 2 == 0 && !isCellSelected ( rowIndex, vColIndex - // ) ) { - } else if ( rowIndex % 2 == 0 ) { - c.setBackground ( lightGray ); - c.setForeground ( Color.black ); - } else { - // If not shaded, match the table's background - c.setBackground ( getBackground () ); - c.setForeground ( Color.black ); - } - return c; - } - - public ReadOnlyTable(int rows, int cols) { - super ( rows, cols ); - lightGray = new Color ( 220, 220, 220 ); - } - - public ReadOnlyTable(TableModel tm) { - super ( tm ); - lightGray = new Color ( 220, 220, 220 ); - } - - public boolean isCellEditable ( int row, int col ) { - return false; - } - - public void paint ( Graphics g ) { - if ( firstPaint ) { - // After first paint call, we can get font metric info. So, - // call autoResize to adjust column widths. - // autoResize (); - // doLayout (); - firstPaint = false; - } - super.paint ( g ); - } - - private void autoResize () { - FontMetrics fm = getGraphics ().getFontMetrics (); - int[] widths = new int[getColumnCount ()]; - for ( int i = 0; i < getColumnCount (); i++ ) { - TableColumn tc = this.getColumnModel ().getColumn ( i ); - String label = (String) tc.getHeaderValue (); - widths[i] = fm.stringWidth ( label ) + 2 - * getColumnModel ().getColumnMargin () + 15; - } - for ( int row = 0; row < getRowCount (); row++ ) { - for ( int col = 0; col < getColumnCount (); col++ ) { - String val = this.getValueAt ( row, col ).toString (); - int width = ( val == null ? 0 : fm.stringWidth ( val ) - + ( 2 * getColumnModel ().getColumnMargin () ) + 6 ); - if ( width > widths[col] ) - widths[col] = width; - } - } - - int totalW = 0; - for ( int i = 0; i < getColumnCount (); i++ ) { - totalW += widths[i]; - } - Dimension d = getPreferredSize (); - d = new Dimension ( totalW + 50, d.height ); - // setPreferredSize ( d ); - for ( int i = 0; i < getColumnCount (); i++ ) { - TableColumn tc = getColumnModel ().getColumn ( i ); - tc.setPreferredWidth ( widths[i] ); - } - } - - // Implement table header tool tips. - protected JTableHeader createDefaultTableHeader () { - return new JTableHeader ( columnModel ) { - public String getToolTipText ( MouseEvent e ) { - java.awt.Point p = e.getPoint (); - int index = columnModel.getColumnIndexAtX ( p.x ); - String text = columnModel.getColumn ( index ).getHeaderValue () - .toString (); - return "Click to sort by " + text; - } - }; - } -} diff --git a/src/main/java/us/k5n/journal/Repository.java b/src/main/java/us/k5n/journal/Repository.java deleted file mode 100644 index a0e5915..0000000 --- a/src/main/java/us/k5n/journal/Repository.java +++ /dev/null @@ -1,356 +0,0 @@ -package us.k5n.journal; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import us.k5n.ical.Categories; -import us.k5n.ical.Date; -import us.k5n.ical.Journal; -import us.k5n.ical.Utils; - -/** - * The Repository class manages all loading and saving of data files. All - * methods are intended to work with just Journal objects. However, if an - * iCalendar file is loaded with Event objects, they should be preserved in the - * data if it is written back out. - * - * @author Craig Knudsen, craig@k5n.us - */ -public class Repository { - File directory; - List dataFiles; - Map dataFileHash; - int parseErrorCount = 0; - int journalCount = 0; - Date[] listOfDates; - Map uidHash; - private List changeListeners; - private List categories; // List of String categories - - public Repository(File dir, boolean strictParsing) { - this.directory = dir; - this.dataFiles = new ArrayList(); - this.dataFileHash = new HashMap(); - this.uidHash = new HashMap(); - this.changeListeners = new ArrayList(); - this.categories = new ArrayList(); - - // Load all files. - File[] files = this.directory.listFiles(new IcsFileFilter()); - for (int i = 0; files != null && i < files.length; i++) { - DataFile f = new DataFile(files[i].getAbsolutePath(), strictParsing); - if (f != null) { - this.addDataFile(f); - } - } - - rebuildPrivateData(); - } - - public void addDataFile(DataFile f) { - this.dataFiles.add(f); - journalCount += f.getJournalCount(); - parseErrorCount += f.getParseErrorCount(); - // Store in HashMap using just the filename (19991231.ics) - // as the key - this.dataFileHash.put(f.getName().toLowerCase(), f); - } - - public DataFile findDataFile(Journal j) { - String YMD = Utils.DateToYYYYMMDD(j.getStartDate()); - String fileName = YMD + ".ics"; - DataFile dataFile = (DataFile) this.dataFileHash.get(fileName); - return dataFile; - } - - /** - * Get an array of int values indicating which years have Journal entries. - * - * @return - */ - public int[] getYears() { - if (listOfDates == null) - return null; - Map h = new HashMap(); - List years = new ArrayList(); - for (int i = 0; i < listOfDates.length; i++) { - Integer ival = new Integer(listOfDates[i].getYear()); - if (!h.containsKey(ival)) { - h.put(ival, ival); - years.add(ival); - } - } - int[] ret = new int[years.size()]; - for (int i = 0; i < years.size(); i++) - ret[i] = years.get(i); - return ret; - } - - /** - * Get an array of int values that will indicate which months of the specified - * year have Journal entries. - * - * @param year - * 4-digit year - * @return - */ - public int[] getMonthsForYear(int year) { - if (listOfDates == null) - return null; - HashMap h = new HashMap(); - List months = new ArrayList(); - for (int i = 0; i < listOfDates.length; i++) { - if (listOfDates[i].getYear() == year) { - Integer ival = new Integer(listOfDates[i].getMonth()); - if (!h.containsKey(ival)) { - h.put(ival, ival); - months.add(ival); - } - } - } - int[] ret = new int[months.size()]; - for (int i = 0; i < months.size(); i++) - ret[i] = months.get(i); - return ret; - } - - /** - * Get all Journal objects for the specified month. - * - * @param year - * The 4-digit year - * @param month - * The month (Jan=1, Feb=2, etc.) - * @return - */ - public List getEntriesByMonth(int year, int month) { - if (listOfDates == null) - return null; - List ret = new ArrayList(); - for (int i = 0; i < dataFiles.size(); i++) { - DataFile df = dataFiles.get(i); - for (int j = 0; j < df.getJournalCount(); j++) { - Journal journal = df.journalEntryAt(j); - if (journal.getStartDate() == null) { - System.err.println("Error: no DTSTART date for entry #" + (j + 1) - + " of " + df); - } else { - if (journal.getStartDate().getYear() == year - && journal.getStartDate().getMonth() == month) - ret.add(journal); - } - } - } - return ret; - } - - /** - * Get all Journal objects for the specified year. - * - * @param year - * The 4-digit year - * @return - */ - public List getEntriesByYear(int year) { - if (listOfDates == null) - return null; - List ret = new ArrayList(); - for (int i = 0; i < dataFiles.size(); i++) { - DataFile df = dataFiles.get(i); - for (int j = 0; j < df.getJournalCount(); j++) { - Journal journal = df.journalEntryAt(j); - if (journal.getStartDate() == null) { - System.err.println("Error: no DTSTART date for entry #" + (j + 1) - + " of " + df); - } else { - if (journal.getStartDate().getYear() == year) - ret.add(journal); - } - } - } - return ret; - } - - /** - * Get all Journal objects. - * - * @return - */ - public List getAllEntries() { - if (listOfDates == null) - return null; - List ret = new ArrayList(); - for (int i = 0; i < dataFiles.size(); i++) { - DataFile df = dataFiles.get(i); - for (int j = 0; j < df.getJournalCount(); j++) { - Journal journal = df.journalEntryAt(j); - ret.add(journal); - } - } - return ret; - } - - /** - * Update the listOfDates array. Update the List of existing categories. - */ - private void rebuildPrivateData() { - List dates = new ArrayList(); - this.categories = new ArrayList(); - HashMap catH = new HashMap(); - HashMap h = new HashMap(); - for (int i = 0; i < dataFiles.size(); i++) { - DataFile df = dataFiles.get(i); - // System.out.println ( "DataFile#" + i + ": " + df.toString () ); - // System.out.println ( " df.getJournalCount () =" + df.getJournalCount () - // ); - for (int j = 0; j < df.getJournalCount(); j++) { - Journal journal = df.journalEntryAt(j); - if (journal.getStartDate() != null) { - String YMD = Utils.DateToYYYYMMDD(journal.getStartDate()); - if (!h.containsKey(YMD)) { - h.put(YMD, YMD); - dates.add(journal.getStartDate()); - // System.out.println ( "Added date: " + journal.getStartDate () ); - } - } - Categories cats = journal.getCategories(); - if (cats != null && cats.getValue() != null) { - String[] catArray = splitCategories(cats.getValue()); - for (int k = 0; catArray != null && k < catArray.length; k++) { - String c1 = catArray[k].trim(); - if (c1.length() > 0) { - String c1up = c1.toUpperCase(); - if (!catH.containsKey(c1up)) { - this.categories.add(c1); - catH.put(c1up, c1up); - } - } - } - } - } - } - if (dates.size() > 0) { - Collections.sort(dates); - listOfDates = new Date[dates.size()]; - for (int i = 0; i < dates.size(); i++) { - listOfDates[i] = dates.get(i); - // System.out.println ( "Found date: " + listOfDates[i] ); - } - } else { - listOfDates = null; - } - } - - /** - * Save the specified Journal object. If the Journal is part of an existing - * iCalendar file, the entire file will be written out. If this Journal object - * is new, then a new iCalendar file will be created. Note: It is up to the - * caller to update the Sequence object each time a Journal entry is saved. - * The "LAST-MODIFIED" setting will be updated automatically. - * - * @param j - * @throws IOException - */ - public void saveJournal(Journal j) throws IOException { - boolean added = false; - - DataFile dataFile = (DataFile) j.getUserData(); - if (dataFile == null) { - // New journal. Add to existing data file named YYYYMMDD.ics if - // it exists. - dataFile = findDataFile(j); - if (dataFile == null) { - added = true; - // No file for this date (YYYYMMDD.ics) exists yet. - // So, we need to create a new one. - File f = new File(this.directory, Utils.DateToYYYYMMDD(j - .getStartDate()) - + ".ics"); - dataFile = new DataFile(f.getAbsolutePath()); - dataFile.addJournal(j); - this.addDataFile(dataFile); - } else { - // Add this journal entry to the file - dataFile.addJournal(j); - } - } - j.setLastModified(Date.getCurrentDateTime("LAST-MODIFIED")); - j.setUserData(dataFile); - dataFile.write(); - - rebuildPrivateData(); - - if (added) { - for (int i = 0; this.changeListeners != null - && i < this.changeListeners.size(); i++) { - RepositoryChangeListener l = (RepositoryChangeListener) this.changeListeners - .get(i); - l.journalAdded(j); - } - } else { - // If we are updating, then the Journal to be updated should - // already be updated in the DataStore. - for (int i = 0; this.changeListeners != null - && i < this.changeListeners.size(); i++) { - RepositoryChangeListener l = (RepositoryChangeListener) this.changeListeners - .get(i); - l.journalUpdated(j); - } - } - } - - /** - * Delete the specified Journal object. - * - * @param j - * @throws IOException - */ - public boolean deleteJournal(Journal j) throws IOException { - boolean deleted = false; - DataFile dataFile = (DataFile) j.getUserData(); - if (dataFile == null) { - // New journal. Nothing to do... - System.err.println("Not found..."); - } else { - // Journal to be deleted should be in the DataStore. - if (dataFile.removeJournal(j)) { - deleted = true; - dataFile.write(); - rebuildPrivateData(); - for (int i = 0; this.changeListeners != null - && i < this.changeListeners.size(); i++) { - RepositoryChangeListener l = (RepositoryChangeListener) this.changeListeners - .get(i); - l.journalDeleted(j); - } - } else { - // System.out.println ( "Not deleted" ); - } - } - return deleted; - } - - /** - * Ask to be notified when changes are made to the Repository. - * - * @param l - */ - public void addChangeListener(RepositoryChangeListener l) { - if (this.changeListeners == null) - this.changeListeners = new ArrayList(); - this.changeListeners.add(l); - } - - public List getCategories() { - return this.categories; - } - - private static String[] splitCategories(String categories) { - return categories.trim().split(","); - } -} diff --git a/src/main/java/us/k5n/journal/RepositoryChangeListener.java b/src/main/java/us/k5n/journal/RepositoryChangeListener.java deleted file mode 100644 index a371b45..0000000 --- a/src/main/java/us/k5n/journal/RepositoryChangeListener.java +++ /dev/null @@ -1,17 +0,0 @@ -package us.k5n.journal; - -import us.k5n.ical.Journal; - -/** - * Interface for receiving updates from Repository. - * - * @author Craig Knudsen, craig@k5n.us - */ -public interface RepositoryChangeListener { - - public abstract void journalAdded ( Journal journal ); - - public abstract void journalUpdated ( Journal journal ); - - public abstract void journalDeleted ( Journal journal ); -} diff --git a/src/main/java/us/k5n/journal/SearchTextField.java b/src/main/java/us/k5n/journal/SearchTextField.java deleted file mode 100644 index 0856fa1..0000000 --- a/src/main/java/us/k5n/journal/SearchTextField.java +++ /dev/null @@ -1,128 +0,0 @@ -package us.k5n.journal; - -import java.awt.Color; -import java.awt.Cursor; -import java.awt.FontMetrics; -import java.awt.Graphics; -import java.awt.Point; -import java.awt.event.MouseEvent; -import java.awt.event.MouseListener; -import java.awt.event.MouseMotionListener; - -import javax.swing.ImageIcon; -import javax.swing.JTextField; - -public class SearchTextField extends JTextField implements MouseListener, - MouseMotionListener { - public static ImageIcon clearImage = null; - private static byte[] clearImageBytes = { 71, 73, 70, 56, 57, 97, 14, 0, 14, - 0, -124, 26, 0, -60, 124, 124, -58, -126, -126, -58, -125, -125, -57, - -125, -125, -54, -118, -118, -54, -117, -117, -52, -115, -115, -52, -114, - -114, -50, -109, -109, -50, -108, -108, -49, -108, -108, -47, -103, -103, - -43, -94, -94, -43, -93, -93, -38, -83, -83, -36, -78, -78, -36, -77, - -77, -35, -77, -77, -33, -71, -71, -31, -67, -67, -26, -55, -55, -5, -8, - -8, -4, -8, -8, -4, -6, -6, -3, -5, -5, -2, -3, -3, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 33, -2, 21, 67, 114, - 101, 97, 116, 101, 100, 32, 119, 105, 116, 104, 32, 84, 104, 101, 32, 71, - 73, 77, 80, 0, 44, 0, 0, 0, 0, 14, 0, 14, 0, 0, 5, 80, -32, -11, 12, 64, - 105, -106, 2, 36, -98, -84, 9, -111, -91, 113, 18, 38, 12, 72, 24, 83, - 46, -43, -60, 30, 24, 77, -114, -89, -55, 40, 88, -115, 32, -58, 82, 116, - -76, 0, 73, 77, -13, 9, 88, 48, -123, -70, 22, 49, -93, -52, -102, 10, - -107, 102, 20, -125, 96, 77, 50, 78, 40, -122, 98, 10, -104, -114, -90, - -124, 73, -16, -96, -98, 84, 16, -9, 51, 16, -71 }; - int buttonX = -1, buttonY = -1; - boolean iconVisible = false; - boolean cursorChanged = false; - private Cursor defaultCursor = null; - private Cursor buttonCursor = null; - private Color hintColor = null; - public static String HINT = "Enter search text"; - - public SearchTextField() { - super (); - this.addMouseListener ( this ); - this.addMouseMotionListener ( this ); - } - - public void paint ( Graphics g ) { - int x, y; - super.paint ( g ); - if ( clearImage == null ) { - clearImage = new ImageIcon ( clearImageBytes ); - } - if ( hintColor == null ) { - // Make hint color a very light grey - // TODO: derive this from current colors - hintColor = new Color ( 200, 200, 200 ); - } - - if ( this.getText ().length () > 0 ) { - x = this.getWidth () - clearImage.getIconWidth () - 5; - y = ( this.getHeight () - clearImage.getIconHeight () ) / 2; - clearImage.paintIcon ( this, g, x, y ); - this.buttonX = x; - this.buttonY = y; - this.iconVisible = true; - } else { - this.iconVisible = false; - // No text. Draw hint - FontMetrics fm = g.getFontMetrics (); - y = ( this.getHeight () - fm.getHeight () ) / 2 + fm.getAscent (); - x = ( this.getWidth () - fm.stringWidth ( HINT ) ) / 2; - g.setColor ( hintColor ); - g.drawString ( HINT, x, y ); - } - } - - public void mouseEntered ( MouseEvent e ) { - } - - public void mouseReleased ( MouseEvent e ) { - } - - public void mouseClicked ( MouseEvent e ) { - } - - public void mouseExited ( MouseEvent e ) { - } - - private boolean isOverClearButton ( Point p ) { - return ( this.iconVisible && p.x >= this.buttonX - && p.x <= this.buttonX + clearImage.getIconWidth () - && p.y >= this.buttonY && p.y <= this.buttonY - + clearImage.getIconHeight () ); - } - - public void mousePressed ( MouseEvent e ) { - if ( this.isOverClearButton ( e.getPoint () ) ) { - this.setText ( "" ); - this.fireActionPerformed (); - this.repaint (); - } - } - - public void mouseMoved ( MouseEvent e ) { - if ( this.isOverClearButton ( e.getPoint () ) ) { - if ( !this.cursorChanged ) { - // change cursor - if ( this.defaultCursor == null ) { - this.defaultCursor = this.getCursor (); - } - if ( this.buttonCursor == null ) { - this.buttonCursor = new Cursor ( Cursor.DEFAULT_CURSOR ); - } - this.setCursor ( this.buttonCursor ); - this.cursorChanged = true; - } - } else { - if ( this.cursorChanged ) { - this.setCursor ( this.defaultCursor ); - this.cursorChanged = false; - } - } - } - - public void mouseDragged ( MouseEvent e ) { - } - -} diff --git a/src/main/java/us/k5n/journal/TableSorter.java b/src/main/java/us/k5n/journal/TableSorter.java deleted file mode 100644 index 6fd3f68..0000000 --- a/src/main/java/us/k5n/journal/TableSorter.java +++ /dev/null @@ -1,495 +0,0 @@ -package us.k5n.journal; - -import java.awt.Color; -import java.awt.Component; -import java.awt.Graphics; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.awt.event.MouseListener; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; - -import javax.swing.Icon; -import javax.swing.JLabel; -import javax.swing.JTable; -import javax.swing.event.TableModelEvent; -import javax.swing.event.TableModelListener; -import javax.swing.table.AbstractTableModel; -import javax.swing.table.JTableHeader; -import javax.swing.table.TableCellRenderer; -import javax.swing.table.TableColumnModel; -import javax.swing.table.TableModel; - -// Note: this file was found at the following URL: -// http://java.sun.com/docs/books/tutorial/uiswing/components/table.html#sorting - -/** - * TableSorter is a decorator for TableModels; adding sorting functionality to a - * supplied TableModel. TableSorter does not store or copy the data in its - * TableModel; instead it maintains a map from the row indexes of the view to - * the row indexes of the model. As requests are made of the sorter (like - * getValueAt(row, col)) they are passed to the underlying model after the row - * numbers have been translated via the internal mapping array. This way, the - * TableSorter appears to hold another copy of the table with the rows in a - * different order.

TableSorter registers itself as a listener to the - * underlying model, just as the JTable itself would. Events recieved from the - * model are examined, sometimes manipulated (typically widened), and then - * passed on to the TableSorter's listeners (typically the JTable). If a change - * to the model has invalidated the order of TableSorter's rows, a note of this - * is made and the sorter will resort the rows the next time a value is - * requested.

When the tableHeader property is set, either by using the - * setTableHeader() method or the two argument constructor, the table header may - * be used as a complete UI for TableSorter. The default renderer of the - * tableHeader is decorated with a renderer that indicates the sorting status of - * each column. In addition, a mouse listener is installed with the following - * behavior: - *

    - *
  • Mouse-click: Clears the sorting status of all other columns and advances - * the sorting status of that column through three values: {NOT_SORTED, - * ASCENDING, DESCENDING} (then back to NOT_SORTED again). - *
  • SHIFT-mouse-click: Clears the sorting status of all other columns and - * cycles the sorting status of the column through the same three values, in the - * opposite order: {NOT_SORTED, DESCENDING, ASCENDING}. - *
  • CONTROL-mouse-click and CONTROL-SHIFT-mouse-click: as above except that - * the changes to the column do not cancel the statuses of columns that are - * already sorting - giving a way to initiate a compound sort. - *
- *

This is a long overdue rewrite of a class of the same name that first - * appeared in the swing table demos in 1997. - * - * @author Philip Milne - * @author Brendon McLean - * @author Dan van Enckevort - * @author Parwinder Sekhon - * @version 2.0 02/27/04 - */ - -public class TableSorter extends AbstractTableModel { - protected TableModel tableModel; - - public static final int DESCENDING = -1; - public static final int NOT_SORTED = 0; - public static final int ASCENDING = 1; - - private static Directive EMPTY_DIRECTIVE = new Directive ( -1, NOT_SORTED ); - - public static final Comparator COMPARABLE_COMAPRATOR = new Comparator () { - public int compare ( Object o1, Object o2 ) { - return ( (Comparable) o1 ).compareTo ( o2 ); - } - }; - public static final Comparator LEXICAL_COMPARATOR = new Comparator () { - public int compare ( Object o1, Object o2 ) { - return o1.toString ().compareTo ( o2.toString () ); - } - }; - - private Row[] viewToModel; - private int[] modelToView; - - private JTableHeader tableHeader; - private MouseListener mouseListener; - private TableModelListener tableModelListener; - private Map columnComparators = new HashMap (); - private List sortingColumns = new ArrayList (); - - public TableSorter() { - this.mouseListener = new MouseHandler (); - this.tableModelListener = new TableModelHandler (); - } - - public TableSorter(TableModel tableModel) { - this (); - setTableModel ( tableModel ); - } - - public TableSorter(TableModel tableModel, JTableHeader tableHeader) { - this (); - setTableHeader ( tableHeader ); - setTableModel ( tableModel ); - } - - private void clearSortingState () { - viewToModel = null; - modelToView = null; - } - - public TableModel getTableModel () { - return tableModel; - } - - public void setTableModel ( TableModel tableModel ) { - if ( this.tableModel != null ) { - this.tableModel.removeTableModelListener ( tableModelListener ); - } - - this.tableModel = tableModel; - if ( this.tableModel != null ) { - this.tableModel.addTableModelListener ( tableModelListener ); - } - - clearSortingState (); - fireTableStructureChanged (); - } - - public JTableHeader getTableHeader () { - return tableHeader; - } - - public void setTableHeader ( JTableHeader tableHeader ) { - if ( this.tableHeader != null ) { - this.tableHeader.removeMouseListener ( mouseListener ); - TableCellRenderer defaultRenderer = this.tableHeader - .getDefaultRenderer (); - if ( defaultRenderer instanceof SortableHeaderRenderer ) { - this.tableHeader - .setDefaultRenderer ( ( (SortableHeaderRenderer) defaultRenderer ).tableCellRenderer ); - } - } - this.tableHeader = tableHeader; - if ( this.tableHeader != null ) { - this.tableHeader.addMouseListener ( mouseListener ); - this.tableHeader.setDefaultRenderer ( new SortableHeaderRenderer ( - this.tableHeader.getDefaultRenderer () ) ); - } - } - - public boolean isSorting () { - return sortingColumns.size () != 0; - } - - private Directive getDirective ( int column ) { - for ( int i = 0; i < sortingColumns.size (); i++ ) { - Directive directive = (Directive) sortingColumns.get ( i ); - if ( directive.column == column ) { - return directive; - } - } - return EMPTY_DIRECTIVE; - } - - public int getSortingStatus ( int column ) { - return getDirective ( column ).direction; - } - - private void sortingStatusChanged () { - clearSortingState (); - fireTableDataChanged (); - if ( tableHeader != null ) { - tableHeader.repaint (); - } - } - - public void setSortingStatus ( int column, int status ) { - Directive directive = getDirective ( column ); - if ( directive != EMPTY_DIRECTIVE ) { - sortingColumns.remove ( directive ); - } - if ( status != NOT_SORTED ) { - sortingColumns.add ( new Directive ( column, status ) ); - } - sortingStatusChanged (); - } - - protected Icon getHeaderRendererIcon ( int column, int size ) { - Directive directive = getDirective ( column ); - if ( directive == EMPTY_DIRECTIVE ) { - return null; - } - return new Arrow ( directive.direction == DESCENDING, size, sortingColumns - .indexOf ( directive ) ); - } - - private void cancelSorting () { - sortingColumns.clear (); - sortingStatusChanged (); - } - - public void setColumnComparator ( Class type, Comparator comparator ) { - if ( comparator == null ) { - columnComparators.remove ( type ); - } else { - columnComparators.put ( type, comparator ); - } - } - - protected Comparator getComparator ( int column ) { - Class columnType = tableModel.getColumnClass ( column ); - Comparator comparator = (Comparator) columnComparators.get ( columnType ); - if ( comparator != null ) { - return comparator; - } - if ( Comparable.class.isAssignableFrom ( columnType ) ) { - return COMPARABLE_COMAPRATOR; - } - return LEXICAL_COMPARATOR; - } - - private Row[] getViewToModel () { - if ( viewToModel == null ) { - int tableModelRowCount = tableModel.getRowCount (); - viewToModel = new Row[tableModelRowCount]; - for ( int row = 0; row < tableModelRowCount; row++ ) { - viewToModel[row] = new Row ( row ); - } - - if ( isSorting () ) { - Arrays.sort ( viewToModel ); - } - } - return viewToModel; - } - - public int modelIndex ( int viewIndex ) { - return getViewToModel ()[viewIndex].modelIndex; - } - - private int[] getModelToView () { - if ( modelToView == null ) { - int n = getViewToModel ().length; - modelToView = new int[n]; - for ( int i = 0; i < n; i++ ) { - modelToView[modelIndex ( i )] = i; - } - } - return modelToView; - } - - // TableModel interface methods - - public int getRowCount () { - return ( tableModel == null ) ? 0 : tableModel.getRowCount (); - } - - public int getColumnCount () { - return ( tableModel == null ) ? 0 : tableModel.getColumnCount (); - } - - public String getColumnName ( int column ) { - return tableModel.getColumnName ( column ); - } - - public Class getColumnClass ( int column ) { - return tableModel.getColumnClass ( column ); - } - - public boolean isCellEditable ( int row, int column ) { - return tableModel.isCellEditable ( modelIndex ( row ), column ); - } - - public Object getValueAt ( int row, int column ) { - return tableModel.getValueAt ( modelIndex ( row ), column ); - } - - public void setValueAt ( Object aValue, int row, int column ) { - tableModel.setValueAt ( aValue, modelIndex ( row ), column ); - } - - // Helper classes - - private class Row implements Comparable { - private int modelIndex; - - public Row(int index) { - this.modelIndex = index; - } - - public int compareTo ( Object o ) { - int row1 = modelIndex; - int row2 = ( (Row) o ).modelIndex; - - for ( Iterator it = sortingColumns.iterator (); it.hasNext (); ) { - Directive directive = (Directive) it.next (); - int column = directive.column; - Object o1 = tableModel.getValueAt ( row1, column ); - Object o2 = tableModel.getValueAt ( row2, column ); - - int comparison = 0; - // Define null less than everything, except null. - if ( o1 == null && o2 == null ) { - comparison = 0; - } else if ( o1 == null ) { - comparison = -1; - } else if ( o2 == null ) { - comparison = 1; - } else { - comparison = getComparator ( column ).compare ( o1, o2 ); - } - if ( comparison != 0 ) { - return directive.direction == DESCENDING ? -comparison : comparison; - } - } - return 0; - } - } - - private class TableModelHandler implements TableModelListener { - public void tableChanged ( TableModelEvent e ) { - // If we're not sorting by anything, just pass the event along. - if ( !isSorting () ) { - clearSortingState (); - fireTableChanged ( e ); - return; - } - - // If the table structure has changed, cancel the sorting; the - // sorting columns may have been either moved or deleted from - // the model. - if ( e.getFirstRow () == TableModelEvent.HEADER_ROW ) { - cancelSorting (); - fireTableChanged ( e ); - return; - } - - // We can map a cell event through to the view without widening - // when the following conditions apply: - // - // a) all the changes are on one row (e.getFirstRow() == e.getLastRow()) - // and, - // b) all the changes are in one column (column != - // TableModelEvent.ALL_COLUMNS) and, - // c) we are not sorting on that column (getSortingStatus(column) == - // NOT_SORTED) and, - // d) a reverse lookup will not trigger a sort (modelToView != null) - // - // Note: INSERT and DELETE events fail this test as they have column == - // ALL_COLUMNS. - // - // The last check, for (modelToView != null) is to see if modelToView - // is already allocated. If we don't do this check; sorting can become - // a performance bottleneck for applications where cells - // change rapidly in different parts of the table. If cells - // change alternately in the sorting column and then outside of - // it this class can end up re-sorting on alternate cell updates - - // which can be a performance problem for large tables. The last - // clause avoids this problem. - int column = e.getColumn (); - if ( e.getFirstRow () == e.getLastRow () - && column != TableModelEvent.ALL_COLUMNS - && getSortingStatus ( column ) == NOT_SORTED && modelToView != null ) { - int viewIndex = getModelToView ()[e.getFirstRow ()]; - fireTableChanged ( new TableModelEvent ( TableSorter.this, viewIndex, - viewIndex, column, e.getType () ) ); - return; - } - - // Something has happened to the data that may have invalidated the row - // order. - clearSortingState (); - fireTableDataChanged (); - return; - } - } - - private class MouseHandler extends MouseAdapter { - public void mouseClicked ( MouseEvent e ) { - JTableHeader h = (JTableHeader) e.getSource (); - TableColumnModel columnModel = h.getColumnModel (); - int viewColumn = columnModel.getColumnIndexAtX ( e.getX () ); - int column = columnModel.getColumn ( viewColumn ).getModelIndex (); - if ( column != -1 ) { - int status = getSortingStatus ( column ); - if ( !e.isControlDown () ) { - cancelSorting (); - } - // Cycle the sorting states through {NOT_SORTED, ASCENDING, DESCENDING} - // or - // {NOT_SORTED, DESCENDING, ASCENDING} depending on whether shift is - // pressed. - status = status + ( e.isShiftDown () ? -1 : 1 ); - status = ( status + 4 ) % 3 - 1; // signed mod, returning {-1, 0, 1} - setSortingStatus ( column, status ); - } - } - } - - private static class Arrow implements Icon { - private boolean descending; - private int size; - private int priority; - - public Arrow(boolean descending, int size, int priority) { - this.descending = descending; - this.size = size; - this.priority = priority; - } - - public void paintIcon ( Component c, Graphics g, int x, int y ) { - Color color = c == null ? Color.GRAY : c.getBackground (); - // In a compound sort, make each succesive triangle 20% - // smaller than the previous one. - int dx = (int) ( size / 2 * Math.pow ( 0.8, priority ) ); - int dy = descending ? dx : -dx; - // Align icon (roughly) with font baseline. - y = y + 5 * size / 6 + ( descending ? -dy : 0 ); - int shift = descending ? 1 : -1; - g.translate ( x, y ); - - // Right diagonal. - g.setColor ( color.darker () ); - g.drawLine ( dx / 2, dy, 0, 0 ); - g.drawLine ( dx / 2, dy + shift, 0, shift ); - - // Left diagonal. - g.setColor ( color.brighter () ); - g.drawLine ( dx / 2, dy, dx, 0 ); - g.drawLine ( dx / 2, dy + shift, dx, shift ); - - // Horizontal line. - if ( descending ) { - g.setColor ( color.darker ().darker () ); - } else { - g.setColor ( color.brighter ().brighter () ); - } - g.drawLine ( dx, 0, 0, 0 ); - - g.setColor ( color ); - g.translate ( -x, -y ); - } - - public int getIconWidth () { - return size; - } - - public int getIconHeight () { - return size; - } - } - - private class SortableHeaderRenderer implements TableCellRenderer { - private TableCellRenderer tableCellRenderer; - - public SortableHeaderRenderer(TableCellRenderer tableCellRenderer) { - this.tableCellRenderer = tableCellRenderer; - } - - public Component getTableCellRendererComponent ( JTable table, - Object value, boolean isSelected, boolean hasFocus, int row, int column ) { - Component c = tableCellRenderer.getTableCellRendererComponent ( table, - value, isSelected, hasFocus, row, column ); - if ( c instanceof JLabel ) { - JLabel l = (JLabel) c; - l.setHorizontalTextPosition ( JLabel.LEFT ); - int modelColumn = table.convertColumnIndexToModel ( column ); - l.setIcon ( getHeaderRendererIcon ( modelColumn, l.getFont () - .getSize () ) ); - } - return c; - } - } - - private static class Directive { - private int column; - private int direction; - - public Directive(int column, int direction) { - this.column = column; - this.direction = direction; - } - } -}