[california/wip/725792-quick-add] Better parsing, translated, duration and delay included



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]