From: Travis Reitter Date: Sun, 28 Mar 2010 17:04:33 +0000 (-0700) Subject: Add a basic task cache X-Git-Url: http://vcs.maemo.org/git/?p=milk;a=commitdiff_plain;h=52f46483b13ef3008c5a7089fa435d84541d09de Add a basic task cache --- diff --git a/configure.ac b/configure.ac index 37db629..c4bf0f7 100644 --- a/configure.ac +++ b/configure.ac @@ -18,6 +18,7 @@ PKG_CHECK_MODULES([MILK], [ libcurl json-glib-1.0 rtm-glib + sqlite3 ]) AC_SUBST([MILK_CFLAGS]) AC_SUBST([MILK_LIBS]) diff --git a/src/Makefile.am b/src/Makefile.am index 1bd0b39..1253a3c 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -11,6 +11,8 @@ bin_PROGRAMS = milk milk_SOURCES = \ milk-auth.c \ milk-auth.h \ + milk-cache.c \ + milk-cache.h \ milk-dialogs.c \ milk-dialogs.h \ milk-main.c \ diff --git a/src/milk-auth.c b/src/milk-auth.c index 96155c5..c36db6a 100644 --- a/src/milk-auth.c +++ b/src/milk-auth.c @@ -216,6 +216,50 @@ milk_auth_timeline_create (MilkAuth *auth, return rtm_glib_timelines_create (priv->rtm_glib, error); } +/* FIXME: we probably really want this to be async (but sequencable) */ +GList* +milk_auth_tasks_add (MilkAuth *auth, + char *timeline, + GList *names) +{ + gboolean success = TRUE; + MilkAuthPrivate *priv; + GList *l; + GList *tasks = NULL; + GError *error = NULL; + + g_return_val_if_fail (MILK_IS_AUTH (auth), NULL); + g_return_val_if_fail (names, NULL); + + priv = MILK_AUTH_PRIVATE (auth); + + for (l = names; l; l = l->next) { + RtmTask *task; + + /* FIXME: cut this */ + g_debug ("trying to send task with name '%s'", l->data); + + /* XXX: this uses Smart Add parsing; make this user-settable? */ + /* XXX: the cast to char* is actually a bug in the rtm-glib API + */ + task = rtm_glib_tasks_add (priv->rtm_glib, timeline, + l->data, NULL, TRUE, &error); + if (task) { + /* FIXME: cut this */ + g_debug (G_STRLOC ": added task with ID '%s'", + rtm_task_get_id (task)); + + tasks = g_list_prepend (tasks, task); + } else { + g_warning ("failed to add some tasks: %s", + error->message); + g_clear_error (&error); + } + } + + return tasks; +} + RtmTask* milk_auth_task_add (MilkAuth *auth, char *timeline, @@ -229,11 +273,94 @@ milk_auth_task_add (MilkAuth *auth, priv = MILK_AUTH_PRIVATE (auth); /* XXX: this uses Smart Add parsing; make this user-settable? */ - /* FIXME: the cast to char* is actually a bug in the rtm-glib API */ + /* XXX: the cast to char* is actually a bug in the rtm-glib API */ return rtm_glib_tasks_add (priv->rtm_glib, timeline, (char*) name, NULL, TRUE, error); } +/* FIXME: we probably really want this to be async (but sequencable) */ +GList* +milk_auth_tasks_send_changes (MilkAuth *auth, + char *timeline, + GList *tasks) +{ + gboolean success = TRUE; + MilkAuthPrivate *priv; + GList *l; + GList *tasks_sent = NULL; + GError *error = NULL; + + g_return_val_if_fail (MILK_IS_AUTH (auth), NULL); + g_return_val_if_fail (tasks, NULL); + + priv = MILK_AUTH_PRIVATE (auth); + + for (l = tasks; l; l = l->next) { + RtmTask *task = l->data; + + GTimeVal *tv; + + /* If any of these conditions fail, libsoup ends up exploding + * in a segfault (blindly de-reffing NULL); better to be safe + * than sorry */ + if (!rtm_task_get_list_id (task)) { + g_warning (G_STRLOC ": task doesn't have a list ID; " + "skipping..."); + continue; + } + + if (!rtm_task_get_taskseries_id (task)) { + g_warning (G_STRLOC ": task doesn't have a taskseries " + "ID; skipping..."); + continue; + } + + if (!rtm_task_get_id (task)) { + g_warning (G_STRLOC ": task doesn't have an ID; " + "skipping..."); + continue; + } + + tv = rtm_task_get_due_date (task); + if (tv) { + char *due_str; + char *tid; + + due_str = g_time_val_to_iso8601 (tv); + + /* FIXME: cut this */ + g_debug ("going to set due string: '%s'", due_str); + + /* XXX: this uses Smart Add parsing; make this + * user-settable? */ + /* XXX: the cast to char* is actually a bug in the + * rtm-glib API */ + tid = rtm_glib_tasks_set_due_date (priv->rtm_glib, timeline, + task, due_str, + /* FIXME: set this appropriately */ + FALSE, + FALSE, &error); + + if (tid) { + /* FIXME: this should be a set, not a list -- + * we'll add each task to the set if it's been + * changed since the last send */ + tasks_sent = g_list_prepend (tasks_sent, task); + } else { + g_warning ("failed to add some tasks: %s", + error->message); + g_clear_error (&error); + } + + g_free (tid); + } + + /* FIXME: handle all the other attributes, not just the date */ + } + + return tasks_sent; +} + char* milk_auth_task_complete (MilkAuth *auth, char *timeline, @@ -264,6 +391,12 @@ milk_auth_task_delete (MilkAuth *auth, return rtm_glib_tasks_delete (priv->rtm_glib, timeline, task, error); } +/* FIXME: why does this (or something above it) totally fail if we don't have a + * working Internet connection / resolv.conf is mangled? */ +/* FIXME: instead of this manual call, listen to the connection manager + * transitions -- see this: + * http://wiki.maemo.org/Documentation/Maemo_5_Developer_Guide/Using_Connectivity_Components/Maemo_Connectivity#Libconic_Usage + */ void milk_auth_log_in (MilkAuth *auth) { diff --git a/src/milk-auth.h b/src/milk-auth.h index 28faf3e..5e98254 100644 --- a/src/milk-auth.h +++ b/src/milk-auth.h @@ -81,6 +81,12 @@ RtmTask* milk_auth_task_add (MilkAuth *auth, char *timeline, const char *name, GError **error); +GList* milk_auth_tasks_add (MilkAuth *auth, + char *timeline, + GList *names); +GList* milk_auth_tasks_send_changes (MilkAuth *auth, + char *timeline, + GList *tasks); char* milk_auth_task_complete (MilkAuth *auth, char *timeline, RtmTask *task, diff --git a/src/milk-cache.c b/src/milk-cache.c new file mode 100644 index 0000000..af0c7cb --- /dev/null +++ b/src/milk-cache.c @@ -0,0 +1,1534 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, + * Boston, MA 02110-1301 USA + * + * Authors: Travis Reitter + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "milk-cache.h" +#include "milk-auth.h" +#include "milk-dialogs.h" + +G_DEFINE_TYPE (MilkCache, milk_cache, G_TYPE_OBJECT); + +/* less expensive than G_TYPE_INSTANCE_GET_PRIVATE */ +#define MILK_CACHE_PRIVATE(o) ((MILK_CACHE ((o)))->priv) + +#define RTM_API_KEY "81f5c6c904aeafbbc914d9845d250ea8" +#define RTM_SHARED_SECRET "b08b15419378f913" + +/* FIXME: centralize this generic data dir */ +#define DATA_DIR ".milk" +#define DB_BASENAME "tasks.db" + +/* FIXME: make this configurable at runtime, pref. as a gconf value */ +/* time between syncing with the server, in ms */ +#define CACHE_UPDATE_PERIOD 5000 + +struct _MilkCachePrivate +{ + MilkAuth *auth; + guint update_id; + char *last_sync; + sqlite3 *db; +}; + +enum { + PROP_AUTH = 1, +}; + +enum { + CLEARED, + TASK_ADDED, + TASK_CHANGED, + TASK_FINISHED, + LAST_SIGNAL +}; + +static guint signals[LAST_SIGNAL]; + +static MilkCache *default_cache = NULL; + +static const char* +get_data_dir () +{ + static char *filename = NULL; + + if (!filename) + filename = g_build_filename (g_get_home_dir (), DATA_DIR, NULL); + + return filename; +} + +static const char* +get_db_filename () +{ + static char *filename = NULL; + + if (!filename) + filename = g_build_filename (get_data_dir (), DB_BASENAME, + NULL); + + return filename; +} + +static gint +get_schema_version (sqlite3 *db) +{ + gint version = -1; + gint status; + sqlite3_stmt *query = NULL; + + if (sqlite3_prepare_v2 (db, "PRAGMA user_version;", -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (db)); + goto get_schema_version_OUT; + } + + while (status = sqlite3_step (query)) { + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (db)); + goto get_schema_version_OUT; + } + + version = sqlite3_column_int (query, 0); + } + +get_schema_version_OUT: + sqlite3_finalize (query); + + return version; +} + +static void +db_transaction_begin (sqlite3 *db) +{ + char *err = NULL; + + if (sqlite3_exec (db, "BEGIN;", NULL, NULL, &err)) { + g_error ("failed to begin transaction: %s\n", + sqlite3_errmsg (db)); + } + sqlite3_free (err); +} + +static void +db_transaction_commit (sqlite3 *db) +{ + char *err = NULL; + + if (sqlite3_exec (db, "COMMIT;", NULL, NULL, &err)) { + g_error ("failed to commit transaction: %s\n", + sqlite3_errmsg (db)); + } + sqlite3_free (err); +} + +static void +db_transaction_rollback (sqlite3 *db) +{ + char *err = NULL; + + if (sqlite3_exec (db, "ROLLBACK;", NULL, NULL, &err)) { + g_error ("failed to rollback transaction: %s\n", + sqlite3_errmsg (db)); + } + sqlite3_free (err); +} + +static gboolean +db_set_schema_version (sqlite3 *db, + guint version) +{ + gboolean success = TRUE; + char *statement; + char *err = NULL; + + statement = g_strdup_printf ("PRAGMA user_version = %d;", version); + + if (sqlite3_exec (db, statement, NULL, NULL, &err)) { + g_warning ("failed to update schema version: %s\n", + sqlite3_errmsg (db)); + success = FALSE; + } + + g_free (statement); + sqlite3_free (err); + + return success; +} + +static char* +db_get_key_value (sqlite3 *db, + const char *key) +{ + gint status; + sqlite3_stmt *query = NULL; + char *statement; + char *value = NULL; + + statement = g_strdup_printf ("SELECT value FROM key_values " + "WHERE key = '%s';", key); + + if (sqlite3_prepare_v2 (db, statement, -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (db)); + goto get_schema_version_OUT; + } + + while (status = sqlite3_step (query)) { + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (db)); + goto get_schema_version_OUT; + } + + value = g_strdup (sqlite3_column_text (query, 0)); + } + +get_schema_version_OUT: + sqlite3_finalize (query); + g_free (statement); + + return value; +} + +static gboolean +db_date_column_to_timeval (sqlite3_stmt *query, + guint colnum, + GTimeVal *timeval) +{ + const gchar *date_str; + + date_str = sqlite3_column_text (query, colnum); + + if (date_str && date_str[0] != '\0' && !g_strcmp0 (date_str, "(null)")) + return g_time_val_from_iso8601 (date_str, timeval); + + return FALSE; +} + +static GList* +db_get_tasks_active (sqlite3 *db) +{ + gint status; + sqlite3_stmt *query = NULL; + char *statement; + GList *tasks = NULL; + + statement = g_strdup_printf ("SELECT " + "task_id, name, due_date FROM tasks " + "WHERE " + "delete_date IS NULL AND " + "complete_date IS NULL" + ";"); + + if (sqlite3_prepare_v2 (db, statement, -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (db)); + goto get_schema_version_OUT; + } + + while (status = sqlite3_step (query)) { + RtmTask *task; + const char *due; + GTimeVal timeval; + + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (db)); + goto get_schema_version_OUT; + } + + task = rtm_task_new (); + + rtm_task_set_id (task, (char*)sqlite3_column_text (query, 0)); + rtm_task_set_name (task, (char*)sqlite3_column_text (query, 1)); + + if (db_date_column_to_timeval (query, 2, &timeval)) + rtm_task_set_due_date (task, &timeval); + + tasks = g_list_prepend (tasks, task); + } + +get_schema_version_OUT: + sqlite3_finalize (query); + g_free (statement); + + return tasks; +} + +static char* +db_get_last_sync (sqlite3 *db) +{ + return db_get_key_value (db, "last_sync"); +} + +static gboolean +db_set_key_value (sqlite3 *db, + const char *key, + const char *value) +{ + gboolean success = TRUE; + char *statement; + char *err = NULL; + + /* FIXME: probably safer to create them per-schema update and then fail + * if they don't exist */ + statement = g_strdup_printf ("INSERT OR REPLACE INTO key_values " + "('key', 'value') VALUES ('%s', '%s');", key, value); + + if (sqlite3_exec (db, statement, NULL, NULL, &err)) { + g_warning ("failed to update schema version: %s\n", + sqlite3_errmsg (db)); + success = FALSE; + } + + g_free (statement); + sqlite3_free (err); + + return success; +} + +static gboolean +db_set_last_sync (sqlite3 *db, + const char *last_sync) +{ + return db_set_key_value (db, "last_sync", last_sync); +} + +static gboolean +db_update_schema_0_to_1 (sqlite3 *db) +{ + sqlite3_stmt *query = NULL; + char *err = NULL; + + db_transaction_begin (db); + + /* FIXME: actually create the required triggers */ + + /* FIXME: ugh... sqlite supports foreign keys, but don't actualyl + * /enforce them/ (thanks, guys...); here's a way to enforce them using + * triggers: + * + * http://www.justatheory.com/computers/databases/sqlite/ + * + * it seems to be fixed in sqlite 3.6.19, but even Karmic has 3.6.16 + * (and Fremantle has 3.6.14) + * */ + + if (sqlite3_exec (db, "CREATE TABLE task_ids " + "(task_id TEXT PRIMARY KEY);", NULL, NULL, &err)) { + g_warning ("failed to create tasks table: %s\n", + sqlite3_errmsg (db)); + goto db_update_schema_0_to_1_ERROR; + } + sqlite3_free (err); + + /* XXX: there is a subtle race here where we can add a task on the + * server but disconnect before we get a confirmation, and change the + * name on the server before we recieve it. Then we'll end up + * re-submitting this task with its original name again (until we don't + * hit this condition again). The risk is fairly low, but it's still + * there */ + + /* insert a special task ID (NULL) to designate that we haven't + * gotten a confirmation for adding the task yet (and thus haven't + * gotten its server-side ID) */ + if (sqlite3_exec (db, "INSERT INTO task_ids " + "(task_id) values (NULL);", NULL, NULL, &err)) { + g_warning ("failed to insert special 'unset' value: %s\n", + sqlite3_errmsg (db)); + goto db_update_schema_0_to_1_ERROR; + } + sqlite3_free (err); + + if (sqlite3_exec (db, "CREATE TABLE tasks (" + "local_id INTEGER PRIMARY KEY NOT NULL," + "task_id" + " CONSTRAINT fk_task_id " + " REFERENCES task_ids(task_id) " + " ON DELETE CASCADE," + "name TEXT NOT NULL," + "local_changes BOOLEAN DEFAULT 0," + "due_date TEXT," + "delete_date TEXT," + "complete_date TEXT," + "list_id TEXT," + "taskseries_id TEXT" + ");", NULL, NULL, &err)) { + g_warning ("failed to create tasks table: %s\n", + sqlite3_errmsg (db)); + goto db_update_schema_0_to_1_ERROR; + } + sqlite3_free (err); + + if (sqlite3_exec (db, "CREATE TABLE key_values (" + "key TEXT PRIMARY KEY NOT NULL," + "value TEXT" + ");", NULL, NULL, &err)) { + g_warning ("failed to create key_values table: %s\n", + sqlite3_errmsg (db)); + goto db_update_schema_0_to_1_ERROR; + } + sqlite3_free (err); + + if (!db_set_last_sync (db, "0")) + goto db_update_schema_0_to_1_ERROR; + + if (!db_set_schema_version (db, 1)) + goto db_update_schema_0_to_1_ERROR; + + db_transaction_commit (db); + + return TRUE; + +db_update_schema_0_to_1_ERROR: + db_transaction_rollback (db); + sqlite3_finalize (query); + + return FALSE; +} + +static gboolean +db_update_schema (sqlite3 *db) +{ + gboolean (*update_funcs[]) (sqlite3 *)= { + db_update_schema_0_to_1, + }; + gint i; + gint schema_version = -1; + gint latest_version = G_N_ELEMENTS (update_funcs); + gint status; + + schema_version = get_schema_version (db); + + if (schema_version > latest_version) { + g_error ("your database is newer than this version of the " + "app knows how to deal with -- bailing..."); + } else if (schema_version == latest_version) { + return TRUE; + } + + for (i = schema_version; i < latest_version; i++) { + if (!update_funcs[i] (db)) { + g_error ("error upgrading from schema version %d to %d", + i, i+1); + /* FIXME: probably better to just wipe the cache and + * start over, rather than crash (which would probably + * prevent us from ever automatically recovering) */ + } + } + + return TRUE; +} + +static sqlite3* +db_open () +{ + sqlite3 *db = NULL; + gint status; + + if (!g_file_test (get_data_dir (), G_FILE_TEST_EXISTS)) { + + } + if (g_mkdir_with_parents (get_data_dir (), S_IRWXU | S_IRWXG)) { + + g_error ("Can't create the data dir %s: %s; giving up...", + get_data_dir (), strerror (errno)); + } + + status = sqlite3_open (get_db_filename (), &db); + if (status){ + GFile *file; + GError *error = NULL; + + g_warning ("Can't open database: %s; deleting it...\n", + sqlite3_errmsg (db)); + sqlite3_close (db); + db = NULL; + + /* FIXME: open a banner warning that any pending tasks may have + * been lost (but actually just move the file as a backup file) + */ + file = g_file_new_for_path (get_db_filename ()); + if (!g_file_delete (file, NULL, &error)) { + g_error ("Could not delete the broken database: %s; " + "giving up...", error->message); + + g_clear_error (&error); + exit (1); + } + } + + if (!db) { + status = sqlite3_open (get_db_filename (), &db); + if (status){ + sqlite3_close (db); + exit (1); + } + } + + return db; +} + +static char* +time_val_to_iso8601 (GTimeVal *val) +{ + return val ? + g_strdup_printf ("'%s'", g_time_val_to_iso8601 (val)) : + g_strdup ("NULL"); +} + +static char* +sql_escape_quotes (const char *str) +{ + char **tokens; + char *final_str; + + if (!str) + return NULL; + + /* escape all single quotes as double quotes (as is normal in SQL) */ + tokens = g_strsplit (str, "'", -1); + final_str = g_strjoinv("''", tokens); + g_strfreev (tokens); + + return final_str; +} + +static gboolean +db_insert_or_update_local_task (sqlite3 *db, + RtmTask *task, + const char *local_id, + gboolean local_changes) +{ + gboolean success = TRUE; + char *statement; + char *err = NULL; + GTimeVal *due, *deleted, *completed; + char *name_str, *due_str, *deleted_str, *completed_str; + const char *task_id; + const char *list_id; + const char *taskseries_id; + gint status; + + name_str = sql_escape_quotes (rtm_task_get_name (task)); + due = rtm_task_get_due_date (task); + deleted = rtm_task_get_deleted_date (task); + completed = rtm_task_get_completed_date (task); + due_str = time_val_to_iso8601 (due); + deleted_str = time_val_to_iso8601 (deleted); + completed_str = time_val_to_iso8601 (completed); + + /* FIXME: it doesn't actually have to be this complicated; see the + * actual code above and below */ + /* FIXME: + * 1. add a new internal ID ('local_id') that's unique in the table + * 'tasks'. + * 2. Add another 1-column table ('task_ids') with PRIMARY KEY column + * 'task_id' + * 3. prep-populate tasks_ids with a single special value (NULL, if + * possible). This will be used to designate "not yet written back to + * server" + * 4. make tasks.task_id have a foreign key constraint on + * task_ids.task_id + * 5. when inserting new tasks (ie, before they've been written back to + * the server), set their tasks.task_id to NULL (or whatever special + * value we're using) + * 6. when we recieve tasks, check to see if we get a match for SELECT + * local_id from tasks WHERE + * tasks.task_id = rtm_task_get_id (task) AND tasks.name = name_str; + * 6.a. if we have a match, UPDATE tasks SET task_id, name, ... + * WHERE local_id = + * 6.b. if we didn't have a match, INSERT INTO tasks (task_id, + * name, ...) VALUES (rtm_task_get_id (task), ...); + * 7. these "pending" tasks can be treated normally when being + * manipulated locally + * 8. any task changed locally needs to have its 'local_changes' field + * set to TRUE + */ + + + /* if we insert NULL for the local_id, it will nicely replace it with + * the next automatic value. This way we can use a single statement to + * update the other fields if the task already exists or create the task + * (with a new local_id) if necessary */ + + task_id = rtm_task_get_id (task); + task_id = task_id ? task_id : "NULL"; + + list_id = rtm_task_get_list_id (task); + list_id = list_id ? list_id : "NULL"; + + taskseries_id = rtm_task_get_taskseries_id (task); + taskseries_id = taskseries_id ? taskseries_id : "NULL"; + + /* FIXME: cut this? */ + if (!rtm_task_get_list_id (task)) { + g_warning ("caching a task without a list ID -- this can " + "cause problems later"); + } + + if (!rtm_task_get_taskseries_id (task)) { + g_warning ("caching a task without a task series ID -- this " + "can cause problems later"); + } + + /* all but the name fields are already quoted or NULL */ + statement = g_strdup_printf ("INSERT OR REPLACE INTO tasks " + "('local_id','task_id','name','due_date','delete_date'," + "'complete_date','list_id','taskseries_id'," + "'local_changes') " + "VALUES (%s, %s, '%s', %s, %s, %s, %s, %s, %d)" + ";", + local_id, + task_id, + name_str, + due_str, + deleted_str, + completed_str, + list_id, + taskseries_id, + local_changes ? 1 : 0); + g_free (name_str); + g_free (due_str); + g_free (deleted_str); + g_free (completed_str); + + if (sqlite3_exec (db, statement, NULL, NULL, &err)) { + g_warning ("failed to insert or update task in cache: %s\n", + sqlite3_errmsg (db)); + success = FALSE; + } + + g_free (statement); + sqlite3_free (err); + + return success; +} + +static void +cache_tasks_notify (MilkCache *cache, + GList *tasks, + guint signal_id) +{ + MilkCachePrivate *priv; + GList *l; + + g_return_if_fail (MILK_IS_CACHE (cache)); + + priv = MILK_CACHE_PRIVATE (cache); + + /* FIXME: make the signals just emit all at once as a list, not + * individually */ + for (l = tasks; l; l = l->next) { + g_signal_emit (cache, signal_id, 0, l->data); + } +} + +static RtmTask* +db_add_local_only_task (sqlite3 *db, + const char *name) +{ + RtmTask *task; + + /* FIXME: cut this */ + g_debug ("attempting to create new local-only task with name %s", + name); + + task = rtm_task_new (); + rtm_task_set_name (task, (char*) name); + + if (!db_insert_or_update_local_task (db, task, "NULL", TRUE)) { + g_object_unref (task); + task = NULL; + } + + return task; +} + +static gboolean +db_insert_or_update_task (sqlite3 *db, + RtmTask *task, + gboolean local_changes, + gboolean *task_existed) +{ + gboolean success = TRUE; + char *statement; + char *err = NULL; + GTimeVal *due, *deleted, *completed; + const char *task_id; + gint status; + sqlite3_stmt *query = NULL; + char *local_id = NULL; + char *local_id_formatted = NULL; + + task_id = rtm_task_get_id (task); + g_return_val_if_fail (task_id, FALSE); + + /* FIXME: should probably use a begin...commit block around this all */ + + /* FIXME: this needs a whole bucket of clean-up. + */ + /* FIXME: make these all prepared statements */ + + statement = g_strdup_printf ("INSERT OR REPLACE INTO task_ids (task_id) VALUES ('%s');", task_id); + if (sqlite3_exec (db, statement, NULL, NULL, &err)) { + g_warning ("failed to insert task id %s in cache: %s\n", + task_id, sqlite3_errmsg (db)); + success = FALSE; + } + + g_free (statement); + sqlite3_free (err); + + /* try to find an existing task that we've already received from the + * server */ + statement = g_strdup_printf ("SELECT local_id FROM tasks NATURAL JOIN task_ids WHERE task_id = '%s';", task_id); + + if (sqlite3_prepare_v2 (db, statement, -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (db)); + /* FIXME: use a goto instead, so we can carefully free any + * necessary memory */ + return FALSE; + } + + while (status = sqlite3_step (query)) { + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (db)); + /* FIXME: use a goto instead, so we can carefully free + * any necessary memory */ + return FALSE; + } + + local_id = g_strdup (sqlite3_column_text (query, 0)); + } + + g_free (statement); + sqlite3_free (err); + + /* otherwise, try to find a matching task that we've added locally but + * haven't gotten confirmed by the server yet */ + if (!local_id) { + char *name_str; + + /* FIXME: cut this */ + g_debug ("trying to update a local-only task"); + + name_str = sql_escape_quotes (rtm_task_get_name (task)); + + statement = g_strdup_printf ("SELECT local_id FROM tasks WHERE name = '%s';", name_str); + g_free (name_str); + + if (sqlite3_prepare_v2 (db, statement, -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (db)); + /* FIXME: use a goto instead, so we can carefully free any + * necessary memory */ + return FALSE; + } + + while (status = sqlite3_step (query)) { + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (db)); + /* FIXME: use a goto instead, so we can carefully free + * any necessary memory */ + return FALSE; + } + + local_id = g_strdup (sqlite3_column_text (query, 0)); + } + + g_free (statement); + sqlite3_free (err); + } + + if (task_existed) + *task_existed = (local_id != NULL); + + /* FIXME: cut this */ + g_debug ("got local_id: %s", local_id); + + local_id_formatted = local_id ? + g_strdup_printf ("'%s'", local_id) : + g_strdup ("NULL"); + g_free (local_id); + + /* FIXME: cut this */ + g_debug ("formatted local_id:\n%s", local_id_formatted); + + success &= db_insert_or_update_local_task (db, task, + local_id_formatted, local_changes); + g_free (local_id_formatted); + + return success; +} + +static GList* +db_get_tasks_to_add_names (MilkCache *cache) +{ + MilkCachePrivate *priv; + char *statement; + char *err = NULL; + sqlite3_stmt *query = NULL; + GList *names = NULL; + gint status; + + priv = MILK_CACHE_PRIVATE (cache); + + statement = g_strdup_printf ("SELECT name FROM tasks " + "WHERE task_id IS NULL;"); + + if (sqlite3_prepare_v2 (priv->db, statement, -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (priv->db)); + goto db_get_tasks_to_add_names_ERROR; + } + + while ((status = sqlite3_step (query))) { + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (priv->db)); + goto db_get_tasks_to_add_names_ERROR; + } + + names = g_list_prepend (names, + g_strdup (sqlite3_column_text (query, 0))); + } + + goto db_get_tasks_to_add_names_OUT; + +db_get_tasks_to_add_names_ERROR: + g_list_foreach (names, (GFunc) g_free, NULL); + g_list_free (names); + names = NULL; + +db_get_tasks_to_add_names_OUT: + + g_free (statement); + sqlite3_free (err); + + return names; +} + +static GList* +db_get_tasks_to_change (MilkCache *cache) +{ + MilkCachePrivate *priv; + char *statement; + char *err = NULL; + sqlite3_stmt *query = NULL; + GList *tasks = NULL; + gint status; + + priv = MILK_CACHE_PRIVATE (cache); + + statement = g_strdup_printf ("SELECT task_id,name,due_date,list_id," + "taskseries_id FROM tasks " + "WHERE local_changes=1;"); + + if (sqlite3_prepare_v2 (priv->db, statement, -1, &query, NULL)) { + g_warning ("failed to prepare statement: %s\n", + sqlite3_errmsg (priv->db)); + goto db_get_tasks_to_change_ERROR; + } + + while ((status = sqlite3_step (query))) { + RtmTask *task; + GTimeVal timeval; + + if (status == SQLITE_DONE) { + break; + } else if (status != SQLITE_ROW) { + g_warning ("error stepping through SQL statement: %s", + sqlite3_errmsg (priv->db)); + goto db_get_tasks_to_change_ERROR; + } + + task = rtm_task_new (); + rtm_task_set_id (task, (gchar*) sqlite3_column_text (query, 0)); + rtm_task_set_name (task, + (gchar*) sqlite3_column_text (query, 1)); + + if (db_date_column_to_timeval (query, 2, &timeval)) + rtm_task_set_due_date (task, &timeval); + + rtm_task_set_list_id (task, + (gchar*) sqlite3_column_text (query, 3)); + + rtm_task_set_taskseries_id (task, + (gchar*) sqlite3_column_text (query, 4)); + + tasks = g_list_prepend (tasks, task); + } + + goto db_get_tasks_to_change_OUT; + +db_get_tasks_to_change_ERROR: + g_list_foreach (tasks, (GFunc) g_object_unref, NULL); + g_list_free (tasks); + tasks = NULL; + +db_get_tasks_to_change_OUT: + + g_free (statement); + sqlite3_free (err); + + return tasks; +} + +static gboolean +cache_send_new_tasks (MilkCache *cache, + char *timeline) +{ + MilkCachePrivate *priv; + gboolean success = TRUE; + GList *names = NULL; + + priv = MILK_CACHE_PRIVATE (cache); + + /* FIXME: have a single function to get the sets of (new, changed, + * completed, deleted) tasks, then deal with each of them here */ + names = db_get_tasks_to_add_names (cache); + + /* FIXME: cut this */ + g_debug ("trying to send %d new tasks", g_list_length (names)); + + /* FIXME: this entire block needs to be sequential as a whole but also + * async as a whole */ + if (names) { + GList *tasks_added = NULL; + GList *l; + + tasks_added = milk_auth_tasks_add (priv->auth, timeline, names); + + for (l = tasks_added; l; l = l->next) { + /* FIXME: cut this */ + g_debug (G_STRLOC ": trying to add task ID to " + "newly-inserted task: '%s' (%s);" + " priority: '%s', " + " list ID: '%s', " + " taskseries ID: '%s', " + , + rtm_task_get_name (l->data), + rtm_task_get_id (l->data), + rtm_task_get_priority (l->data), + rtm_task_get_list_id (l->data), + rtm_task_get_taskseries_id (l->data) + ); + + /* mark these as having local changes so we'll send all + * there non-name attributes when we send the changes, + * in the next step */ + db_insert_or_update_task (priv->db, l->data, TRUE, + NULL); + } + + /* not the most complete verification, but probably fine */ + success &= (g_list_length (tasks_added) == + g_list_length (names)); + + g_list_foreach (tasks_added, (GFunc) g_object_unref, NULL); + g_list_free (tasks_added); + } + + g_list_foreach (names, (GFunc) g_free, NULL); + g_list_free (names); + + return success; +} + +/* FIXME: cut this */ +static void +set_due_date (RtmTask *task, + const char *date_str) +{ + GTimeVal timeval = {0}; + + /* FIXME: cut this */ + g_debug ("going to decode date: '%s'", date_str); + + g_time_val_from_iso8601 (date_str, &timeval); + + rtm_task_set_due_date (task, &timeval); +} + +static gboolean +db_tasks_mark_as_synced (MilkCache *cache, + GList *tasks) +{ + MilkCachePrivate *priv; + gboolean success = TRUE; + GString *tasks_builder = NULL; + GList *l; + char *task_ids; + gboolean first = TRUE; + char *statement; + char *err = NULL; + + priv = MILK_CACHE_PRIVATE (cache); + + tasks_builder = g_string_new (""); + for (l = tasks; l; l = l->next) { + const char *format; + + format = first ? "%s" : ",%s"; + + g_string_append_printf (tasks_builder, format, + rtm_task_get_id (l->data)); + first = FALSE; + } + task_ids = g_string_free (tasks_builder, FALSE); + + statement = g_strdup_printf ("UPDATE tasks " + "SET local_changes=0 " + "WHERE task_id IN (%s);", + task_ids); + + if (sqlite3_exec (priv->db, statement, NULL, NULL, &err)) { + g_warning ("failed to acknowledge local changes were pushed " + "to the server: %s\n", + sqlite3_errmsg (priv->db)); + success = FALSE; + } + + g_free (statement); + sqlite3_free (err); + + g_free (task_ids); +} + +static gboolean +cache_send_changed_tasks (MilkCache *cache, + char *timeline) +{ + MilkCachePrivate *priv; + gboolean success = TRUE; + GList *tasks_to_change; + GList *tasks_sent; + + priv = MILK_CACHE_PRIVATE (cache); + + tasks_to_change = db_get_tasks_to_change (cache); + + if (!tasks_to_change) + return; + + tasks_sent = milk_auth_tasks_send_changes (priv->auth, + timeline, tasks_to_change); + + /* as above, if we miss any of these, the worst case is just resending + * them later (vs. data loss or false caching) -- it's still not great + * if you're on a flakey network, though */ + success &= (g_list_length (tasks_sent) == + g_list_length (tasks_to_change)); + + success &= db_tasks_mark_as_synced (cache, tasks_sent); + + /* FIXME: cut this */ + g_debug ("successfully updated all the tasks: %d", success); + + g_list_foreach (tasks_to_change, (GFunc) g_object_unref, NULL); + g_list_free (tasks_to_change); + g_list_free (tasks_sent); + + return success; +} + +/* FIXME: make this async */ +static gboolean +cache_send_changes (MilkCache *cache) +{ + MilkCachePrivate *priv; + gboolean success = TRUE; + char *timeline; + + priv = MILK_CACHE_PRIVATE (cache); + + /* FIXME: this makes the send_changes_hint fairly pointless, though we + * may want to rename this function similarly */ + if (milk_auth_get_state (priv->auth) != MILK_AUTH_STATE_CONNECTED) { + g_warning ("trying to send changes before auth has connected - we shouldn't be doing this (at least not until we've connected once)"); + + return; + } + + /* FIXME: cut this */ + g_debug (G_STRLOC ": sending new (and updated tasks, once implemented) "); + + timeline = milk_auth_timeline_create (priv->auth, NULL); + + success &= cache_send_new_tasks (cache, timeline); + success &= cache_send_changed_tasks (cache, timeline); + + /* FIXME: also get all the deleted tasks and delete them, and all the + * completed tasks (complete_date IS NOT NULL && local_changes=1), and + * apply those on the server */ + + /* FIXME: cut this */ + g_debug ("looks like we successfully added the pending tasks"); + + /* XXX: if we use the same timeline for creating these new tasks as + * updating their attributes, we won't need to have this insane 2-step + * process, right? (and thus updating the local_changes above shouldn't + * be necessary) */ + + g_free (timeline); + + return success; +} + +/* FIXME: make this async */ +static gboolean +cache_receive_changes (MilkCache *cache) +{ + MilkCachePrivate *priv = MILK_CACHE_PRIVATE (cache); + GList *rtm_tasks; + GList *l; + GList *added = NULL, *changed = NULL, *finished = NULL; + GTimeVal current_time; + char *new_sync; + GError *error = NULL; + + if (milk_auth_get_state (priv->auth) != MILK_AUTH_STATE_CONNECTED) { + return TRUE; + } + + g_get_current_time (¤t_time); + new_sync = g_time_val_to_iso8601 (¤t_time); + + rtm_tasks = milk_auth_get_tasks (priv->auth, priv->last_sync, &error); + if (error) { + g_error (G_STRLOC ": failed to retrieve latest tasks: %s", + error->message); + g_clear_error (&error); + goto cache_receive_changes_ERROR; + } + + /* We don't wrap this in a begin...commit...rollback because it's better + * to let the individual items get committed automatically. If one of + * them fails, then we won't update the "last_sync" date. So the next + * time we sync, we'll start from where we did last time, and so on, + * until they all succeed at once. Any tasks that are already in the DB + * will be "updated", whether their content actually changed or not + * (likely not) */ + for (l = rtm_tasks; l; l = g_list_delete_link (l, l)) { + GtkTreeIter iter; + RtmTask *task; + gboolean task_existed; + + task = RTM_TASK (l->data); + + if (!db_insert_or_update_task (priv->db, task, FALSE, + &task_existed)) + goto cache_receive_changes_ERROR; + + /* FIXME: read the task back out of the DB for any sort + * of normalization to happen transparently, and for + * more commonality in the code reading out of the DB + */ + + /* Task is deleted or completed */ + if (task_is_finished (task)) { + finished = g_list_prepend (finished, task); + + /* Task has been changed */ + } else if (task_existed) { + changed = g_list_prepend (changed, task); + + /* Task is new */ + } else { + added = g_list_prepend (added, task); + } + } + + cache_tasks_notify (cache, added, signals[TASK_ADDED]); + cache_tasks_notify (cache, changed, signals[TASK_CHANGED]); + cache_tasks_notify (cache, finished, signals[TASK_FINISHED]); + + g_list_free (added); + g_list_free (changed); + g_list_free (finished); + + if (db_set_last_sync (priv->db, new_sync)) { + g_free (priv->last_sync); + priv->last_sync = new_sync; + } else { + g_warning ("failed to set new sync timestamp to %d", new_sync); + goto cache_receive_changes_ERROR; + } + + return TRUE; + +cache_receive_changes_ERROR: + g_free (new_sync); + + return FALSE; +} + +static gboolean +cache_send_receive_changes (MilkCache *cache) +{ + return cache_send_changes (cache) && cache_receive_changes (cache); +} + +static void +restart_send_receive_poll (MilkCache *cache, + gboolean poll_first) +{ + MilkCachePrivate *priv; + + priv = MILK_CACHE_PRIVATE (cache); + + /* FIXME: cut this */ + g_debug ("restarting the send/receive poll"); + + if (priv->update_id) + g_source_remove (priv->update_id); + + if (poll_first) + cache_send_receive_changes (cache); + + priv->update_id = g_timeout_add (CACHE_UPDATE_PERIOD, + (GSourceFunc) cache_send_receive_changes, cache); +} + +task_is_finished (RtmTask *task) +{ + return (rtm_task_get_completed_date (task) || + rtm_task_get_deleted_date (task)); +} + +static void +auth_notify_cb (MilkAuth *auth, + GParamSpec *spec, + MilkCache *cache) +{ + if (milk_auth_get_state (auth) == MILK_AUTH_STATE_CONNECTED) { + cache_send_receive_changes (cache); + } +} + +void +milk_cache_set_auth (MilkCache *cache, + MilkAuth *auth) +{ + MilkCachePrivate *priv; + + g_return_if_fail (MILK_IS_CACHE (cache)); + g_return_if_fail (MILK_IS_AUTH (auth)); + + priv = MILK_CACHE_PRIVATE (cache); + + if (priv->auth) { + g_object_unref (priv->auth); + } + priv->auth = g_object_ref (auth); + + restart_send_receive_poll (cache, FALSE); + + g_signal_emit (cache, signals[CLEARED], 0); + + g_signal_connect (priv->auth, "notify::state", + G_CALLBACK (auth_notify_cb), cache); + auth_notify_cb (priv->auth, NULL, cache); +} + +static void +milk_cache_get_property (GObject *object, + guint property_id, + GValue *value, + GParamSpec *pspec) +{ + MilkCachePrivate *priv = MILK_CACHE_PRIVATE (object); + + switch (property_id) + { + case PROP_AUTH: + g_value_set_object (value, priv->auth); + break; + + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, + pspec); + } +} + +static void +milk_cache_set_property (GObject *object, + guint property_id, + const GValue *value, + GParamSpec *pspec) +{ + MilkCachePrivate *priv; + MilkCache *cache; + + cache = MILK_CACHE (object); + priv = MILK_CACHE_PRIVATE (cache); + + switch (property_id) + { + case PROP_AUTH: + milk_cache_set_auth (cache, g_value_get_object (value)); + break; + + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, + pspec); + } +} + +GList* +milk_cache_get_active_tasks (MilkCache *cache) +{ + MilkCachePrivate *priv; + GList *tasks; + + g_return_val_if_fail (MILK_IS_CACHE (cache), NULL); + + priv = MILK_CACHE_PRIVATE (cache); + + tasks = db_get_tasks_active (priv->db); + + return tasks; +} + +char* +milk_cache_timeline_create (MilkCache *cache, + GError **error) +{ + MilkCachePrivate *priv; + + g_return_val_if_fail (MILK_IS_CACHE (cache), NULL); + + priv = MILK_CACHE_PRIVATE (cache); + + return milk_auth_timeline_create (priv->auth, error); +} + +/* FIXME: is the timeline argument even useful here? We should be able to just + * assume it's being added right now, right? */ + +/* FIXME: either fill in error appropriately or cut it as an argument, so the + * caller doesn't assume it will be meaningful if we return NULL */ +RtmTask* +milk_cache_task_add (MilkCache *cache, + char *timeline, + const char *name, + GError **error) +{ + MilkCachePrivate *priv; + RtmTask *task; + + g_return_val_if_fail (MILK_IS_CACHE (cache), NULL); + + priv = MILK_CACHE_PRIVATE (cache); + + task = db_add_local_only_task (priv->db, name); + if (task) { + GList *tasks; + + tasks = g_list_prepend (NULL, task); + cache_tasks_notify (cache, tasks, signals[TASK_ADDED]); + restart_send_receive_poll (cache, TRUE); + + g_list_free (tasks); + } + + return task; +} + +char* +milk_cache_task_complete (MilkCache *cache, + char *timeline, + RtmTask *task, + GError **error) +{ + MilkCachePrivate *priv; + + g_return_val_if_fail (MILK_IS_CACHE (cache), NULL); + + priv = MILK_CACHE_PRIVATE (cache); + + /* FIXME: mark the task as "to-complete" and set up the periodic task + * that pushes the changes to the server; then immediately emit the + * "task-finished" signal, so the model will immediately reflect that */ + + return milk_auth_task_complete (priv->auth, timeline, task, error); +} + +char* +milk_cache_task_delete (MilkCache *cache, + char *timeline, + RtmTask *task, + GError **error) +{ + MilkCachePrivate *priv; + + g_return_val_if_fail (MILK_IS_CACHE (cache), NULL); + + priv = MILK_CACHE_PRIVATE (cache); + + /* FIXME: mark the task as "to-delete" and set up the periodic task that + * pushes the changes to the server; then immediately emit the + * "task-finished" signal, so the model will immediately reflect that */ + + return milk_auth_task_delete (priv->auth, timeline, task, error); +} + +/* XXX: this won't be necessary when the auth handles this transparently; or at + * least this will merely be a signal to the auth that we're ready to + * authenticate when it is */ +void +milk_cache_authenticate (MilkCache *cache) +{ + MilkCachePrivate *priv; + + g_return_if_fail (MILK_IS_CACHE (cache)); + + priv = MILK_CACHE_PRIVATE (cache); + + milk_auth_log_in (priv->auth); +} + +static void +milk_cache_dispose (GObject *object) +{ + MilkCachePrivate *priv = MILK_CACHE_PRIVATE (object); + + if (priv->auth) { + g_object_unref (priv->auth); + priv->auth = NULL; + } + + if (priv->update_id) { + g_source_remove (priv->update_id); + priv->update_id = 0; + } +} + +static void +milk_cache_finalize (GObject *object) +{ + MilkCachePrivate *priv = MILK_CACHE_PRIVATE (object); + + g_free (priv->last_sync); + + /* FIXME: should we do this at atexit() instead, for better safety? */ + sqlite3_close (priv->db); +} + +static void +milk_cache_class_init (MilkCacheClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + g_type_class_add_private (klass, sizeof (MilkCachePrivate)); + + object_class->get_property = milk_cache_get_property; + object_class->set_property = milk_cache_set_property; + object_class->dispose = milk_cache_dispose; + object_class->finalize = milk_cache_finalize; + + g_object_class_install_property + (object_class, + PROP_AUTH, + g_param_spec_object + ("auth", + "Authentication proxy", + "Remember The Milk authentication proxy.", + MILK_TYPE_AUTH, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | + G_PARAM_STATIC_STRINGS)); + + signals[CLEARED] = g_signal_new + ("cleared", + MILK_TYPE_CACHE, G_SIGNAL_RUN_LAST, 0, NULL, NULL, + g_cclosure_marshal_VOID__VOID, G_TYPE_NONE, 0); + + signals[TASK_ADDED] = g_signal_new + ("task-added", + MILK_TYPE_CACHE, G_SIGNAL_RUN_LAST, 0, NULL, NULL, + g_cclosure_marshal_VOID__OBJECT, G_TYPE_NONE, 1, + RTM_TYPE_TASK); + + signals[TASK_FINISHED] = g_signal_new + ("task-finished", + MILK_TYPE_CACHE, G_SIGNAL_RUN_LAST, 0, NULL, NULL, + g_cclosure_marshal_VOID__OBJECT, G_TYPE_NONE, 1, RTM_TYPE_TASK); + + signals[TASK_CHANGED] = g_signal_new + ("task-changed", + MILK_TYPE_CACHE, G_SIGNAL_RUN_LAST, 0, NULL, NULL, + g_cclosure_marshal_VOID__OBJECT, G_TYPE_NONE, 1, + RTM_TYPE_TASK); +} + +static void +milk_cache_init (MilkCache *self) +{ + MilkCachePrivate *priv; + + self->priv = priv = G_TYPE_INSTANCE_GET_PRIVATE ( + self, MILK_TYPE_CACHE, MilkCachePrivate); + + /* open the DB, creating a new one if necessary */ + priv->db = db_open (); + db_update_schema (priv->db); + priv->last_sync = db_get_last_sync (priv->db); +} + +MilkCache* +milk_cache_get_default () +{ + if (!default_cache) { + default_cache = g_object_new (MILK_TYPE_CACHE, + "auth", milk_auth_get_default (), + NULL); + } + + return default_cache; +} diff --git a/src/milk-cache.h b/src/milk-cache.h new file mode 100644 index 0000000..d774fe0 --- /dev/null +++ b/src/milk-cache.h @@ -0,0 +1,89 @@ +/* + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, + * Boston, MA 02110-1301 USA + * + * Authors: Travis Reitter + */ + +#ifndef _MILK_CACHE_H +#define _MILK_CACHE_H + +G_BEGIN_DECLS + +#define MILK_TYPE_CACHE milk_cache_get_type() + +#define MILK_CACHE(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST ((obj), \ + MILK_TYPE_CACHE, MilkCache)) + +#define MILK_CACHE_CLASS(klass) \ + (G_TYPE_CHECK_CLASS_CAST ((klass), \ + MILK_TYPE_CACHE, MilkCacheClass)) + +#define MILK_IS_CACHE(obj) \ + (G_TYPE_CHECK_INSTANCE_TYPE ((obj), \ + MILK_TYPE_CACHE)) + +#define MILK_IS_CACHE_CLASS(klass) \ + (G_TYPE_CHECK_CLASS_TYPE ((klass), \ + MILK_TYPE_CACHE)) + +#define MILK_CACHE_GET_CLASS(obj) \ + (G_TYPE_INSTANCE_GET_CLASS ((obj), \ + MILK_TYPE_CACHE, MilkCacheClass)) + +typedef struct _MilkCache MilkCache; +typedef struct _MilkCacheClass MilkCacheClass; +typedef struct _MilkCachePrivate MilkCachePrivate; + +struct _MilkCache +{ + GObject parent; + MilkCachePrivate *priv; +}; + +struct _MilkCacheClass +{ + GObjectClass parent_class; +}; + +GType milk_cache_get_type (void); + + +MilkCache* milk_cache_get_default (void); + +void milk_cache_authenticate (MilkCache *cache); + +GList* milk_cache_get_active_tasks (MilkCache *cache); + +char* milk_cache_timeline_create (MilkCache *cache, + GError **error); + +RtmTask* milk_cache_task_add (MilkCache *cache, + char *timeline, + const char *name, + GError **error); + +char* milk_cache_task_complete (MilkCache *cache, + char *timeline, + RtmTask *task, + GError **error); + +char* milk_cache_task_delete (MilkCache *cache, + char *timeline, + RtmTask *task, + GError **error); + +#endif /* _MILK_CACHE_H */ diff --git a/src/milk-main-window.c b/src/milk-main-window.c index e159b2b..abf9ec7 100644 --- a/src/milk-main-window.c +++ b/src/milk-main-window.c @@ -26,7 +26,7 @@ #include #include "milk-main-window.h" -#include "milk-auth.h" +#include "milk-cache.h" #include "milk-task-model.h" G_DEFINE_TYPE (MilkMainWindow, milk_main_window, HILDON_TYPE_WINDOW) @@ -40,7 +40,7 @@ static GtkWidget *default_window = NULL; struct _MilkMainWindowPrivate { - MilkAuth *auth; + MilkCache *cache; GtkWidget *app_menu; @@ -103,19 +103,13 @@ new_task_clicked_cb (GtkButton *button, g_debug ("FIXME: implement 'new task' action"); } -/* XXX: The latency between clicking "complete" and actually removing the task - * from the view after polling the server is very long, so there's an obvious - * lag -- it will be completely transparent (and look very fast) as soon as - * we've got a cache in place */ -static void -complete_clicked_cb (GtkButton *button, - MilkMainWindow *window) +static GList* +get_selected_tasks (MilkMainWindow *window) { MilkMainWindowPrivate *priv; GList *rows; GtkTreeModel *model; - char *timeline; - GError *error = NULL; + GList *tasks = NULL; priv = MILK_MAIN_WINDOW_PRIVATE (window); @@ -126,85 +120,72 @@ complete_clicked_cb (GtkButton *button, HILDON_TOUCH_SELECTOR (priv->task_view), TASK_VIEW_COLUMN_TITLE); - timeline = milk_auth_timeline_create (priv->auth, &error); + while (rows) { + GtkTreeIter iter; + RtmTask *task; + + gtk_tree_model_get_iter (model, &iter, rows->data); + gtk_tree_model_get (model, &iter, + MILK_TASK_MODEL_COLUMN_TASK, &task, + -1); + + tasks = g_list_prepend (tasks, task); + rows = g_list_delete_link (rows, rows); + } + + return tasks; +} + +static void +complete_clicked_cb (GtkButton *button, + MilkMainWindow *window) +{ + MilkMainWindowPrivate *priv; + GList *tasks; + char *timeline; + GError *error = NULL; + + priv = MILK_MAIN_WINDOW_PRIVATE (window); + + tasks = get_selected_tasks (window); + timeline = milk_cache_timeline_create (priv->cache, &error); if (error) { g_warning (G_STRLOC ": failed to create a timeline: %s", error->message); g_clear_error (&error); } else { - while (rows) { - GtkTreeIter iter; - RtmTask *task; - - gtk_tree_model_get_iter (model, &iter, rows->data); - gtk_tree_model_get (model, &iter, - MILK_TASK_MODEL_COLUMN_TASK, &task, - -1); - - milk_auth_task_complete (priv->auth, timeline, task, - &error); - if (error != NULL) { - g_warning (G_STRLOC ": failed to complete task " - "%s: %s", - rtm_task_get_id (task), - error->message); - g_clear_error (&error); - } - - rows = g_list_delete_link (rows, rows); + while (tasks) { + milk_cache_task_complete (priv->cache, timeline, + tasks->data, &error); + tasks = g_list_delete_link (tasks, tasks); } } } -/* XXX: high latency until we have a cache; see the note for - * complete_clicked_cb() */ static void delete_clicked_cb (GtkButton *button, MilkMainWindow *window) { MilkMainWindowPrivate *priv; - GList *rows; - GtkTreeModel *model; + GList *tasks; char *timeline; GError *error = NULL; priv = MILK_MAIN_WINDOW_PRIVATE (window); - rows = hildon_touch_selector_get_selected_rows ( - HILDON_TOUCH_SELECTOR (priv->task_view), - TASK_VIEW_COLUMN_TITLE); - model = hildon_touch_selector_get_model ( - HILDON_TOUCH_SELECTOR (priv->task_view), - TASK_VIEW_COLUMN_TITLE); - - timeline = milk_auth_timeline_create (priv->auth, &error); + tasks = get_selected_tasks (window); + timeline = milk_cache_timeline_create (priv->cache, &error); if (error) { g_warning (G_STRLOC ": failed to create a timeline: %s", error->message); g_clear_error (&error); } else { - while (rows) { - GtkTreeIter iter; - RtmTask *task; - - gtk_tree_model_get_iter (model, &iter, rows->data); - gtk_tree_model_get (model, &iter, - MILK_TASK_MODEL_COLUMN_TASK, &task, - -1); - - milk_auth_task_delete (priv->auth, timeline, task, - &error); - if (error != NULL) { - g_warning (G_STRLOC ": failed to delete task " - "%s: %s", - rtm_task_get_id (task), - error->message); - g_clear_error (&error); - } - - rows = g_list_delete_link (rows, rows); + while (tasks) { + milk_cache_task_delete (priv->cache, timeline, + tasks->data, &error); + tasks = g_list_delete_link (tasks, tasks); } } } @@ -259,7 +240,7 @@ new_task_entry_activated_cb (GtkEntry *entry, char *timeline; GError *error = NULL; - timeline = milk_auth_timeline_create (priv->auth, &error); + timeline = milk_cache_timeline_create (priv->cache, &error); if (error) { g_warning (G_STRLOC ": failed to create a timeline: %s", @@ -268,13 +249,17 @@ new_task_entry_activated_cb (GtkEntry *entry, } else { RtmTask *task; - task = milk_auth_task_add (priv->auth, timeline, name, + task = milk_cache_task_add (priv->cache, timeline, name, &error); if (task) { /* empty out the entry and show its placeholder * text */ gtk_entry_set_text (entry, ""); gtk_widget_grab_focus (priv->task_view); + + /* FIXME: we should probably scroll to this new + * task in the model view, if it's not currently + * visible (and highlight only it in any case */ } else { g_warning (G_STRLOC ": failed to add task: %s", error->message); @@ -400,13 +385,13 @@ contact_column_render_func (GtkCellLayout *cell_layout, } static gboolean -begin_auth_idle (MilkMainWindow *window) +begin_cache_idle (MilkMainWindow *window) { MilkMainWindowPrivate *priv; priv = MILK_MAIN_WINDOW_PRIVATE (window); - milk_auth_log_in (priv->auth); + milk_cache_authenticate (priv->cache); return FALSE; } @@ -444,11 +429,9 @@ milk_main_window_constructed (GObject* object) /* * Task List */ - priv->auth = milk_auth_get_default (); - model = GTK_TREE_MODEL (milk_task_model_new (priv->auth)); + model = GTK_TREE_MODEL (milk_task_model_new ()); w = hildon_touch_selector_new (); - renderer = gtk_cell_renderer_text_new (); g_object_set (renderer, "ellipsize", PANGO_ELLIPSIZE_END, @@ -484,9 +467,12 @@ milk_main_window_constructed (GObject* object) hildon_window_set_app_menu ( HILDON_WINDOW (self), HILDON_APP_MENU (priv->app_menu)); + /* set up the cache */ + priv->cache = milk_cache_get_default (); + /* break a cyclical dependency by doing this after the window is * constructed */ - g_idle_add ((GSourceFunc) begin_auth_idle, self); + g_idle_add ((GSourceFunc) begin_cache_idle, self); } static void diff --git a/src/milk-task-model.c b/src/milk-task-model.c index a1d6594..cb24974 100644 --- a/src/milk-task-model.c +++ b/src/milk-task-model.c @@ -27,6 +27,7 @@ #include "milk-task-model.h" #include "milk-auth.h" +#include "milk-cache.h" static void milk_task_model_tree_model_init (GtkTreeModelIface *iface); @@ -41,30 +42,18 @@ G_DEFINE_TYPE_EXTENDED (MilkTaskModel, /* less expensive than G_TYPE_INSTANCE_GET_PRIVATE */ #define MILK_TASK_MODEL_PRIVATE(o) ((MILK_TASK_MODEL ((o)))->priv) -/* FIXME: make this configurable at runtime, pref. as a gconf value */ -/* time between syncing with the server, in ms */ -#define MODEL_UPDATE_PERIOD 60000 - struct _MilkTaskModelPrivate { GHashTable *tasks; GtkListStore *store; - MilkAuth *auth; - guint update_id; - char *last_sync; + MilkCache *cache; }; enum { - PROP_AUTH = 1, + PROP_0, + PROP_CACHE, }; -static gboolean -task_is_finished (RtmTask *task) -{ - return (rtm_task_get_completed_date (task) || - rtm_task_get_deleted_date (task)); -} - static GtkTreeModelFlags milk_task_model_get_flags (GtkTreeModel *model) { @@ -243,10 +232,13 @@ milk_task_model_iter_parent (GtkTreeModel *model, GTK_TREE_MODEL (priv->store), iter, child); } +typedef gchar* (*RtmTaskAttrFunc) (RtmTask*); + static gboolean -model_store_find_task (MilkTaskModel *model, - RtmTask *task_in, - GtkTreeIter *iter_in) +model_store_find_task_by_attr (MilkTaskModel *model, + RtmTask *task_in, + RtmTaskAttrFunc attr_func, + GtkTreeIter *iter_in) { MilkTaskModelPrivate *priv = MILK_TASK_MODEL_PRIVATE (model); gboolean valid; @@ -262,8 +254,8 @@ model_store_find_task (MilkTaskModel *model, MILK_TASK_MODEL_COLUMN_TASK, &task, -1); - if (!g_strcmp0 (rtm_task_get_id (task_in), - rtm_task_get_id (task))) { + if (!g_strcmp0 (attr_func (task_in), + attr_func (task))) { *iter_in = iter; found = TRUE; } @@ -278,99 +270,21 @@ model_store_find_task (MilkTaskModel *model, } static gboolean -update_model (MilkTaskModel *model) +model_store_find_task (MilkTaskModel *model, + RtmTask *task_in, + GtkTreeIter *iter_in) { - MilkTaskModelPrivate *priv = MILK_TASK_MODEL_PRIVATE (model); - GList *rtm_tasks; - GList *l; - GTimeVal current_time; - char *new_sync; - GError *error = NULL; - - if (milk_auth_get_state (priv->auth) != MILK_AUTH_STATE_CONNECTED) { - return TRUE; - } - - g_get_current_time (¤t_time); - new_sync = g_time_val_to_iso8601 (¤t_time); - rtm_tasks = milk_auth_get_tasks (priv->auth, priv->last_sync, &error); - - if (error) { - g_error (G_STRLOC ": failed to retrieve latest tasks: %s", - error->message); - g_clear_error (&error); - } else { - g_free (priv->last_sync); - priv->last_sync = new_sync; - } - - /* Populate model */ - for (l = rtm_tasks; l; l = g_list_delete_link (l, l)) { - GtkTreeIter iter; - RtmTask *rtm_task; - const char *id; - gboolean task_in_store; - - rtm_task = RTM_TASK (l->data); - - id = rtm_task_get_id (rtm_task); - g_hash_table_insert (priv->tasks, g_strdup (id), - g_object_ref (rtm_task)); - - task_in_store = model_store_find_task (model, rtm_task, &iter); - - /* Task is deleted or completed */ - if (task_is_finished (rtm_task)) { - if (task_in_store) { - gtk_list_store_remove (priv->store, &iter); - } - /* Task has been changed */ - } else if (task_in_store) { - RtmTask *old_task; - GtkTreePath *path; - - /* rtm-glib doesn't re-use task structs when they're - * updated, so we have to replace the changed */ - gtk_tree_model_get ( - GTK_TREE_MODEL (priv->store), &iter, - MILK_TASK_MODEL_COLUMN_TASK, &old_task, - -1); - - gtk_list_store_set ( - priv->store, &iter, - MILK_TASK_MODEL_COLUMN_TASK, rtm_task, - -1); - - path = gtk_tree_model_get_path ( - GTK_TREE_MODEL (priv->store), &iter); - gtk_tree_model_row_changed ( - GTK_TREE_MODEL (priv->store), - path, &iter); - gtk_tree_path_free (path); - - g_object_unref (old_task); - - /* Task is new */ - } else { - gtk_list_store_append (priv->store, &iter); - gtk_list_store_set ( - priv->store, &iter, - MILK_TASK_MODEL_COLUMN_TASK, rtm_task, - -1); - } - } - - return TRUE; + return model_store_find_task_by_attr (model, task_in, rtm_task_get_id, + iter_in); } -static void -auth_notify_cb (MilkAuth *auth, - GParamSpec *spec, - MilkTaskModel *model) +static gboolean +model_store_find_local_only_task (MilkTaskModel *model, + RtmTask *task_in, + GtkTreeIter *iter_in) { - if (milk_auth_get_state (auth) == MILK_AUTH_STATE_CONNECTED) { - update_model (model); - } + return model_store_find_task_by_attr (model, task_in,rtm_task_get_name, + iter_in); } static void @@ -410,35 +324,151 @@ rows_reordered_cb (GtkTreeModel *model, new_order); } -void -milk_task_model_set_auth (MilkTaskModel *model, - MilkAuth *auth) +static void +cache_cleared_cb (MilkCache *cache, + MilkTaskModel *model) { MilkTaskModelPrivate *priv; + priv = MILK_TASK_MODEL_PRIVATE (model); - g_return_if_fail (model); - g_return_if_fail (MILK_IS_TASK_MODEL (model)); - g_return_if_fail (auth); - g_return_if_fail (MILK_IS_AUTH (auth)); + gtk_list_store_clear (priv->store); +} + +static void +cache_task_added_cb (MilkCache *cache, + RtmTask *task, + MilkTaskModel *model) +{ + MilkTaskModelPrivate *priv; + GtkTreeIter iter; + const char *id; + gboolean task_in_store; + + priv = MILK_TASK_MODEL_PRIVATE (model); + + /* local-only tasks don't have a set task ID */ + id = rtm_task_get_id (task); + if (id) { + /* clear out any entries for the task created before we knew its + * server-side ID */ + g_hash_table_remove (priv->tasks, rtm_task_get_name (task)); + } else { + id = rtm_task_get_name (task); + } + g_return_if_fail (id); + + g_hash_table_insert (priv->tasks, g_strdup (id), + g_object_ref (task)); + + task_in_store = model_store_find_task (model, task, &iter); + + gtk_list_store_append (priv->store, &iter); + gtk_list_store_set (priv->store, &iter, + MILK_TASK_MODEL_COLUMN_TASK, task, -1); +} + +static void +cache_task_changed_cb (MilkCache *cache, + RtmTask *task, + MilkTaskModel *model) +{ + MilkTaskModelPrivate *priv; + GtkTreeIter iter; + const char *id; + gboolean task_in_store; + RtmTask *old_task; + GtkTreePath *path; priv = MILK_TASK_MODEL_PRIVATE (model); - if (priv->auth) { - g_object_unref (priv->auth); + id = rtm_task_get_id (task); + g_hash_table_insert (priv->tasks, g_strdup (id), + g_object_ref (task)); + + /* try to find a local-only version of this task first, to upgrade it to + * remote status */ + task_in_store = model_store_find_local_only_task (model, task, &iter); + if (!task_in_store) { + /* FIXME: cut this */ + g_debug ("task (supposedly) was already known remotely"); + + task_in_store = model_store_find_task (model, task, &iter); + } else { + /* FIXME: cut this */ + g_debug ("task was *NOT* known remotely"); } - priv->auth = g_object_ref (auth); - if (priv->update_id) { - g_source_remove (priv->update_id); + /* rtm-glib doesn't re-use task structs when they're updated, so we have + * to replace the changed */ + gtk_tree_model_get (GTK_TREE_MODEL (priv->store), &iter, + MILK_TASK_MODEL_COLUMN_TASK, &old_task, -1); + + gtk_list_store_set (priv->store, &iter, + MILK_TASK_MODEL_COLUMN_TASK, task, -1); + + path = gtk_tree_model_get_path (GTK_TREE_MODEL (priv->store), &iter); + gtk_tree_model_row_changed (GTK_TREE_MODEL (priv->store), path, &iter); + gtk_tree_path_free (path); + + g_object_unref (old_task); +} + +static void +cache_task_finished_cb (MilkCache *cache, + RtmTask *task, + MilkTaskModel *model) +{ + MilkTaskModelPrivate *priv; + GtkTreeIter iter; + const char *id; + gboolean task_in_store; + + priv = MILK_TASK_MODEL_PRIVATE (model); + + id = rtm_task_get_id (task); + g_hash_table_insert (priv->tasks, g_strdup (id), + g_object_ref (task)); + + task_in_store = model_store_find_task (model, task, &iter); + + if (task_in_store) + gtk_list_store_remove (priv->store, &iter); +} + +static void +set_cache (MilkTaskModel *model, + MilkCache *cache) +{ + MilkTaskModelPrivate *priv; + GList *tasks, *l; + + g_return_if_fail (MILK_IS_TASK_MODEL (model)); + g_return_if_fail (MILK_IS_CACHE (cache)); + + priv = MILK_TASK_MODEL_PRIVATE (model); + + if (priv->cache) { + g_object_unref (priv->cache); } - priv->update_id = g_timeout_add (MODEL_UPDATE_PERIOD, - (GSourceFunc) update_model, model); + priv->cache = g_object_ref (cache); gtk_list_store_clear (priv->store); - g_signal_connect (priv->auth, "notify::state", - G_CALLBACK (auth_notify_cb), model); - auth_notify_cb (priv->auth, NULL, model); + g_signal_connect (cache, "cleared", G_CALLBACK (cache_cleared_cb), + model); + g_signal_connect (cache, "task-added", G_CALLBACK (cache_task_added_cb), + model); + g_signal_connect (cache, "task-changed", + G_CALLBACK (cache_task_changed_cb), model); + g_signal_connect (cache, "task-finished", + G_CALLBACK (cache_task_finished_cb), model); + + /* do the initial fill from the cache */ + tasks = milk_cache_get_active_tasks (cache); + for (l = tasks; l; l = l->next) { + cache_task_added_cb (cache, l->data, model); + } + g_list_free (tasks); } static void @@ -451,8 +481,8 @@ milk_task_model_get_property (GObject *object, switch (property_id) { - case PROP_AUTH: - g_value_set_object (value, priv->auth); + case PROP_CACHE: + g_value_set_object (value, priv->cache); break; default: @@ -469,8 +499,8 @@ milk_task_model_set_property (GObject *object, { switch (property_id) { - case PROP_AUTH: - milk_task_model_set_auth (MILK_TASK_MODEL (object), + case PROP_CACHE: + set_cache (MILK_TASK_MODEL (object), g_value_get_object (value)); break; @@ -485,9 +515,18 @@ milk_task_model_dispose (GObject *object) { MilkTaskModelPrivate *priv = MILK_TASK_MODEL_PRIVATE (object); - if (priv->auth) { - g_object_unref (priv->auth); - priv->auth = NULL; + g_signal_handlers_disconnect_by_func (priv->cache, cache_cleared_cb, + object); + g_signal_handlers_disconnect_by_func (priv->cache, cache_task_added_cb, + object); + g_signal_handlers_disconnect_by_func (priv->cache, + cache_task_changed_cb, object); + g_signal_handlers_disconnect_by_func (priv->cache, + cache_task_finished_cb, object); + + if (priv->cache) { + g_object_unref (priv->cache); + priv->cache = NULL; } g_signal_handlers_disconnect_by_func (priv->store, row_changed_cb, @@ -504,11 +543,6 @@ milk_task_model_dispose (GObject *object) priv->store = NULL; } - if (priv->update_id) { - g_source_remove (priv->update_id); - priv->update_id = 0; - } - if (priv->tasks) { g_hash_table_destroy (priv->tasks); priv->tasks = NULL; @@ -518,16 +552,6 @@ milk_task_model_dispose (GObject *object) } static void -milk_task_model_finalize (GObject *object) -{ - MilkTaskModelPrivate *priv = MILK_TASK_MODEL_PRIVATE (object); - - g_free (priv->last_sync); - - G_OBJECT_CLASS (milk_task_model_parent_class)->finalize (object); -} - -static void milk_task_model_class_init (MilkTaskModelClass *klass) { GObjectClass *object_class = G_OBJECT_CLASS (klass); @@ -537,16 +561,15 @@ milk_task_model_class_init (MilkTaskModelClass *klass) object_class->get_property = milk_task_model_get_property; object_class->set_property = milk_task_model_set_property; object_class->dispose = milk_task_model_dispose; - object_class->finalize = milk_task_model_finalize; g_object_class_install_property (object_class, - PROP_AUTH, + PROP_CACHE, g_param_spec_object - ("auth", - "Authentication proxy", - "Remember The Milk authentication proxy.", - MILK_TYPE_AUTH, + ("cache", + "Cache of tasks", + "Remember The Milk tasks cache.", + MILK_TYPE_CACHE, G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); } @@ -559,8 +582,6 @@ milk_task_model_init (MilkTaskModel *self) self->priv = priv = G_TYPE_INSTANCE_GET_PRIVATE ( self, MILK_TYPE_TASK_MODEL, MilkTaskModelPrivate); - priv->last_sync = NULL; - priv->tasks = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_object_unref); @@ -598,7 +619,8 @@ milk_task_model_tree_model_init (GtkTreeModelIface *iface) } MilkTaskModel* -milk_task_model_new (MilkAuth *auth) +milk_task_model_new () { - return g_object_new (MILK_TYPE_TASK_MODEL, "auth", auth, NULL); + return g_object_new (MILK_TYPE_TASK_MODEL, + "cache", milk_cache_get_default (), NULL); } diff --git a/src/milk-task-model.h b/src/milk-task-model.h index e48b75e..5dee531 100644 --- a/src/milk-task-model.h +++ b/src/milk-task-model.h @@ -69,7 +69,7 @@ struct _MilkTaskModelClass GType milk_task_model_get_type (void); -MilkTaskModel* milk_task_model_new (MilkAuth *auth); +MilkTaskModel* milk_task_model_new (void); void milk_task_model_set_auth (MilkTaskModel *model, MilkAuth *auth);