[california] Quick add event: Closes bgo#725792
- From: Jim Nelson <jnelson src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [california] Quick add event: Closes bgo#725792
- Date: Wed, 23 Apr 2014 00:36:08 +0000 (UTC)
commit b273a50c85cd23a4e7e6a8d9c38b2066c3a40d3e
Author: Jim Nelson <jim yorba org>
Date: Tue Apr 22 17:35:36 2014 -0700
Quick add event: Closes bgo#725792
Basic parser will fill in relevant event information and add to
calendar.
src/Makefile.am | 13 +
src/application/main.vala | 2 +-
src/base/base-properties.vala | 31 ++
src/calendar/calendar-date.vala | 14 +
src/calendar/calendar-day-of-week.vala | 24 ++-
src/calendar/calendar-duration.vala | 86 +++++
src/calendar/calendar-month.vala | 25 ++-
src/calendar/calendar-wall-time.vala | 71 ++++
src/calendar/calendar.vala | 20 ++
src/california-resources.xml | 3 +
src/collection/collection-lookahead-stack.vala | 133 ++++++++
src/collection/collection.vala | 22 ++
src/component/component-details-parser.vala | 422 ++++++++++++++++++++++++
src/component/component-event.vala | 19 +
src/component/component-instance.vala | 10 +
src/component/component.vala | 63 ++++
src/host/host-create-update-event.vala | 16 +-
src/host/host-main-window.vala | 45 ++-
src/host/host-quick-create-event.vala | 78 +++++
src/rc/quick-create-event.ui | 165 +++++++++
src/tests/tests-quick-add.vala | 143 ++++++++
src/tests/tests.vala | 16 +
src/toolkit/toolkit-combo-box-text-model.vala | 166 ++++++++++
src/unit-test/unit-test-harness.vala | 143 ++++++++
src/util/util-string.vala | 25 ++
vapi/libical.vapi | 2 +-
26 files changed, 1731 insertions(+), 26 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index f535a81..57105f0 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -43,6 +43,7 @@ california_VALASOURCES = \
backing/eds/backing-eds-store.vala \
\
base/base-object.vala \
+ base/base-properties.vala \
base/base-unit.vala \
\
calendar/calendar.vala \
@@ -51,6 +52,7 @@ california_VALASOURCES = \
calendar/calendar-day-of-week.vala \
calendar/calendar-date.vala \
calendar/calendar-dbus.vala \
+ calendar/calendar-duration.vala \
calendar/calendar-error.vala \
calendar/calendar-exact-time.vala \
calendar/calendar-exact-time-span.vala \
@@ -67,12 +69,15 @@ california_VALASOURCES = \
calendar/calendar-week-span.vala \
calendar/calendar-year.vala \
\
+ collection/collection.vala \
collection/collection-iterable.vala \
+ collection/collection-lookahead-stack.vala \
collection/collection-simple-iterator.vala \
collection/collection-simple-iterable.vala \
\
component/component.vala \
component/component-date-time.vala \
+ component/component-details-parser.vala \
component/component-error.vala \
component/component-event.vala \
component/component-icalendar.vala \
@@ -85,6 +90,7 @@ california_VALASOURCES = \
host/host-create-update-event.vala \
host/host-import-calendar.vala \
host/host-main-window.vala \
+ host/host-quick-create-event.vala \
host/host-show-event.vala \
\
manager/manager.vala \
@@ -92,9 +98,13 @@ california_VALASOURCES = \
manager/manager-calendar-list-item.vala \
manager/manager-window.vala \
\
+ tests/tests.vala \
+ tests/tests-quick-add.vala \
+ \
toolkit/toolkit.vala \
toolkit/toolkit-calendar-popup.vala \
toolkit/toolkit-card.vala \
+ toolkit/toolkit-combo-box-text-model.vala \
toolkit/toolkit-deck.vala \
toolkit/toolkit-deck-window.vala \
toolkit/toolkit-listbox-model.vala \
@@ -106,6 +116,8 @@ california_VALASOURCES = \
util/util-string.vala \
util/util-uri.vala \
\
+ unit-test/unit-test-harness.vala \
+ \
view/view.vala \
view/view-controllable.vala \
\
@@ -131,6 +143,7 @@ california_RC = \
rc/google-authenticating.ui \
rc/google-calendar-list.ui \
rc/google-login.ui \
+ rc/quick-create-event.ui \
rc/show-event.ui \
rc/webcal-subscribe.ui \
$(NULL)
diff --git a/src/application/main.vala b/src/application/main.vala
index 1211e0a..61e5a77 100644
--- a/src/application/main.vala
+++ b/src/application/main.vala
@@ -5,6 +5,6 @@
*/
int main(string[] args) {
- return California.Application.instance.run(args);
+ return args[1] != "--tests" ? California.Application.instance.run(args) : California.Tests.run(args);
}
diff --git a/src/base/base-properties.vala b/src/base/base-properties.vala
new file mode 100644
index 0000000..f71a691
--- /dev/null
+++ b/src/base/base-properties.vala
@@ -0,0 +1,31 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+/**
+ * Helper functions for using GObject properties and bindings.
+ */
+
+namespace California.Properties {
+
+public delegate bool ValueToBoolCallback(Value source_value);
+
+/**
+ * Simplified binding transformation of a property of any value to a boolean.
+ *
+ * The transformation is always considered successful. Use bind_property directly if finer control
+ * is required.
+ */
+public void value_to_bool(Object source, string source_property, Object target, string target_property,
+ BindingFlags flags, ValueToBoolCallback cb) {
+ source.bind_property(source_property, target, target_property, flags, (binding, source, ref target) => {
+ target = cb(source);
+
+ return true;
+ });
+}
+
+}
+
diff --git a/src/calendar/calendar-date.vala b/src/calendar/calendar-date.vala
index c9166b0..5ba97ba 100644
--- a/src/calendar/calendar-date.vala
+++ b/src/calendar/calendar-date.vala
@@ -250,6 +250,20 @@ public class Date : BaseObject, Gee.Comparable<Date>, Gee.Hashable<Date> {
return new Date.from_gdate(clone);
}
+ /**
+ * Returns the next date;
+ */
+ public Date next() {
+ return adjust(1, DateUnit.DAY);
+ }
+
+ /**
+ * Returns the previous date.
+ */
+ public Date previous() {
+ return adjust(-1, DateUnit.DAY);
+ }
+
public int compare_to(Date other) {
return (this != other) ? gdate.compare(other.gdate) : 0;
}
diff --git a/src/calendar/calendar-day-of-week.vala b/src/calendar/calendar-day-of-week.vala
index e5d6f2f..4c2024c 100644
--- a/src/calendar/calendar-day-of-week.vala
+++ b/src/calendar/calendar-day-of-week.vala
@@ -59,6 +59,8 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
private static DayOfWeek[]? days_of_week_monday = null;
private static DayOfWeek[]? days_of_week_sunday = null;
+ private static Gee.Map<string, DayOfWeek> parse_map;
+
/**
* The abbreviated locale-specific name for the day of the week.
*/
@@ -110,10 +112,18 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
date.add_days(1);
}
+ parse_map = new Gee.HashMap<string, DayOfWeek>(String.ci_hash, String.ci_equal);
+
// Following GLib's lead, days of week Monday-first is straightforward
days_of_week_monday = new DayOfWeek[COUNT];
- for (int ctr = MIN; ctr <= MAX; ctr++)
- days_of_week_monday[ctr - MIN] = new DayOfWeek(ctr, abbrevs[ctr - MIN], fulls[ctr - MIN]);
+ for (int ctr = MIN; ctr <= MAX; ctr++) {
+ DayOfWeek dow = new DayOfWeek(ctr, abbrevs[ctr - MIN], fulls[ctr - MIN]);
+ days_of_week_monday[ctr - MIN] = dow;
+
+ // add to parse map by abbreivated and full name
+ parse_map.set(dow.abbrev_name, dow);
+ parse_map.set(dow.full_name, dow);
+ }
MON = days_of_week_monday[0];
TUE = days_of_week_monday[1];
@@ -180,6 +190,16 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
}
/**
+ * Parses the string looking for a match with any of the { link DayOfWeek}'s { link abbrev_name}
+ * or { link full_name}.
+ *
+ * parse() is case-insensitive.
+ */
+ public static DayOfWeek? parse(string str) {
+ return parse_map.get(str);
+ }
+
+ /**
* The one-based ordinal value of the day of the week, depended on what the definition of
* the first day of the week.
*/
diff --git a/src/calendar/calendar-duration.vala b/src/calendar/calendar-duration.vala
new file mode 100644
index 0000000..7eae77d
--- /dev/null
+++ b/src/calendar/calendar-duration.vala
@@ -0,0 +1,86 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Calendar {
+
+/**
+ * An immutable representation of duration, as in a positive span of time.
+ *
+ * See [[https://tools.ietf.org/html/rfc5545#section-3.8.2.5]]
+ */
+
+public class Duration : BaseObject {
+ /**
+ * Number of absolute days the duration spans.
+ */
+ public uint days { get { return hours / WallTime.HOURS_PER_DAY; } }
+
+ /**
+ * Number of absolute hours the duration spans.
+ */
+ public uint hours { get { return minutes / WallTime.MINUTES_PER_HOUR; } }
+
+ /**
+ * Number of absolute minutes the duration spans.
+ */
+ public uint minutes { get { return seconds / WallTime.SECONDS_PER_MINUTE; } }
+
+ /**
+ * Number of absolute seconds the duration spans.
+ */
+ public uint seconds { get; private set; }
+
+ public Duration(uint days = 0, uint hours = 0, uint minutes = 0, uint seconds = 0) {
+ // internally stored as seconds
+ this.seconds =
+ (days * WallTime.SECONDS_PER_MINUTE * WallTime.MINUTES_PER_HOUR * WallTime.HOURS_PER_DAY)
+ + (hours * WallTime.SECONDS_PER_MINUTE * WallTime.MINUTES_PER_HOUR)
+ + (minutes * WallTime.SECONDS_PER_MINUTE)
+ + seconds;
+ }
+
+ /**
+ * Parses the two tokens into a { link Duration}.
+ *
+ * parse() is looking for a pattern where the first token is a number and the second a string
+ * of units of time (localized), either hours, minutes, or seconds. null is returned if that
+ * pattern is not located.
+ *
+ * Future expansion could include a pattern where the first token has a unit as a suffix, i.e.
+ * "3hrs" or "4m".
+ *
+ * It's possible for this call to return a Duration of zero time.
+ */
+ public static Duration? parse(string value, string unit) {
+ if (String.is_empty(value) || String.is_empty(unit))
+ return null;
+
+ if (!String.is_numeric(value))
+ return null;
+
+ int duration = int.parse(value);
+ if (duration < 0)
+ return null;
+
+ if (unit in UNIT_DAYS)
+ return new Duration(duration);
+
+ if (unit in UNIT_HOURS)
+ return new Duration(0, duration);
+
+ if (unit in UNIT_MINS)
+ return new Duration(0, 0, duration);
+
+ return null;
+ }
+
+ public override string to_string() {
+ return "%us".printf(seconds);
+ }
+}
+
+}
+
diff --git a/src/calendar/calendar-month.vala b/src/calendar/calendar-month.vala
index 5a62adc..536cad9 100644
--- a/src/calendar/calendar-month.vala
+++ b/src/calendar/calendar-month.vala
@@ -24,6 +24,8 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
public static Month NOV;
public static Month DEC;
+ private static Gee.Map<string, Month> parse_map;
+
public const int MIN = 1;
public const int MAX = 12;
public const int COUNT = MAX - MIN + 1;
@@ -78,9 +80,17 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
}
internal static void init() {
+ parse_map = new Gee.HashMap<string, Month>(String.ci_hash, String.ci_equal);
+
months = new Month[COUNT];
- for (int ctr = MIN; ctr <= MAX; ctr++)
- months[ctr - MIN] = new Month(ctr);
+ for (int ctr = MIN; ctr <= MAX; ctr++) {
+ Month month = new Month(ctr);
+ months[ctr - MIN] = month;
+
+ // build parse map of abbreviated and full name to the Month
+ parse_map.set(month.abbrev_name, month);
+ parse_map.set(month.full_name, month);
+ }
JAN = months[0];
FEB = months[1];
@@ -97,6 +107,7 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
}
internal static void terminate() {
+ parse_map = null;
months = null;
JAN = FEB = MAR = APR = MAY = JUN = JUL = AUG = SEP = OCT = NOV = DEC = null;
}
@@ -131,6 +142,16 @@ public class Month : BaseObject, Gee.Comparable<Month>, Gee.Hashable<Month> {
return for_checked(gdate.get_month());
}
+ /**
+ * Compares the supplied string with all translated { link Month} names, both { link abbrev_name}
+ * and { link full_name}.
+ *
+ * parse() is case-insensitive.
+ */
+ public static Month? parse(string str) {
+ return parse_map.get(str);
+ }
+
internal inline DateMonth to_date_month() {
return (DateMonth) value;
}
diff --git a/src/calendar/calendar-wall-time.vala b/src/calendar/calendar-wall-time.vala
index 7eecb7b..6ff682c 100644
--- a/src/calendar/calendar-wall-time.vala
+++ b/src/calendar/calendar-wall-time.vala
@@ -92,6 +92,8 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
/**
* Generate a new { link WallTime} object with the specified values.
*
+ * Note that hour must be in 24-hour time.
+ *
* Values will be clamped to create a valid time.
*/
public WallTime(int hour, int minute, int second) {
@@ -134,6 +136,75 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
}
/**
+ * Attempt to convert a string into { link WallTime}.
+ *
+ * 24-hour and 12-hour time is recognized, as are localized versions of AM and PM. If the time
+ * was "liberally" parsed (in other words, "8" is converted to 8am), the returned flag is
+ * cleared.
+ */
+ public static WallTime? parse(string str, out bool strictly_parsed) {
+ strictly_parsed = false;
+
+ string token = str.strip().casefold();
+ if (String.is_empty(token))
+ return null;
+
+ // look for meridiem tacked on to end
+ bool pm = false;
+ bool meridiem_unknown = false;
+ if (token.has_suffix(FMT_AM.casefold())) {
+ token = token.slice(0, token.length - FMT_AM.casefold().length);
+ } else if (token.has_suffix(FMT_BRIEF_AM.casefold())) {
+ token = token.slice(0, token.length - FMT_BRIEF_AM.casefold().length);
+ } else if (token.has_suffix(FMT_PM.casefold())) {
+ token = token.slice(0, token.length - FMT_PM.casefold().length);
+ pm = true;
+ } else if (token.has_suffix(FMT_BRIEF_PM.casefold())) {
+ token = token.slice(0, token.length - FMT_BRIEF_PM.casefold().length);
+ pm = true;
+ } else {
+ meridiem_unknown = true;
+ }
+
+ // remove colon (can be present for 12- or 24-hour time)
+ token = token.replace(":", "");
+ int length = token.length;
+
+ // rest of string better be numeric and under the common lengths for specifying time
+ if (!String.is_numeric(token) || length == 0 || length > 4)
+ return null;
+
+ // look for 24-hour time or a fully-detailed 12-hour time
+ if ((length == 3 || length == 4)) {
+ int h, m;
+ if (length == 3) {
+ h = int.parse(token.slice(0, 1));
+ m = int.parse(token.slice(1, 3));
+ } else {
+ h = int.parse(token.slice(0, 2));
+ m = int.parse(token.slice(2, 4));
+ }
+
+ if (!meridiem_unknown && pm)
+ h += 12;
+
+ strictly_parsed = true;
+
+ return new WallTime(h, m, 0);
+ }
+
+ // otherwise, treat as short-form 12-hour time (even if meridiem is unknown, i.e. "8" is
+ // treated as "8:00am"
+ int h = int.parse(token);
+ if (!meridiem_unknown && pm)
+ h += 12;
+
+ strictly_parsed = !meridiem_unknown;
+
+ return new WallTime(h, 0, 0);
+ }
+
+ /**
* Returns { link WallTime} adjusted before or after this one.
*
* To subtract time, use a negative value.
diff --git a/src/calendar/calendar.vala b/src/calendar/calendar.vala
index cafb6cb..112066e 100644
--- a/src/calendar/calendar.vala
+++ b/src/calendar/calendar.vala
@@ -39,6 +39,10 @@ private static unowned string FMT_12HOUR_MIN_SEC_MERIDIEM;
private static unowned string FMT_24HOUR_MIN;
private static unowned string FMT_24HOUR_MIN_SEC;
+private string[] UNIT_DAYS;
+private string[] UNIT_HOURS;
+private string[] UNIT_MINS;
+
public void init() throws Error {
if (!California.Unit.do_init(ref init_count))
return;
@@ -132,6 +136,18 @@ public void init() throws Error {
/// The 24-hour time with minutes and seconds, i.e. "17:06:31"
FMT_24HOUR_MIN_SEC = _("%d:%02d:%02d");
+ // Used by quick-add to convert a user's day unit into an internal value. Common abbreviations
+ // (without punctuation) should be included. Each word must be separated by semi-colons.
+ UNIT_DAYS = _("day;days;").casefold().split(";");
+
+ // Used by quick-add to convert a user's hours unit into an internal value. Common abbreviations
+ // (without punctuation) should be included. Each word must be separated by semi-colons.
+ UNIT_HOURS = _("hour;hours;hr;hrs").casefold().split(";");
+
+ // Used by quick-add to convert a user's minute unit into an internal value. Common abbreviations
+ // (without punctuation) should be included. Each word must be separated by semi-colons.
+ UNIT_MINS = _("minute;minutes;min;mins").casefold().split(";");
+
// return LC_MESSAGES back to proper locale and return LANGUAGE environment variable
if (messages_locale != null)
Intl.setlocale(LocaleCategory.MESSAGES, messages_locale);
@@ -142,6 +158,7 @@ public void init() throws Error {
System.preinit();
// internal initialization
+ Collection.init();
OlsonZone.init();
DayOfWeek.init();
DayOfMonth.init();
@@ -162,6 +179,9 @@ public void terminate() {
DayOfMonth.terminate();
DayOfWeek.terminate();
OlsonZone.terminate();
+ Collection.terminate();
+
+ UNIT_DAYS = UNIT_HOURS = UNIT_MINS = null;
}
}
diff --git a/src/california-resources.xml b/src/california-resources.xml
index 03bb412..9bae540 100644
--- a/src/california-resources.xml
+++ b/src/california-resources.xml
@@ -31,6 +31,9 @@
<file compressed="true">rc/google-login.ui</file>
</gresource>
<gresource prefix="/org/yorba/california">
+ <file compressed="true">rc/quick-create-event.ui</file>
+ </gresource>
+ <gresource prefix="/org/yorba/california">
<file compressed="false">rc/show-event.ui</file>
</gresource>
<gresource prefix="/org/yorba/california">
diff --git a/src/collection/collection-lookahead-stack.vala b/src/collection/collection-lookahead-stack.vala
new file mode 100644
index 0000000..88bcf24
--- /dev/null
+++ b/src/collection/collection-lookahead-stack.vala
@@ -0,0 +1,133 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Collection {
+
+/**
+ * A remove-only stack of elements that allows for marking (saving state) and restoration.
+ *
+ * To make saving and restoring as efficient as possible, additions are not possible with this
+ * collection. The stack is initialized with elements in the constructor. Thereafter, elements
+ * may only be { link pop}ped and elements added back via { link restore}.
+ */
+
+public class LookaheadStack<G> : BaseObject {
+ /**
+ * Returns true if no elements are in the queue.
+ */
+ public bool is_empty { get { return stack.is_empty; } }
+
+ /**
+ * Returns the number of elements remaining in the stack.
+ */
+ public int size { get { return stack.size; } }
+
+ /**
+ * Returns number of saved markpoints.
+ *
+ * @see mark
+ */
+ public int markpoint_count { get { return markpoints.size + (markpoint != null ? 1 : 0); } }
+
+ /**
+ * Returns the current element at the top of the stack.
+ */
+ public G? top { owned get { return stack.peek_head(); } }
+
+ private Gee.Deque<G> stack;
+ private Gee.Deque<Gee.Deque<G>>? markpoints;
+ private Gee.Deque<G>? markpoint = null;
+
+ public LookaheadStack(Gee.Collection<G> init) {
+ // must be initialized here; see
+ // https://bugzilla.gnome.org/show_bug.cgi?id=523767
+ stack = new Gee.LinkedList<G>();
+ stack.add_all(init);
+
+ markpoints = new Gee.LinkedList<Gee.Deque<G>>();
+ }
+
+ /**
+ * Returns null if empty.
+ */
+ public G? pop() {
+ if (stack.is_empty)
+ return null;
+
+ G element = stack.poll_head();
+
+ // if markpoint set, save element for later
+ if (markpoint != null)
+ markpoint.offer_head(element);
+
+ return element;
+ }
+
+ /**
+ * Marks the state of the stack so it can be restored with { link restore}.
+ *
+ * Multiple markpoints can be made, each requiring a matching { link restore} to return to the
+ * state.
+ */
+ public void mark() {
+ if (markpoint != null)
+ markpoints.offer_head(markpoint);
+
+ markpoint = new Gee.LinkedList<G>();
+ }
+
+ /**
+ * Restores the state of the stack to the point when the last markpoint was made.
+ *
+ * This does nothing if { link mark} was not first called.
+ */
+ public void restore() {
+ if (markpoint != null) {
+ // restore elements as stored in marked queue
+ while (!markpoint.is_empty)
+ stack.offer_head(markpoint.poll_head());
+ }
+
+ // pop last marked state, if any, as the current marked state
+ pop_markpoint();
+ }
+
+ /**
+ * Drops the last markpoint, if any.
+ *
+ * This is functionally equivalent to { link restore}, but the current markpoint elements are
+ * not added back to the stack. Prior markpoints remain.
+ *
+ * @see mark
+ */
+ public void unmark() {
+ pop_markpoint();
+ }
+
+ /**
+ * Drops all markpoints.
+ *
+ * @see mark
+ */
+ public void clear_markpoints() {
+ markpoint = null;
+ markpoints.clear();
+ }
+
+ private void pop_markpoint() {
+ if (!markpoints.is_empty)
+ markpoint = markpoints.poll_head();
+ else
+ markpoint = null;
+ }
+
+ public override string to_string() {
+ return "LookaheadStack (%d elements)".printf(size);
+ }
+}
+
+}
+
diff --git a/src/collection/collection.vala b/src/collection/collection.vala
new file mode 100644
index 0000000..ba28f21
--- /dev/null
+++ b/src/collection/collection.vala
@@ -0,0 +1,22 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Collection {
+
+private int init_count = 0;
+
+public void init() throws Error {
+ if (!Unit.do_init(ref init_count))
+ return;
+}
+
+public void terminate() {
+ if (!Unit.do_terminate(ref init_count))
+ return;
+}
+
+}
+
diff --git a/src/component/component-details-parser.vala b/src/component/component-details-parser.vala
new file mode 100644
index 0000000..2811fb5
--- /dev/null
+++ b/src/component/component-details-parser.vala
@@ -0,0 +1,422 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Component {
+
+/**
+ * Parse the details of a user-entered string into an { link Event}.
+ *
+ * DetailsParser makes no claims of natural language parsing or interpretation. It merely
+ * looks for keywords and patterns within the tokenized stream and guesses what Event details
+ * they refer to.
+ *
+ * The fields the parser attempts to fill-in are { link Event.date_span} (or
+ * { link Event.exact_time_span}, { link Event.summary}, and { link Event.location}. Other fields
+ * may be considered in the future.
+ */
+
+public class DetailsParser : BaseObject {
+ private class Token : BaseObject, Gee.Hashable<Token> {
+ public string original;
+ public string casefolded;
+
+ public Token(string token) {
+ original = token;
+ casefolded = token.casefold();
+ }
+
+ public bool equal_to(Token other) {
+ return (this != other) ? original == other.original : true;
+ }
+
+ public uint hash() {
+ return original.hash();
+ }
+
+ public override string to_string() {
+ return original;
+ }
+ }
+
+ /**
+ * The original string of text generating the { link event}.
+ *
+ * If null is passed to constructor, this will be the empty string.
+ */
+ public string details { get; private set; }
+
+ /**
+ * The generated { link Event}.
+ */
+ public Component.Event event { get; private set; default = new Component.Event.blank(); }
+
+ private Collection.LookaheadStack<Token> stack;
+ private StringBuilder summary = new StringBuilder();
+ private StringBuilder location = new StringBuilder();
+ private Calendar.WallTime? start_time = null;
+ private bool start_time_strict = true;
+ private Calendar.WallTime? end_time = null;
+ private bool end_time_strict = true;
+ private Calendar.Date? start_date = null;
+ private Calendar.Date? end_date = null;
+ private Calendar.Duration? duration = null;
+ private bool adding_location = false;
+
+ /**
+ * Parses a user-entered string of { link Event} details into an Event.
+ *
+ * This always generates an Event, but very little in it may be available. Its backup case
+ * is to use the details string as a summary and leave all other fields empty. The caller
+ * should complete the other fields to generate a valid VEVENT.
+ *
+ * If the details string is empty, a blank Event is generated.
+ */
+ public DetailsParser(string? details, Backing.CalendarSource? calendar_source) {
+ event.calendar_source = calendar_source;
+ this.details = details ?? "";
+
+ // tokenize the string and arrange as a stack for the parser
+ string[] tokenized = String.reduce_whitespace(this.details).split(" ");
+ Gee.LinkedList<Token> list = new Gee.LinkedList<Token>();
+ foreach (string token in tokenized)
+ list.add(new Token(token));
+
+ stack = new Collection.LookaheadStack<Token>(list);
+
+ parse();
+ }
+
+ private void parse() {
+ for (;;) {
+ Token? token = stack.pop();
+ if (token == null)
+ break;
+
+ // mark the stack branch for each parsing branch so if it fails the state can be
+ // restored and the next branch's read-ahead gets a chance; don't restore on success
+ // as each method is responsible for consuming all tokens it needs to complete its work
+ // and no more.
+
+ // look for prepositions indicating time or location follows; this depends on
+ // translated strings, obviously, and does not apply to all languages, but we do what
+ // we can here.
+
+ // A time preposition suggests a specific point of time is being described in the
+ // following token. Don't require strict parsing of time ("8" -> "8am") because the
+ // preposition offers a clue that a time is being specified.
+ stack.mark();
+ if (token.casefolded in TIME_PREPOSITIONS && parse_time(stack.pop(), false))
+ continue;
+ stack.restore();
+
+ // A duration preposition suggests a specific amount of positive time is being described
+ // by the next two tokens.
+ stack.mark();
+ if (token.casefolded in DURATION_PREPOSITIONS && parse_duration(stack.pop(), stack.pop()))
+ continue;
+ stack.restore();
+
+ // A delay preposition suggests a specific point of time is being described by a
+ // positive duration of time after the current time by the next two tokens.
+ stack.mark();
+ if (token.casefolded in DELAY_PREPOSITIONS && parse_delay(stack.pop(), stack.pop()))
+ continue;
+ stack.restore();
+
+ // only look for location prepositions if not already adding text to the location field
+ if (!adding_location && token.casefolded in LOCATION_PREPOSITIONS) {
+ // add current token (the preposition) to summary but not location (because location
+ // tokens are added to summary, i.e. "dinner at John's" yields "John's" for location
+ // and "dinner at John's" for summary)
+ add_text(token);
+
+ // now adding to both summary and location
+ adding_location = true;
+
+ continue;
+ }
+
+ // if this token and next describe a duration, use them
+ stack.mark();
+ if (parse_duration(token, stack.pop()))
+ continue;
+ stack.restore();
+
+ // attempt to (strictly) parse into wall-clock time
+ stack.mark();
+ if (parse_time(token, true))
+ continue;
+ stack.restore();
+
+ // append original to current text field(s) as fallback
+ add_text(token);
+ }
+
+ //
+ // assemble accumulated information in an Event, using defaults wherever appropriate
+ //
+
+ // if no start time or date but a duration was specified, assume start is now and use
+ // duration for end time
+ if (start_time == null && start_date == null && duration != null) {
+ start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now);
+ end_time = new Calendar.WallTime.from_exact_time(
+ Calendar.System.now.adjust_time((int) duration.minutes, Calendar.TimeUnit.MINUTE));
+ duration = null;
+ }
+
+ // if a start time was described but not end time, use a 1 hour duration default
+ bool midnight_crossed = false;
+ if (start_time != null && end_time == null) {
+ if (duration != null) {
+ end_time = start_time.adjust((int) duration.minutes, Calendar.TimeUnit.MINUTE,
+ out midnight_crossed);
+ } else {
+ end_time = start_time.adjust(1, Calendar.TimeUnit.HOUR, out midnight_crossed);
+ }
+ }
+
+ // if no start date was described but a start time was, assume for today
+ if (start_date == null && start_time != null)
+ start_date = Calendar.System.today;
+
+ // if no end date was describe, assume ends today as well (unless midnight was crossed
+ // due to duration)
+ if (start_date != null && end_date == null)
+ end_date = midnight_crossed ? start_date.adjust(1, Calendar.DateUnit.DAY) : start_date;
+
+ // Event start/end time, if specified
+ if (start_time != null && end_time != null) {
+ assert(start_date != null);
+ assert(end_date != null);
+
+ event.set_event_exact_time_span(new Calendar.ExactTimeSpan(
+ new Calendar.ExactTime(Calendar.System.timezone, start_date, start_time),
+ new Calendar.ExactTime(Calendar.System.timezone, end_date, end_time)
+ ));
+ } else if (start_date != null && end_date != null) {
+ event.set_event_date_span(new Calendar.DateSpan(start_date, end_date));
+ }
+
+ // other event details
+ if (!String.is_empty(summary.str))
+ event.summary = summary.str;
+
+ if (!String.is_empty(location.str))
+ event.location = location.str;
+
+ // store full detail text in the event description for user and for debugging
+ event.description = details;
+ }
+
+ private bool parse_time(Token? specifier, bool strict) {
+ if (specifier == null)
+ return false;
+
+ // look for day/month specifiers, in any order
+ stack.mark();
+ {
+ Token? second = stack.pop();
+ if (second != null) {
+ Calendar.Date? date = parse_day_month(specifier, second);
+ if (date == null)
+ date = parse_day_month(second, specifier);
+
+ if (date != null && add_date(date))
+ return true;
+ }
+ }
+ stack.restore();
+
+ // look for day/month/year specifiers
+ stack.mark();
+ {
+ Token? second = stack.pop();
+ Token? third = stack.pop();
+ if (second != null && third != null) {
+ // try d/m/y followed by m/d/y ... every other combination seems overkill
+ Calendar.Date? date = parse_day_month_year(specifier, second, third);
+ if (date == null)
+ date = parse_day_month_year(second, specifier, third);
+
+ if (date != null && add_date(date))
+ return true;
+ }
+ }
+ stack.restore();
+
+ // parse single specifier looking for date first, then time
+ Calendar.Date? date = parse_relative_date(specifier);
+ if (date != null && add_date(date))
+ return true;
+
+ bool strictly_parsed;
+ Calendar.WallTime? wall_time = Calendar.WallTime.parse(specifier.casefolded,
+ out strictly_parsed);
+ if (wall_time != null && !strictly_parsed && strict)
+ return false;
+
+ return (wall_time != null) ? add_wall_time(wall_time, strictly_parsed) : false;
+ }
+
+ // Add a duration to the event if not already specified and an end time has not already been
+ // specified
+ private bool parse_duration(Token? amount, Token? unit) {
+ if (amount == null || unit == null)
+ return false;
+
+ if (end_time != null || duration != null)
+ return false;
+
+ duration = Calendar.Duration.parse(amount.casefolded, unit.casefolded);
+
+ return duration != null;
+ }
+
+ private bool parse_delay(Token? amount, Token? unit) {
+ if (amount == null || unit == null)
+ return false;
+
+ // Since delay is a way of specifying the start time, don't add if already known
+ if (start_time != null)
+ return false;
+
+ Calendar.Duration? delay = Calendar.Duration.parse(amount.casefolded, unit.casefolded);
+ if (delay == null)
+ return false;
+
+ start_time = new Calendar.WallTime.from_exact_time(
+ Calendar.System.now.adjust_time((int) delay.minutes, Calendar.TimeUnit.MINUTE));
+
+ return true;
+ }
+
+ // Adds the text to the summary and location field, if adding_location is set
+ private void add_text(Token token) {
+ // always add to summary
+ add_to_builder(summary, token);
+
+ // add to location if in that mode
+ if (adding_location)
+ add_to_builder(location, token);
+ }
+
+ private static void add_to_builder(StringBuilder builder, Token token) {
+ // keep everything space-delimited
+ if (!String.is_empty(builder.str))
+ builder.append_unichar(' ');
+
+ builder.append(token.original);
+ }
+
+ // Adds a time to the event, start time first, then end time, dropping thereafter
+ private bool add_wall_time(Calendar.WallTime wall_time, bool strictly_parsed) {
+ if (start_time == null) {
+ start_time = wall_time;
+ start_time_strict = strictly_parsed;
+ } else if (end_time == null) {
+ end_time = wall_time;
+ end_time_strict = strictly_parsed;
+ } else {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Parses a potential date specifier into a calendar date relative to today
+ private Calendar.Date? parse_relative_date(Token token) {
+ // attempt to parse into common words for relative dates
+ if (token.casefolded == TODAY)
+ return Calendar.System.today;
+ else if (token.casefolded == TOMORROW)
+ return Calendar.System.today.next();
+ else if (token.casefolded == YESTERDAY)
+ return Calendar.System.today.previous();
+
+ // attempt to parse into day of the week
+ Calendar.DayOfWeek? dow = Calendar.DayOfWeek.parse(token.casefolded);
+ if (dow == null)
+ return null;
+
+ // find a Date for day of the week ... starting today, move forward up to one
+ // week
+ Calendar.Date upcoming = Calendar.System.today;
+ Calendar.Date next_week = upcoming.adjust(1, Calendar.DateUnit.WEEK);
+ do {
+ if (upcoming.day_of_week.equal_to(dow))
+ return upcoming;
+
+ upcoming = upcoming.next();
+ } while (!upcoming.equal_to(next_week));
+
+ return null;
+ }
+
+ // Parses potential date specifiers into a specific calendar date
+ private Calendar.Date? parse_day_month(Token day, Token mon, Calendar.Year? year = null) {
+ // strip ordinal suffix if present
+ string day_number = day.casefolded;
+ foreach (string suffix in ORDINAL_SUFFIXES) {
+ if (!String.is_empty(suffix) && day_number.has_suffix(suffix)) {
+ day_number = day_number.slice(0, day_number.length - suffix.length);
+
+ break;
+ }
+ }
+
+ if (!String.is_numeric(day_number))
+ return null;
+
+ Calendar.Month? month = Calendar.Month.parse(mon.casefolded);
+ if (month == null)
+ return null;
+
+ if (year == null)
+ year = Calendar.System.today.year;
+
+ try {
+ return new Calendar.Date(Calendar.DayOfMonth.for(int.parse(day.casefolded)),
+ month, year);
+ } catch (CalendarError calerr) {
+ // probably an out-of-bounds day of month
+ return null;
+ }
+ }
+
+ // Parses potential date specifiers into a specific calendar date
+ private Calendar.Date? parse_day_month_year(Token day, Token mon, Token yr) {
+ if (!String.is_numeric(yr.casefolded))
+ return null;
+
+ // a *sane* year
+ int year = int.parse(yr.casefolded);
+ int current_year = Calendar.System.today.year.value;
+ if (year < (current_year - 1) || (year > current_year + 10))
+ return null;
+
+ return parse_day_month(day, mon, new Calendar.Year(year));
+ }
+
+ // Adds a date to the event, start time first, then end time, dropping dates thereafter
+ private bool add_date(Calendar.Date date) {
+ if (start_date == null)
+ start_date = date;
+ else if (end_date == null)
+ end_date = date;
+ else
+ return false;
+
+ return true;
+ }
+
+ public override string to_string() {
+ return "DetailsParser:%s".printf(event.to_string());
+ }
+}
+
+}
+
diff --git a/src/component/component-event.vala b/src/component/component-event.vala
index 08f0e69..bd68069 100644
--- a/src/component/component-event.vala
+++ b/src/component/component-event.vala
@@ -18,6 +18,7 @@ public class Event : Instance, Gee.Comparable<Event> {
public const string PROP_EXACT_TIME_SPAN = "exact-time-span";
public const string PROP_DATE_SPAN = "date-span";
public const string PROP_IS_ALL_DAY = "is-all-day";
+ public const string PROP_LOCATION = "location";
public const string PROP_STATUS = "status";
public enum Status {
@@ -62,6 +63,11 @@ public class Event : Instance, Gee.Comparable<Event> {
public bool is_all_day { get; private set; }
/**
+ * Location of an { link Event}.
+ */
+ public string? location { get; set; default = null; }
+
+ /**
* Status (confirmation) of an { link Event}.
*/
public Status status { get; set; default = Status.CONFIRMED; }
@@ -118,6 +124,8 @@ public class Event : Instance, Gee.Comparable<Event> {
// need to set this here because on_notify() doesn't update inside full update
is_all_day = (date_span != null);
+ location = ical_component.get_location();
+
switch (ical_component.get_status()) {
case iCal.icalproperty_status.TENTATIVE:
status = Status.TENTATIVE;
@@ -175,6 +183,10 @@ public class Event : Instance, Gee.Comparable<Event> {
is_all_day = (date_span != null);
break;
+ case PROP_LOCATION:
+ ical_component.set_location(location);
+ break;
+
case PROP_STATUS:
switch(status) {
case Status.TENTATIVE:
@@ -239,6 +251,13 @@ public class Event : Instance, Gee.Comparable<Event> {
}
/**
+ * @inheritDoc
+ */
+ public override bool is_valid() {
+ return base.is_valid() && (date_span != null || exact_time_span != null);
+ }
+
+ /**
* Compares an { link Event} to another and returns which is chronologically first.
*
* The method attempts to compare DATE-TIMEs first, then DATEs, coercing a DATE-TIME into a DATE
diff --git a/src/component/component-instance.vala b/src/component/component-instance.vala
index 037f851..8c56076 100644
--- a/src/component/component-instance.vala
+++ b/src/component/component-instance.vala
@@ -352,6 +352,16 @@ public abstract class Instance : BaseObject, Gee.Hashable<Instance> {
}
/**
+ * Returns true if all the fields necessary for creating/updating the { link Instance} are
+ * present with proper values.
+ *
+ * The presence of { link calendar_source} is not necessary to deem an Instance valid.
+ */
+ public virtual bool is_valid() {
+ return dtstamp != null;
+ }
+
+ /**
* Equality is defined as { link Component.Instance}s having the same UID.
*
* Subclasses should override this and { link hash} if more definite equality is necessary.
diff --git a/src/component/component.vala b/src/component/component.vala
index 21eb074..850e897 100644
--- a/src/component/component.vala
+++ b/src/component/component.vala
@@ -17,19 +17,82 @@ namespace California.Component {
private int init_count = 0;
+private string TODAY;
+private string TOMORROW;
+private string YESTERDAY;
+private string[] TIME_PREPOSITIONS;
+private string[] LOCATION_PREPOSITIONS;
+private string[] DURATION_PREPOSITIONS;
+private string[] DELAY_PREPOSITIONS;
+private string[] ORDINAL_SUFFIXES;
+
public void init() throws Error {
if (!Unit.do_init(ref init_count))
return;
// external unit init
+ Collection.init();
Calendar.init();
+
+ // Used by quick-add to indicate the user wants to create an event for today.
+ TODAY = _("today").casefold();
+
+ // Used by quick-add to indicate the user wants to create an event for tomorrow.
+ TOMORROW = _("tomorrow").casefold();
+
+ // Used by quick-add to indicate the user wants to create an event for yesterday.
+ YESTERDAY = _("yesterday").casefold();
+
+ // Used by quick-add to determine if the word is a time-based preposition (indicating a
+ // specific time, not a duration). Each word must be separated by semi-colons.
+ // It's allowable for some or all of these words to
+ // be duplicated in the location prepositions list (elsewhere) but not another time list.
+ // The list can be empty, but that will limit the parser.
+ // Examples: "at 9am", "from 10pm to 11:30pm", "on monday"
+ TIME_PREPOSITIONS = _("at;from;to;on;").casefold().split(";");
+
+ // Used by quick-add to determine if the word is a duration-based preposition (indicating a
+ // a duration, not a specific time). Each word must be separated by semi-colons.
+ // It's allowable for some or all of these words to
+ // be duplicated in the location prepositions list (elsewhere) but not another time list.
+ // The list can be empty, but that will limit the parser.
+ // Examples: "for 3 hours", "for 90 minutes"
+ DURATION_PREPOSITIONS = _("for;").casefold().split(";");
+
+ // Used by quick-add to determine if the word is a delay preposition (indicating a specific
+ // time from the current moment). Each word must be separated by semi-colons.
+ // It's allowable for some or all of these words to
+ // be duplicated in the location prepositions list (elsewhere) but not another time list.
+ // The list can be empty, but that will limit the parser.
+ // Example: "in 3 hours" (meaning 3 hours from now)
+ DELAY_PREPOSITIONS = _("in;").casefold().split(";");
+
+ // Used by quick-add to determine if the word is a location-based preposition (indicating a
+ // specific place). Each word must be separated by semi-colons.
+ // It's allowable for some or all of these words to be duplicated in
+ // the time prepositions list (elsewhere). The list can be empty, but that will limit the
+ // parser.
+ // Example: "at supermarket", "at Eiffel Tower"
+ LOCATION_PREPOSITIONS = _("at;").casefold().split(";");
+
+ // Used by quick-add to strip date numbers of common ordinal suffices. Each word must be
+ // separated by semi-colons.
+ // The list can be empty, but that will limit the parser if your language supports ordinal
+ // suffixes.
+ // Example: "1st", "2nd", "3rd", "4th"
+ ORDINAL_SUFFIXES = _("st;nd;rd;th").casefold().split(";");
}
public void terminate() {
if (!Unit.do_terminate(ref init_count))
return;
+ TIME_PREPOSITIONS = LOCATION_PREPOSITIONS = DURATION_PREPOSITIONS = ORDINAL_SUFFIXES =
+ DELAY_PREPOSITIONS =null;
+ TODAY = TOMORROW = YESTERDAY = null;
+
Calendar.terminate();
+ Collection.terminate();
}
}
diff --git a/src/host/host-create-update-event.vala b/src/host/host-create-update-event.vala
index 4e4d3e0..1e1fec8 100644
--- a/src/host/host-create-update-event.vala
+++ b/src/host/host-create-update-event.vala
@@ -93,6 +93,12 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
init();
}
+ public CreateUpdateEvent.finish(Component.Event event) {
+ this.event = event;
+
+ init();
+ }
+
private void init() {
if (event.summary != null)
summary_entry.text = event.summary;
@@ -111,14 +117,18 @@ public class CreateUpdateEvent : Gtk.Grid, Toolkit.Card {
event.exact_time_span.start_exact_time.to_timezone(Calendar.Timezone.local));
initial_end_time = new Calendar.WallTime.from_exact_time(
event.exact_time_span.end_exact_time.to_timezone(Calendar.Timezone.local));
- } else {
- assert(event.date_span != null);
-
+ } else if (event.date_span != null) {
all_day_toggle.active = true;
selected_date_span = event.date_span;
initial_start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now);
initial_end_time = new Calendar.WallTime.from_exact_time(
Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR));
+ } else {
+ all_day_toggle.active = false;
+ selected_date_span = new Calendar.DateSpan(Calendar.System.today, Calendar.System.today);
+ initial_start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now);
+ initial_end_time = new Calendar.WallTime.from_exact_time(
+ Calendar.System.now.adjust_time(1, Calendar.TimeUnit.HOUR));
}
// initialize start and end time (as in, wall clock time)
diff --git a/src/host/host-main-window.vala b/src/host/host-main-window.vala
index 1706122..f4f666d 100644
--- a/src/host/host-main-window.vala
+++ b/src/host/host-main-window.vala
@@ -13,8 +13,8 @@ namespace California.Host {
public class MainWindow : Gtk.ApplicationWindow {
private const string PROP_FIRST_OF_WEEK = "first-of-week";
- private const string ACTION_NEW_EVENT = "win.new-event";
- private const string ACCEL_NEW_EVENT = "<Primary>n";
+ private const string ACTION_QUICK_CREATE_EVENT = "win.quick-create-event";
+ private const string ACCEL_QUICK_CREATE_EVENT = "<Primary>n";
private const string ACTION_JUMP_TO_TODAY = "win.jump-to-today";
private const string ACCEL_JUMP_TO_TODAY = "<Primary>t";
@@ -26,7 +26,7 @@ public class MainWindow : Gtk.ApplicationWindow {
private const string ACCEL_PREVIOUS = "<Alt>Left";
private static const ActionEntry[] action_entries = {
- { "new-event", on_new_event },
+ { "quick-create-event", on_quick_create_event },
{ "jump-to-today", on_jump_to_today },
{ "next", on_next },
{ "previous", on_previous }
@@ -37,6 +37,7 @@ public class MainWindow : Gtk.ApplicationWindow {
private View.Controllable current_view;
private View.Month.Controllable month_view = new View.Month.Controllable();
+ private Gtk.Button quick_add_button;
public MainWindow(Application app) {
Object (application: app);
@@ -49,7 +50,7 @@ public class MainWindow : Gtk.ApplicationWindow {
bool rtl = get_direction() == Gtk.TextDirection.RTL;
add_action_entries(action_entries, this);
- Application.instance.add_accelerator(ACCEL_NEW_EVENT, ACTION_NEW_EVENT, null);
+ Application.instance.add_accelerator(ACCEL_QUICK_CREATE_EVENT, ACTION_QUICK_CREATE_EVENT, null);
Application.instance.add_accelerator(ACCEL_JUMP_TO_TODAY, ACTION_JUMP_TO_TODAY, null);
Application.instance.add_accelerator(rtl ? ACCEL_PREVIOUS : ACCEL_NEXT, ACTION_NEXT, null);
Application.instance.add_accelerator(rtl ? ACCEL_NEXT : ACCEL_PREVIOUS, ACTION_PREVIOUS, null);
@@ -91,9 +92,9 @@ public class MainWindow : Gtk.ApplicationWindow {
headerbar.pack_start(today);
headerbar.pack_start(nav_buttons);
- Gtk.Button new_event = new Gtk.Button.from_icon_name("list-add-symbolic", Gtk.IconSize.MENU);
- new_event.tooltip_text = _("Create a new event (Ctrl+N)");
- new_event.set_action_name(ACTION_NEW_EVENT);
+ quick_add_button = new Gtk.Button.from_icon_name("list-add-symbolic", Gtk.IconSize.MENU);
+ quick_add_button.tooltip_text = _("Quick add event (Ctrl+N)");
+ quick_add_button.set_action_name(ACTION_QUICK_CREATE_EVENT);
Gtk.Button calendars = new Gtk.Button.from_icon_name("x-office-calendar-symbolic",
Gtk.IconSize.MENU);
@@ -101,7 +102,7 @@ public class MainWindow : Gtk.ApplicationWindow {
calendars.set_action_name(Application.ACTION_CALENDAR_MANAGER);
// pack right-side of window
- headerbar.pack_end(new_event);
+ headerbar.pack_end(quick_add_button);
headerbar.pack_end(calendars);
Gtk.Box layout = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
@@ -137,12 +138,20 @@ public class MainWindow : Gtk.ApplicationWindow {
deck_window.destroy();
}
- private void on_new_event() {
- // create all-day event for today
- Calendar.DateSpan initial = new Calendar.DateSpan(Calendar.System.today, Calendar.System.today);
+ private void on_quick_create_event() {
+ QuickCreateEvent quick_create = new QuickCreateEvent();
+
+ quick_create.completed.connect(() => {
+ if (quick_create.parsed_event == null)
+ return;
+
+ if (quick_create.parsed_event.is_valid())
+ create_event_async.begin(quick_create.parsed_event, null);
+ else
+ create_event(null, null, quick_create.parsed_event, true, quick_add_button, null);
+ });
- // revert to today's date and use the widget for the popover
- create_event(null, initial, null, current_view.today(), null);
+ show_deck(quick_add_button, null, iterate<Toolkit.Card>(quick_create).to_array_list());
}
private void on_jump_to_today() {
@@ -159,16 +168,16 @@ public class MainWindow : Gtk.ApplicationWindow {
private void on_request_create_timed_event(Calendar.ExactTimeSpan initial, Gtk.Widget relative_to,
Gdk.Point? for_location) {
- create_event(initial, null, null, relative_to, for_location);
+ create_event(initial, null, null, false, relative_to, for_location);
}
private void on_request_create_all_day_event(Calendar.DateSpan initial, Gtk.Widget relative_to,
Gdk.Point? for_location) {
- create_event(null, initial, null, relative_to, for_location);
+ create_event(null, initial, null, false, relative_to, for_location);
}
private void create_event(Calendar.ExactTimeSpan? time_span, Calendar.DateSpan? date_span,
- Component.Event? existing, Gtk.Widget relative_to, Gdk.Point? for_location) {
+ Component.Event? existing, bool create_existing, Gtk.Widget relative_to, Gdk.Point? for_location) {
assert(time_span != null || date_span != null || existing != null);
CreateUpdateEvent create_update_event;
@@ -176,6 +185,8 @@ public class MainWindow : Gtk.ApplicationWindow {
create_update_event = new CreateUpdateEvent(time_span);
else if (date_span != null)
create_update_event = new CreateUpdateEvent.all_day(date_span);
+ else if (create_existing)
+ create_update_event = new CreateUpdateEvent.finish(existing);
else
create_update_event = new CreateUpdateEvent.update(existing);
@@ -222,7 +233,7 @@ public class MainWindow : Gtk.ApplicationWindow {
});
show_event.update_event.connect(() => {
- create_event(null, null, event, relative_to, for_location);
+ create_event(null, null, event, false, relative_to, for_location);
});
show_deck(relative_to, for_location, iterate<Toolkit.Card>(show_event).to_array_list());
diff --git a/src/host/host-quick-create-event.vala b/src/host/host-quick-create-event.vala
new file mode 100644
index 0000000..09c0273
--- /dev/null
+++ b/src/host/host-quick-create-event.vala
@@ -0,0 +1,78 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Host {
+
+[GtkTemplate (ui = "/org/yorba/california/rc/quick-create-event.ui")]
+public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
+ public const string ID = "QuickCreateEvent";
+
+ public string card_id { get { return ID; } }
+
+ public string? title { get { return null; } }
+
+ public Component.Event? parsed_event { get; private set; default = null; }
+
+ public Gtk.Widget? default_widget { get { return create_button; } }
+
+ public Gtk.Widget? initial_focus { get { return details_entry; } }
+
+ [GtkChild]
+ private Gtk.Entry details_entry;
+
+ [GtkChild]
+ private Gtk.ComboBoxText calendar_combo_box;
+
+ [GtkChild]
+ private Gtk.Button create_button;
+
+ private Toolkit.ComboBoxTextModel<Backing.CalendarSource> model;
+
+ public QuickCreateEvent() {
+ // create and initialize combo box model
+ model = new Toolkit.ComboBoxTextModel<Backing.CalendarSource>(calendar_combo_box,
+ (cal) => cal.title);
+ foreach (Backing.CalendarSource calendar_source in
+ Backing.Manager.instance.get_sources_of_type<Backing.CalendarSource>()) {
+ if (calendar_source.visible)
+ model.add(calendar_source);
+ }
+
+ // make first item active
+ calendar_combo_box.active = 0;
+
+ Properties.value_to_bool(details_entry, "text-length", create_button, "sensitive",
+ BindingFlags.SYNC_CREATE, () => !String.is_empty(details_entry.text));
+ }
+
+ public void jumped_to(Toolkit.Card? from, Value? message) {
+ }
+
+ [GtkCallback]
+ private void on_details_entry_icon_release(Gtk.Entry entry, Gtk.EntryIconPosition icon,
+ Gdk.Event event) {
+ // check for clear icon being pressed
+ if (icon == Gtk.EntryIconPosition.SECONDARY)
+ details_entry.text = "";
+ }
+
+ [GtkCallback]
+ private void on_cancel_button_clicked() {
+ dismissed(true);
+ }
+
+ [GtkCallback]
+ private void on_create_button_clicked() {
+ Component.DetailsParser parser = new Component.DetailsParser(details_entry.text, model.active);
+ parsed_event = parser.event;
+
+ completed();
+ dismissed(true);
+ }
+}
+
+}
+
diff --git a/src/rc/quick-create-event.ui b/src/rc/quick-create-event.ui
new file mode 100644
index 0000000..431e8a8
--- /dev/null
+++ b/src/rc/quick-create-event.ui
@@ -0,0 +1,165 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.16.1 -->
+<interface>
+ <requires lib="gtk+" version="3.10"/>
+ <template class="CaliforniaHostQuickCreateEvent" parent="GtkGrid">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="row_spacing">8</property>
+ <child>
+ <object class="GtkLabel" id="title_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">_Quick add event:</property>
+ <property name="use_underline">True</property>
+ <property name="mnemonic_widget">details_entry</property>
+ <attributes>
+ <attribute name="weight" value="bold"/>
+ </attributes>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">0</property>
+ <property name="width">1</property>
+ <property name="height">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButtonBox" id="buttonbox1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="margin_top">8</property>
+ <property name="spacing">8</property>
+ <property name="homogeneous">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="cancel_button">
+ <property name="label" translatable="yes">C_ancel</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_underline">True</property>
+ <signal name="clicked" handler="on_cancel_button_clicked"
object="CaliforniaHostQuickCreateEvent" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="create_button">
+ <property name="label" translatable="yes">_Create</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="can_default">True</property>
+ <property name="has_default">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_underline">True</property>
+ <signal name="clicked" handler="on_create_button_clicked"
object="CaliforniaHostQuickCreateEvent" swapped="no"/>
+ <style>
+ <class name="suggested-action"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">3</property>
+ <property name="width">1</property>
+ <property name="height">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="box1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <object class="GtkEntry" id="details_entry">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hexpand">True</property>
+ <property name="activates_default">True</property>
+ <property name="width_chars">40</property>
+ <property name="secondary_icon_name">edit-delete-symbolic</property>
+ <signal name="icon-release" handler="on_details_entry_icon_release"
object="CaliforniaHostQuickCreateEvent" swapped="no"/>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="example_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes"><small><i>Example: Dinner at Tadich Grill
7:30pm tomorrow</i></small></property>
+ <property name="use_markup">True</property>
+ <property name="ellipsize">start</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">1</property>
+ <property name="width">1</property>
+ <property name="height">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="box2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">8</property>
+ <child>
+ <object class="GtkLabel" id="calendar_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Ca_lendar:</property>
+ <property name="use_underline">True</property>
+ <property name="mnemonic_widget">calendar_combo_box</property>
+ <property name="ellipsize">start</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkComboBoxText" id="calendar_combo_box">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">2</property>
+ <property name="width">1</property>
+ <property name="height">1</property>
+ </packing>
+ </child>
+ </template>
+</interface>
diff --git a/src/tests/tests-quick-add.vala b/src/tests/tests-quick-add.vala
new file mode 100644
index 0000000..c65302a
--- /dev/null
+++ b/src/tests/tests-quick-add.vala
@@ -0,0 +1,143 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Tests {
+
+private class QuickAdd : UnitTest.Harness {
+ public QuickAdd() {
+ add_case("summary", summary);
+ add_case("summary-location", summary_location);
+ add_case("with-12hr-time", with_12hr_time);
+ add_case("with-24hr-time", with_24hr_time);
+ add_case("with-day-of-week", with_day_of_week);
+ add_case("with-delay", with_delay);
+ add_case("with-duration", with_duration);
+ add_case("with-delay-and-duration", with_delay_and_duration);
+ add_case("indeterminate-time", indeterminate_time);
+ add_case("dialog-example", dialog_example);
+ }
+
+ protected override void setup() throws Error {
+ Component.init();
+ Calendar.init();
+ }
+
+ protected override void teardown() {
+ Component.terminate();
+ Calendar.terminate();
+ }
+
+ private bool summary() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("meet with Alice", null);
+
+ return parser.event.summary == "meet with Alice"
+ && parser.event.location == null
+ && parser.event.exact_time_span == null
+ && parser.event.date_span == null;
+ }
+
+ private bool summary_location() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("meet with Alice at Bob's", null);
+
+ return parser.event.summary == "meet with Alice at Bob's"
+ && parser.event.location == "Bob's"
+ && parser.event.exact_time_span == null
+ && parser.event.date_span == null;
+ }
+
+ private bool with_12hr_time() throws Error {
+ return with_time(new Component.DetailsParser("dinner at 7pm with Alice", null));
+ }
+
+ private bool with_24hr_time() throws Error {
+ return with_time(new Component.DetailsParser("dinner at 1900 with Alice", null));
+ }
+
+ private bool with_time(Component.DetailsParser parser) {
+ Calendar.ExactTime time = new Calendar.ExactTime(
+ Calendar.System.timezone,
+ Calendar.System.today,
+ new Calendar.WallTime(19, 0, 0)
+ );
+
+ return parser.event.summary == "dinner with Alice"
+ && parser.event.location == null
+ && parser.event.exact_time_span.start_exact_time.equal_to(time)
+ && parser.event.exact_time_span.end_exact_time.equal_to(time.adjust_time(1,
Calendar.TimeUnit.HOUR));
+ }
+
+ private bool with_day_of_week() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("dinner Monday at Bob's with Alice",
null);
+
+ return parser.event.summary == "dinner at Bob's with Alice"
+ && parser.event.location == "Bob's with Alice"
+ && parser.event.date_span.start_date.day_of_week == Calendar.DayOfWeek.MON;
+ }
+
+ private bool with_delay() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("meet Alice in 3 hours", null);
+
+ Calendar.WallTime start = new Calendar.WallTime.from_exact_time(Calendar.System.now).adjust(
+ 3, Calendar.TimeUnit.HOUR, null);
+ Calendar.WallTime end = start.adjust(1, Calendar.TimeUnit.HOUR, null);
+
+ assert(parser.event.summary == "meet Alice");
+ assert(new
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.start_exact_time).equal_to(start));
+ assert(new Calendar.WallTime.from_exact_time(
+ parser.event.exact_time_span.start_exact_time).adjust(1, Calendar.TimeUnit.HOUR,
null).equal_to(end));
+
+ return true;
+ }
+
+ private bool with_duration() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("meet Alice for 2 hrs", null);
+
+ Calendar.WallTime start = new Calendar.WallTime.from_exact_time(Calendar.System.now);
+ Calendar.WallTime end = start.adjust(2, Calendar.TimeUnit.HOUR, null);
+
+ return parser.event.summary == "meet Alice"
+ && new
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.start_exact_time).equal_to(start)
+ && new
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.end_exact_time).equal_to(end);
+ }
+
+ private bool with_delay_and_duration() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("meet Alice in 3 hours for 30 min",
null);
+
+ Calendar.WallTime start = new Calendar.WallTime.from_exact_time(Calendar.System.now.adjust_time(3,
Calendar.TimeUnit.HOUR));
+ Calendar.WallTime end = start.adjust(30, Calendar.TimeUnit.MINUTE, null);
+
+ return parser.event.summary == "meet Alice"
+ && new
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.start_exact_time).equal_to(start)
+ && new
Calendar.WallTime.from_exact_time(parser.event.exact_time_span.end_exact_time).equal_to(end);
+ }
+
+ private bool indeterminate_time() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser("meet Alice 4", null);
+
+ return parser.event.summary == "meet Alice 4"
+ && parser.event.exact_time_span == null
+ && parser.event.date_span == null;
+ }
+
+ private bool dialog_example() throws Error {
+ Component.DetailsParser parser = new Component.DetailsParser(
+ "Dinner at Tadich Grill 7:30pm tomorrow", null);
+
+ Calendar.ExactTime time = new Calendar.ExactTime(
+ Calendar.System.timezone,
+ Calendar.System.today.next(),
+ new Calendar.WallTime(19, 30, 0)
+ );
+
+ return parser.event.summary == "Dinner at Tadich Grill"
+ && parser.event.location == "Tadich Grill"
+ && parser.event.exact_time_span.start_exact_time.equal_to(time)
+ && parser.event.exact_time_span.end_exact_time.equal_to(time.adjust_time(1,
Calendar.TimeUnit.HOUR));
+ }
+}
+
+}
+
diff --git a/src/tests/tests.vala b/src/tests/tests.vala
new file mode 100644
index 0000000..327609f
--- /dev/null
+++ b/src/tests/tests.vala
@@ -0,0 +1,16 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Tests {
+
+public int run(string[] args) {
+ UnitTest.Harness.register(new QuickAdd());
+
+ return UnitTest.Harness.exec_all();
+}
+
+}
+
diff --git a/src/toolkit/toolkit-combo-box-text-model.vala b/src/toolkit/toolkit-combo-box-text-model.vala
new file mode 100644
index 0000000..7bd613d
--- /dev/null
+++ b/src/toolkit/toolkit-combo-box-text-model.vala
@@ -0,0 +1,166 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.Toolkit {
+
+/**
+ * A simple model for a Gtk.ComboBoxText.
+ */
+
+public class ComboBoxTextModel<G> : BaseObject {
+ public const string PROP_ACTIVE = "active";
+
+ /**
+ * Returns a string that is the representation of the item in the Gtk.ComboBoxText.
+ */
+ public delegate string ModelPresentation<G>(G item);
+
+ public Gtk.ComboBoxText combo_box { get; private set; }
+
+ /**
+ * Synchronized to the active property of { link combo_box}.
+ */
+ public G? active { get; private set; }
+
+ private unowned ModelPresentation<G> model_presentation;
+ private unowned CompareDataFunc<G>? comparator;
+ private unowned Gee.HashDataFunc<G> hash_func;
+ private unowned Gee.EqualDataFunc<G>? equal_func;
+ private Gee.ArrayList<G> items;
+ private Gee.HashMap<G, int> indices;
+
+ public ComboBoxTextModel(Gtk.ComboBoxText combo_box, ModelPresentation<G> model_presentation,
+ CompareDataFunc<G>? comparator = null, Gee.HashDataFunc<G>? hash_func = null,
+ Gee.EqualDataFunc<G>? equal_func = null) {
+ this.combo_box = combo_box;
+ this.model_presentation = model_presentation;
+ this.comparator = comparator;
+ this.hash_func = hash_func;
+ this.equal_func = equal_func;
+
+ items = new Gee.ArrayList<G>(item_equal_func);
+ indices = new Gee.HashMap<G, int>(item_hash_func, item_equal_func);
+
+ combo_box.notify["active"].connect(on_combo_box_active);
+ }
+
+ ~ComboBoxTextModel() {
+ combo_box.notify["active"].disconnect(on_combo_box_active);
+ }
+
+ private int item_comparator(G a, G b) {
+ if (comparator != null)
+ return comparator(a, b);
+
+ return Gee.Functions.get_compare_func_for(typeof(G))(a, b);
+ }
+
+ private bool item_equal_func(G a, G b) {
+ if (equal_func != null)
+ return equal_func(a, b);
+
+ return Gee.Functions.get_equal_func_for(typeof(G))(a, b);
+ }
+
+ private uint item_hash_func(G item) {
+ if (hash_func != null)
+ return hash_func(item);
+
+ return Gee.Functions.get_hash_func_for(typeof(G))(item);
+ }
+
+ /**
+ * Add an item to the model and the Gtk.ComboBoxText.
+ *
+ * Returns false if the item was not added (already present in model).
+ */
+ public bool add(G item) {
+ if (!items.add(item))
+ return false;
+
+ // sort item according to comparator and determine its index
+ items.sort(item_comparator);
+ int added_index = items.index_of(item);
+
+ // any existing indices need to be incremented
+ foreach (G key in indices.keys) {
+ int existing_index = indices.get(key);
+ if (existing_index >= added_index)
+ indices.set(key, existing_index + 1);
+ }
+
+ // add new item to index map
+ indices.set(item, added_index);
+
+ combo_box.insert_text(added_index, model_presentation(item));
+
+ return true;
+ }
+
+ /**
+ * Removes the item from the model and the Gtk.ComboBoxText.
+ *
+ * Returns false if not removed (not present in model).
+ */
+ public bool remove(G item) {
+ if (!items.remove(item))
+ return false;
+
+ int removed_index;
+ if (!indices.unset(item, out removed_index))
+ return false;
+
+ foreach (G key in indices.keys) {
+ int existing_index = indices.get(key);
+ assert(existing_index != removed_index);
+
+ if (existing_index > removed_index)
+ indices.set(key, existing_index - 1);
+ }
+
+ combo_box.remove(removed_index);
+
+ return true;
+ }
+
+ /**
+ * Makes the item active in the Gtk.ComboBoxText.
+ *
+ * Returns true if the item is present in the model, whether or not it's already active.
+ */
+ public bool set_item_active(G item) {
+ if (!indices.has_key(item))
+ return false;
+
+ combo_box.active = indices.get(item);
+
+ return true;
+ }
+
+ /**
+ * Returns the item at the Gtk.ComboBoxText index.
+ */
+ public G? get_item_at(int index) {
+ Gee.MapIterator<G, int> iter = indices.map_iterator();
+ while (iter.next()) {
+ if (iter.get_value() == index)
+ return iter.get_key();
+ }
+
+ return null;
+ }
+
+ private void on_combo_box_active() {
+ active = get_item_at(combo_box.active);
+ }
+
+ public override string to_string() {
+ return "ComboBoxTextModel (%d items)".printf(items.size);
+ }
+}
+
+}
+
diff --git a/src/unit-test/unit-test-harness.vala b/src/unit-test/unit-test-harness.vala
new file mode 100644
index 0000000..249aae9
--- /dev/null
+++ b/src/unit-test/unit-test-harness.vala
@@ -0,0 +1,143 @@
+/* Copyright 2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+namespace California.UnitTest {
+
+/**
+ * Base class for suites of related tests.
+ */
+
+public abstract class Harness : BaseObject {
+ public delegate bool Case() throws Error;
+
+ private class TestCase : BaseObject {
+ public string name;
+ public unowned Case unit_test;
+
+ public TestCase(string name, Case unit_test) {
+ this.name = name;
+ this.unit_test = unit_test;
+ }
+
+ public override string to_string() {
+ return name;
+ }
+ }
+
+ private static Gee.ArrayList<Harness>? harnesses = null;
+
+ /**
+ * Name of the { link Harness}.
+ */
+ public string name { get; private set; }
+
+ private Gee.ArrayList<TestCase> test_cases = new Gee.ArrayList<TestCase>();
+
+ protected Harness(string? name = null) {
+ this.name = name ?? get_class().get_type().name();
+ }
+
+ /**
+ * Register a { link Harness} to the total list of Harneses.
+ */
+ public static void register(Harness harness) {
+ if (harnesses == null)
+ harnesses = new Gee.ArrayList<Harness>();
+
+ harnesses.add(harness);
+ }
+
+ /**
+ * Execute all { link register}ed { link Harness}es.
+ */
+ public static int exec_all() {
+ if (harnesses == null || harnesses.size == 0)
+ return 0;
+
+ foreach (Harness harness in harnesses) {
+ try {
+ harness.setup();
+ } catch (Error err) {
+ stdout.printf("Unable to setup harness %s: %s", harness.name, err.message);
+ Posix.exit(Posix.EXIT_FAILURE);
+ }
+
+ harness.exec();
+ harness.teardown();
+ }
+
+ return 0;
+ }
+
+ /**
+ * Executed before running any test cases.
+ */
+ protected abstract void setup() throws Error;
+
+ /**
+ * Executed after all test cases have completed.
+ */
+ protected abstract void teardown();
+
+ /**
+ * Executed prior to each test case.
+ */
+ protected virtual void prepare() throws Error {
+ }
+
+ /**
+ * Executed after each test case.
+ */
+ protected virtual void cleanup() {
+ }
+
+ /**
+ * Add a test case to the { link Harness}.
+ */
+ protected void add_case(string name, Case unit_test) {
+ test_cases.add(new TestCase(name, unit_test));
+ }
+
+ private void exec() {
+ foreach (TestCase test_case in test_cases) {
+ stdout.printf("Executing test: %s.%s...", name, test_case.name);
+
+ try {
+ prepare();
+ } catch (Error err) {
+ stdout.printf("prepare failed: %s\n", err.message);
+ Posix.exit(Posix.EXIT_FAILURE);
+ }
+
+ bool success = false;
+ Error? err = null;
+ try {
+ success = test_case.unit_test();
+ } catch (Error caught) {
+ err = caught;
+ }
+
+ if (err != null)
+ stdout.printf("\nFailed: %s.%s\n\t%s\n", name, test_case.name, err.message);
+ else if (!success)
+ stdout.printf("\nFailed: %s.%s\n", name, test_case.name);
+
+ if (err != null || !success)
+ Posix.exit(Posix.EXIT_FAILURE);
+
+ cleanup();
+
+ stdout.printf("success\n");
+ }
+ }
+
+ public override string to_string() {
+ return name;
+ }
+}
+
+}
+
diff --git a/src/util/util-string.vala b/src/util/util-string.vala
index 693cb11..b9561af 100644
--- a/src/util/util-string.vala
+++ b/src/util/util-string.vala
@@ -16,6 +16,14 @@ public int stricmp(string a, string b) {
return strcmp(a.casefold(), b.casefold());
}
+public uint ci_hash(string str) {
+ return str.casefold().hash();
+}
+
+public bool ci_equal(string a, string b) {
+ return stricmp(a, b) == 0;
+}
+
/**
* Removes redundant whitespace (including tabs and newlines) and strips whitespace from beginning
* and end of string.
@@ -44,5 +52,22 @@ public string reduce_whitespace(string str) {
return builder.str;
}
+/**
+ * Returns true if every character in the string is a numeric digit.
+ */
+public bool is_numeric(string? str) {
+ if (is_empty(str))
+ return false;
+
+ unichar ch;
+ int index = 0;
+ while (str.get_next_char(ref index, out ch)) {
+ if (!ch.isdigit())
+ return false;
+ }
+
+ return true;
+}
+
}
diff --git a/vapi/libical.vapi b/vapi/libical.vapi
index 14ea598..a04d261 100644
--- a/vapi/libical.vapi
+++ b/vapi/libical.vapi
@@ -109,7 +109,7 @@ namespace iCal {
[CCode (cname = "icalcomponent_get_inner")]
public unowned iCal.icalcomponent get_inner ();
[CCode (cname = "icalcomponent_get_location")]
- public unowned string get_location ();
+ public unowned string? get_location ();
[CCode (cname = "icalcomponent_get_method")]
public iCal.icalproperty_method get_method ();
[CCode (cname = "icalcomponent_get_next_component")]
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]