[california/wip/725792-quick-add] Better parsing, translated, duration and delay included
- From: Jim Nelson <jnelson src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [california/wip/725792-quick-add] Better parsing, translated, duration and delay included
- Date: Fri, 18 Apr 2014 01:47:59 +0000 (UTC)
commit a39d991e7f039b7bdd2234013eecde3d2dc7e40a
Author: Jim Nelson <jim yorba org>
Date: Thu Apr 17 18:47:43 2014 -0700
Better parsing, translated, duration and delay included
src/Makefile.am | 2 +
src/calendar/calendar-day-of-week.vala | 6 +-
src/calendar/calendar-duration.vala | 86 +++++++++
src/calendar/calendar-wall-time.vala | 27 ++-
src/calendar/calendar.vala | 21 +++
src/component/component-details-parser.vala | 250 +++++++++++++++++++++++++++
src/component/component.vala | 49 ++++++
src/host/host-quick-create-event.vala | 87 +---------
8 files changed, 429 insertions(+), 99 deletions(-)
---
diff --git a/src/Makefile.am b/src/Makefile.am
index f9e3564..e83888e 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -52,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 \
@@ -74,6 +75,7 @@ california_VALASOURCES = \
\
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 \
diff --git a/src/calendar/calendar-day-of-week.vala b/src/calendar/calendar-day-of-week.vala
index 56698a4..80aeb57 100644
--- a/src/calendar/calendar-day-of-week.vala
+++ b/src/calendar/calendar-day-of-week.vala
@@ -184,13 +184,13 @@ public class DayOfWeek : BaseObject, Gee.Hashable<DayOfWeek> {
* or { link full_name}.
*/
public static DayOfWeek? parse(string str) {
- string token = str.strip().down();
+ string token = str.strip().casefold();
foreach (DayOfWeek dow in days_of_week_monday) {
- if (dow.abbrev_name.down() == token)
+ if (dow.abbrev_name.casefold() == token)
return dow;
- if (dow.full_name.down() == token)
+ if (dow.full_name.casefold() == token)
return dow;
}
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-wall-time.vala b/src/calendar/calendar-wall-time.vala
index 9b05a16..a4621c1 100644
--- a/src/calendar/calendar-wall-time.vala
+++ b/src/calendar/calendar-wall-time.vala
@@ -137,24 +137,29 @@ 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 set.
*/
- public static WallTime? parse(string str) {
- string token = str.strip().down();
+ public static WallTime? parse(string str, out bool liberally_parsed) {
+ liberally_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)) {
- token = token.slice(0, token.length - FMT_AM.length);
- } else if (token.has_suffix(FMT_BRIEF_AM)) {
- token = token.slice(0, token.length - FMT_BRIEF_AM.length);
- } else if (token.has_suffix(FMT_PM)) {
- token = token.slice(0, token.length - FMT_PM.length);
+ 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)) {
- token = token.slice(0, token.length - FMT_BRIEF_PM.length);
+ } 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;
@@ -191,6 +196,8 @@ public class WallTime : BaseObject, Gee.Comparable<WallTime>, Gee.Hashable<WallT
if (!meridiem_unknown && pm)
h += 12;
+ liberally_parsed = meridiem_unknown;
+
return new WallTime(h, 0, 0);
}
diff --git a/src/calendar/calendar.vala b/src/calendar/calendar.vala
index cafb6cb..1f61887 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,21 @@ 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 and
+ // casefolded (lowercase).
+ UNIT_DAYS = _("day;days;").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 and
+ // casefolded (lowercase).
+ UNIT_HOURS = _("hour;hours;hr;hrs").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 and
+ // casefolded (lowercase).
+ UNIT_MINS = _("minute;minutes;min;mins").split(";");
+
// return LC_MESSAGES back to proper locale and return LANGUAGE environment variable
if (messages_locale != null)
Intl.setlocale(LocaleCategory.MESSAGES, messages_locale);
@@ -162,6 +181,8 @@ public void terminate() {
DayOfMonth.terminate();
DayOfWeek.terminate();
OlsonZone.terminate();
+
+ UNIT_DAYS = UNIT_HOURS = UNIT_MINS = null;
}
}
diff --git a/src/component/component-details-parser.vala b/src/component/component-details-parser.vala
new file mode 100644
index 0000000..de4fc46
--- /dev/null
+++ b/src/component/component-details-parser.vala
@@ -0,0 +1,250 @@
+/* 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 {
+ /**
+ * The generated { link Event}.
+ */
+ public Component.Event event { get; private set; }
+
+ private StringBuilder summary = new StringBuilder();
+ private StringBuilder location = new StringBuilder();
+ private Calendar.WallTime? start_time = null;
+ private Calendar.WallTime? end_time = null;
+ 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) {
+ event = parse(details);
+ }
+
+ private Component.Event parse(string details) {
+ string[] tokens = String.reduce_whitespace(details).split(" ");
+ for (int ctr = 0; ctr < tokens.length; ctr++) {
+ string token = tokens[ctr].casefold();
+ string? next_token = (ctr + 1 < tokens.length) ? tokens[ctr + 1].casefold() : null;
+ string? next_next_token = (ctr + 2 < tokens.length) ? tokens[ctr + 2].casefold() : null;
+
+ // strip time prepositions if actually followed by time (even if liberally parsed, i.e.
+ // "8"-> 8am
+ if (next_token != null && token in TIME_PREPOSITIONS && is_time_or_date(next_token, true)) {
+ debug("is time");
+ if (!add_wall_clock_time(next_token, true))
+ assert(add_date(next_token));
+
+ ctr++;
+
+ continue;
+ }
+
+ if (next_token != null && token in DURATION_PREPOSITIONS && parse_duration(next_token,
next_next_token) != null) {
+ debug("is duration");
+ add_duration(next_token, next_next_token);
+ ctr += 2;
+
+ continue;
+ }
+
+ if (next_token != null && start_time == null && token in DELAY_PREPOSITIONS &&
parse_duration(next_token, next_next_token) != null) {
+ debug("is delay");
+ Calendar.Duration duration = parse_duration(next_token, next_next_token);
+ start_time = new Calendar.WallTime.from_exact_time(Calendar.System.now.adjust_time((int)
duration.minutes, Calendar.TimeUnit.MINUTE));
+ ctr += 2;
+
+ continue;
+ }
+
+ // start adding to location field if location preposition encountered
+ if (!adding_location && token in LOCATION_PREPOSITIONS) {
+ debug("is location");
+ // add current token to summary
+ add_text(token);
+
+ // now adding to both summary and location
+ adding_location = true;
+
+ continue;
+ }
+
+ if (add_duration(token, next_token)) {
+ ctr++;
+
+ continue;
+ }
+
+ // attempt to (strictly) parse into wall-clock time
+ if (add_wall_clock_time(token, false))
+ continue;
+
+ // ditto for dates
+ if (add_date(token))
+ continue;
+
+ // append original to current text field as fallback
+ add_text(tokens[ctr]);
+ }
+
+ debug("start time: %s", (start_time != null) ? start_time.to_string() : "(null)");
+ debug("end time: %s", (end_time != null) ? end_time.to_string() : "(null)");
+ debug("duration: %s", (duration != null) ? duration.to_string() : "(null)");
+ debug("start date: %s", (start_date != null) ? start_date.to_string() : "(null)");
+ debug("end date: %s", (end_date != null) ? end_date.to_string() : "(null)");
+ debug("title: \"%s\"", summary.str);
+ debug("location: \"%s\"", location.str);
+
+ return new Event.blank();
+ }
+
+ private void add_text(string 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 void add_to_builder(StringBuilder builder, string token) {
+ // keep everything space-delimited
+ if (!String.is_empty(builder.str))
+ builder.append_unichar(' ');
+
+ builder.append(token);
+ }
+
+ private bool is_time_or_date(string token, bool liberal_ok) {
+ bool liberally_parsed;
+ if (parse_time(token, out liberally_parsed) != null)
+ return liberal_ok ? true : liberally_parsed;
+
+ return parse_date(token) != null;
+ }
+
+ private Calendar.Duration? parse_duration(string token, string? next_token) {
+ if (String.is_empty(next_token))
+ return null;
+
+ return Calendar.Duration.parse(token, next_token);
+ }
+
+ private bool add_duration(string token, string? next_token) {
+ if (end_time != null || duration != null)
+ return false;
+
+ duration = parse_duration(token, next_token);
+
+ return duration != null;
+ }
+
+ private Calendar.WallTime? parse_time(string token, out bool liberally_parsed) {
+ return Calendar.WallTime.parse(token, out liberally_parsed);
+ }
+
+ private bool add_wall_clock_time(string token, bool liberal_ok) {
+ if (start_time != null && end_time != null)
+ return false;
+
+ // attempt to parse into wall clock time .. first one found is start time, next is
+ // end time, rest are ignored
+ bool liberally_parsed;
+ Calendar.WallTime? wall_time = parse_time(token, out liberally_parsed);
+ if (wall_time != null) debug("%s %s %s", wall_time.to_string(), liberally_parsed.to_string(),
liberal_ok.to_string());
+ if (wall_time == null || (liberally_parsed && !liberal_ok))
+ return false;
+
+ if (start_time == null) {
+ start_time = wall_time;
+
+ return true;
+ }
+
+ assert(end_time == null);
+ end_time = wall_time;
+
+ return true;
+ }
+
+ private Calendar.Date? parse_date(string token) {
+ // attempt to parse into common words for relative dates
+ if (token == TODAY)
+ return Calendar.System.today;
+ else if (token == TOMORROW)
+ return Calendar.System.today.next();
+ else if (token == YESTERDAY)
+ return Calendar.System.today.previous();
+
+ // attempt to parse into day of the week
+ Calendar.DayOfWeek? dow = Calendar.DayOfWeek.parse(token);
+ 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;
+ }
+
+ private bool add_date(string token) {
+ if (start_date != null && end_date != null)
+ return false;
+
+ Calendar.Date? date = parse_date(token);
+ if (date == null)
+ return false;
+
+ // like wall clock time, first is start date, next is end date, after that, ignored
+ if (start_date == null) {
+ start_date = date;
+
+ return true;
+ }
+
+ assert(end_date == null);
+ end_date = date;
+
+ return true;
+ }
+
+ public override string to_string() {
+ return "DetailsParser:%s".printf(event.to_string());
+ }
+}
+
+}
+
diff --git a/src/component/component.vala b/src/component/component.vala
index 21eb074..ac7464e 100644
--- a/src/component/component.vala
+++ b/src/component/component.vala
@@ -17,18 +17,67 @@ namespace California.Component {
private int init_count = 0;
+private unowned string TODAY;
+private unowned string TOMORROW;
+private unowned string YESTERDAY;
+private string[] TIME_PREPOSITIONS;
+private string[] LOCATION_PREPOSITIONS;
+private string[] DURATION_PREPOSITIONS;
+private string[] DELAY_PREPOSITIONS;
+
public void init() throws Error {
if (!Unit.do_init(ref init_count))
return;
// external unit init
Calendar.init();
+
+ // Used by quick-add to indicate the user wants to create an event for today. Should be
+ // casefolded (lowercase).
+ TODAY = _("today");
+
+ // Used by quick-add to indicate the user wants to create an event for tomorrow. Should be
+ // casefolded (lowercase).
+ TOMORROW = _("tomorrow");
+
+ // Used by quick-add to indicate the user wants to create an event for yesterday. Should be
+ // casefolded (lowercase).
+ YESTERDAY = _("yesterday");
+
+ // 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. All
+ // words should be casefolded (lowercase). It's allowable for some or all of these words to
+ // be duplicated in the location prepositions list (elsewhere) but not another time list.
+ // Examples: "at 9am", "from 10pm to 11:30pm", "on monday"
+ TIME_PREPOSITIONS = _("at;from;to;on;").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. All
+ // words should be casefolded (lowercase). It's allowable for some or all of these words to
+ // be duplicated in the location prepositions list (elsewhere) but not another time list.
+ // Examples: "for 3 hours", "for 90 minutes"
+ DURATION_PREPOSITIONS = _("for;").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. All
+ // words should be casefolded (lowercase). It's allowable for some or all of these words to
+ // be duplicated in the location prepositions list (elsewhere) but not another time list.
+ // Example: "in 3 hours" (meaning 3 hours from now)
+ DELAY_PREPOSITIONS = _("in;").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. All words should be
+ // casefolded (lowercase). It's allowable for some or all of these words to be duplicated in
+ // the time prepositions list (elsewhere).
+ LOCATION_PREPOSITIONS = _("at;").split(";");
}
public void terminate() {
if (!Unit.do_terminate(ref init_count))
return;
+ TIME_PREPOSITIONS = LOCATION_PREPOSITIONS = DURATION_PREPOSITIONS = null;
+
Calendar.terminate();
}
diff --git a/src/host/host-quick-create-event.vala b/src/host/host-quick-create-event.vala
index b750b4c..af70b6e 100644
--- a/src/host/host-quick-create-event.vala
+++ b/src/host/host-quick-create-event.vala
@@ -47,96 +47,11 @@ public class QuickCreateEvent : Gtk.Grid, Toolkit.Card {
[GtkCallback]
private void on_create_button_clicked() {
- parse(details_entry.text);
+ new Component.DetailsParser(details_entry.text);
completed();
dismissed(true);
}
-
- // TODO: Temporary. This logic should be moved out of the UI layer
- private Component.Event? parse(string details) {
- StringBuilder title = new StringBuilder();
- Calendar.WallTime? start_time = null;
- Calendar.WallTime? end_time = null;
- Calendar.Date? start_date = null;
- Calendar.Date? end_date = null;
-
- string[] tokens = String.reduce_whitespace(details).split(" ");
- for (int ctr = 0; ctr < tokens.length; ctr++) {
- string token = tokens[ctr].down();
-
- Calendar.WallTime? wall_time = Calendar.WallTime.parse(token);
- if (wall_time != null) {
- if (start_time == null) {
- start_time = wall_time;
-
- continue;
- }
-
- if (end_time == null) {
- end_time = wall_time;
-
- continue;
- }
- }
-
- // TODO: use internationalized strings
- Calendar.Date? date = null;
- switch (token) {
- case "today":
- date = Calendar.System.today;
- break;
-
- case "tomorrow":
- date = Calendar.System.today.next();
- break;
-
- case "yesterday":
- date = Calendar.System.today.previous();
- break;
- }
-
- if (date == null) {
- Calendar.DayOfWeek? dow = Calendar.DayOfWeek.parse(token);
- if (dow != null) {
- 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))
- date = upcoming;
- else
- upcoming = upcoming.next();
- } while (date == null && !upcoming.equal_to(next_week));
- }
- }
-
- if (date != null) {
- if (start_date == null) {
- start_date = date;
-
- continue;
- }
-
- if (end_date == null) {
- end_date = date;
-
- continue;
- }
- }
-
- if (!String.is_empty(title.str))
- title.append_unichar(' ');
- title.append(tokens[ctr]);
- }
-
- debug("start time: %s", (start_time != null) ? start_time.to_string() : "(null)");
- debug("end time: %s", (end_time != null) ? end_time.to_string() : "(null)");
- debug("start date: %s", (start_date != null) ? start_date.to_string() : "(null)");
- debug("end date: %s", (end_date != null) ? end_date.to_string() : "(null)");
- debug("title: \"%s\"", title.str);
-
- return null;
- }
}
}
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]