# HG changeset patch
# Node ID 5ba998bd1b25
# Parent  d3117aac63b3
diff --git a/libpurple/core.c b/libpurple/core.c
--- a/libpurple/core.c
+++ b/libpurple/core.c
@@ -174,13 +174,18 @@
 
 	purple_account_manager_startup();
 	purple_accounts_init();
-	purple_contact_manager_startup();
+
 	purple_savedstatuses_init();
 	purple_notify_init();
 	purple_conversations_init();
 	purple_conversation_manager_startup();
 	purple_whiteboard_manager_startup();
+
 	purple_blist_init();
+	if(!purple_contact_manager_startup(error)) {
+		return FALSE;
+	}
+
 	purple_history_manager_startup();
 	purple_network_init();
 	purple_proxy_init();
diff --git a/libpurple/purplecontactmanager.h b/libpurple/purplecontactmanager.h
--- a/libpurple/purplecontactmanager.h
+++ b/libpurple/purplecontactmanager.h
@@ -31,6 +31,15 @@
 
 G_BEGIN_DECLS
 
+/**
+ * PURPLE_CONTACT_MANAGER_DOMAIN:
+ *
+ * An error domain for contact manager errors.
+ *
+ * Since: 3.0.0
+ */
+#define PURPLE_CONTACT_MANAGER_DOMAIN (g_quark_from_static_string("contact-manager"))
+
 #define PURPLE_TYPE_CONTACT_MANAGER (purple_contact_manager_get_type())
 G_DECLARE_FINAL_TYPE(PurpleContactManager, purple_contact_manager, PURPLE,
                      CONTACT_MANAGER, GObject)
diff --git a/libpurple/purplecontactmanager.c b/libpurple/purplecontactmanager.c
--- a/libpurple/purplecontactmanager.c
+++ b/libpurple/purplecontactmanager.c
@@ -16,12 +16,17 @@
  * along with this program; if not, see <https://www.gnu.org/licenses/>.
  */
 
+#include <glib.h>
 #include <glib/gi18n-lib.h>
 
+#include <sqlite3.h>
+
 #include "purplecontactmanager.h"
 
 #include "purplegdkpixbuf.h"
+#include "purplepath.h"
 #include "purpleprivate.h"
+#include "purplesqlite3.h"
 #include "util.h"
 
 enum {
@@ -34,15 +39,192 @@
 struct _PurpleContactManager {
 	GObject parent;
 
+	sqlite3 *db;
+
 	GHashTable *accounts;
 };
 
 static PurpleContactManager *default_manager = NULL;
 
+/* This variable is used to create code blocks that will be removed when we are
+ * removing the blist api. This only functions as a way to quickly identify
+ * that code by removing this variable.
+ */
+static gboolean BLIST_MIGRATION = TRUE;
+
 /******************************************************************************
  * Helpers
  *****************************************************************************/
 static gboolean
+purple_contact_manager_update_contact(PurpleContactManager *manager,
+                                      PurpleContact *contact,
+                                      GError **error)
+{
+	PurpleAccount *account = NULL;
+	const char *sql = "INSERT INTO contacts("
+	                  " contact_id, account_id, username, display_name, alias)"
+	                  " VALUES (?1, ?2, ?3, ?4, ?5)"
+	                  " ON CONFLICT DO UPDATE "
+	                  " SET account_id='?2', username='?3', display_name='?4',"
+	                  " alias='?5' WHERE contact_id='?1';";
+	sqlite3_stmt *stmt = NULL;
+
+	if(sqlite3_prepare(manager->db, sql, -1, &stmt, NULL) != SQLITE_OK) {
+		g_set_error_literal(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		                    sqlite3_errmsg(manager->db));
+
+		return FALSE;
+	}
+
+	sqlite3_bind_text(stmt, 1, purple_contact_get_id(contact), -1, NULL);
+	account = purple_contact_get_account(contact);
+	sqlite3_bind_text(stmt, 2, purple_account_get_id(account), -1, NULL);
+	sqlite3_bind_text(stmt, 3, purple_contact_get_username(contact), -1, NULL);
+	sqlite3_bind_text(stmt, 4, purple_contact_get_display_name(contact), -1,
+	                  NULL);
+	sqlite3_bind_text(stmt, 5, purple_contact_get_alias(contact), -1, NULL);
+
+	if(sqlite3_step(stmt) != SQLITE_DONE) {
+		g_set_error_literal(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		                    sqlite3_errmsg(manager->db));
+
+		return FALSE;
+	}
+
+	return TRUE;
+}
+
+static gboolean
+purple_contact_manager_update_contact_tags(PurpleContactManager *manager,
+                                           PurpleContact *contact,
+                                           GError **error)
+{
+	PurpleAccount *account = NULL;
+	PurpleTags *tags = NULL;
+	sqlite3_stmt *stmt = NULL;
+	const char *account_id = NULL;
+	const char *contact_id = NULL;
+	const char *sql = NULL;
+	int result = 0;
+
+	g_return_val_if_fail(PURPLE_IS_CONTACT_MANAGER(manager), FALSE);
+	g_return_val_if_fail(PURPLE_IS_CONTACT(contact), FALSE);
+
+	account = purple_contact_get_account(contact);
+	account_id = purple_account_get_id(account);
+	contact_id = purple_contact_get_id(contact);
+
+	/* There's currently no good way to handle removing old contact_tags, since
+	 * we allow multiple values per tag name. So we delete all of the existing
+	 * tags for the contact and then insert the current tags.
+	 */
+	sql = "DELETE FROM contact_tags WHERE account_id=? AND contact_id=?";
+	sqlite3_prepare_v2(manager->db, sql, -1, &stmt, NULL);
+	if(stmt == NULL) {
+		g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		            "Error creating the prepared statement: %s",
+		            sqlite3_errmsg(manager->db));
+
+		return FALSE;
+	}
+
+	sqlite3_bind_text(stmt, 1, account_id, -1, NULL);
+	sqlite3_bind_text(stmt, 2, contact_id, -1, NULL);
+
+	result = sqlite3_step(stmt);
+	if(result != SQLITE_DONE) {
+		g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		            "failed to clear contact_tags: %s",
+		            sqlite3_errmsg(manager->db));
+
+		sqlite3_finalize(stmt);
+
+		return FALSE;
+	}
+
+	sqlite3_finalize(stmt);
+
+	/* Now run through each tag and insert it. */
+	tags = purple_contact_get_tags(contact);
+	for(GList *l = purple_tags_get_all(tags); l != NULL; l = l->next) {
+		char *name = NULL;
+		char *value = NULL;
+
+		if(l->data == NULL) {
+			continue;
+		}
+
+		/* Attempt to insert the tag. We ignore failures via the statement, so
+		 * we can still do normal error checking.
+		 */
+		sql = "INSERT OR IGNORE INTO tags(name) VALUES(?);";
+		sqlite3_prepare_v2(manager->db, sql, -1, &stmt, NULL);
+		if(stmt == NULL) {
+			g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+			            "Error creating the prepared statement: %s",
+			            sqlite3_errmsg(manager->db));
+
+			return FALSE;
+		}
+
+		purple_tag_parse(l->data, &name, &value);
+
+		/* We don't set a clear function for name here, because we use it after
+		 * this statement.
+		 */
+		sqlite3_bind_text(stmt, 1, name, -1, NULL);
+
+		result = sqlite3_step(stmt);
+		if(result != SQLITE_DONE) {
+			g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+			            "failed to insert tag: %s",
+			            sqlite3_errmsg(manager->db));
+
+			g_free(name);
+			g_free(value);
+
+			sqlite3_finalize(stmt);
+
+			return FALSE;
+		}
+
+		/* Now insert the contact_tags row. */
+		sql = "INSERT INTO contact_tags(account_id, contact_id, tag_id, value)"
+		      " SELECT ?, ?, t.id, ?"
+		      " FROM tags t WHERE t.name=?;";
+		sqlite3_prepare_v2(manager->db, sql, -1, &stmt, NULL);
+		if(stmt == NULL) {
+			g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+			            "Error creating the prepared statement: %s",
+			            sqlite3_errmsg(manager->db));
+
+			g_free(name);
+			g_free(value);
+
+			return FALSE;
+		}
+
+		sqlite3_bind_text(stmt, 1, account_id, -1, NULL);
+		sqlite3_bind_text(stmt, 2, contact_id, -1, NULL);
+		sqlite3_bind_text(stmt, 3, value, -1, g_free);
+		sqlite3_bind_text(stmt, 4, name, -1, g_free);
+
+		result = sqlite3_step(stmt);
+		if(result != SQLITE_DONE) {
+			g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+			            "failed to insert tag: %s",
+			            sqlite3_errmsg(manager->db));
+
+			sqlite3_finalize(stmt);
+
+			return FALSE;
+		}
+	}
+
+	return TRUE;
+}
+
+static gboolean
 purple_contact_manager_find_with_username_helper(gconstpointer a,
                                                  gconstpointer b)
 {
@@ -126,6 +308,214 @@
 	return TRUE;
 }
 
+static gboolean
+purple_contact_manager_open(PurpleContactManager *manager,
+                            const char *filename,
+                            GError **error)
+{
+	const char *path = "/im/pidgin/libpurple/contactmanager/";
+	const char *migrations[] = {
+		"01-initial.sql",
+		NULL
+	};
+	gboolean migrated = FALSE;
+	int rc = 0;
+
+	rc = sqlite3_open(filename, &manager->db);
+	if(rc != SQLITE_OK) {
+		g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		            "failed to open file '%s'", filename);
+		g_clear_pointer(&manager->db, sqlite3_close);
+
+		return FALSE;
+	}
+
+	migrated = purple_sqlite3_run_migrations_from_resources(manager->db, path,
+	                                                        migrations, error);
+	if(!migrated) {
+		g_clear_pointer(&manager->db, sqlite3_close);
+	}
+
+	return migrated;
+}
+
+static gboolean
+purple_contact_manager_update_contact_full(PurpleContactManager *manager,
+                                           PurpleContact *contact,
+                                           GError **error)
+{
+	char *errmsg = NULL;
+
+	sqlite3_exec(manager->db, "BEGIN", NULL, NULL, &errmsg);
+	if(errmsg != NULL) {
+		g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		           "failed to begin transaction: %s", errmsg);
+
+		sqlite3_free(errmsg);
+
+		return FALSE;
+	}
+
+	if(!purple_contact_manager_update_contact(manager, contact, error)) {
+		sqlite3_exec(manager->db, "ROLLBACK", NULL, NULL, &errmsg);
+		if(errmsg != NULL) {
+			g_error("failed to rollback transaction: %s", errmsg);
+
+			sqlite3_free(errmsg);
+		}
+
+		return FALSE;
+	}
+
+	if(!purple_contact_manager_update_contact_tags(manager, contact, error)) {
+		sqlite3_exec(manager->db, "ROLLBACK", NULL, NULL, &errmsg);
+		if(errmsg != NULL) {
+			g_error("failed to rollback transaction: %s", errmsg);
+
+			sqlite3_free(errmsg);
+		}
+
+		return FALSE;
+	}
+
+	sqlite3_exec(manager->db, "COMMIT", NULL, NULL, &errmsg);
+	if(errmsg != NULL) {
+		g_set_error(error, PURPLE_CONTACT_MANAGER_DOMAIN, 0,
+		            "failed to commit transaction: %s", errmsg);
+
+		sqlite3_free(errmsg);
+
+		return FALSE;
+	}
+
+	return TRUE;
+}
+
+/******************************************************************************
+ * Migration Helpers
+ *****************************************************************************/
+static void
+purple_contact_manager_sync_tags(PurpleContact *contact) {
+	PurpleBuddy *buddy = NULL;
+	PurpleGroup *group = NULL;
+	PurpleTags *tags = NULL;
+	GHashTable *settings = NULL;
+	GHashTableIter iter;
+	gpointer key = NULL;
+	gpointer value = NULL;
+	char *tag = NULL;
+
+	tags = purple_contact_get_tags(contact);
+	purple_tags_remove_all(tags);
+
+	buddy = g_object_get_data(G_OBJECT(contact), "buddy");
+
+	/* Grab the group and store it as a tag. */
+	group = purple_buddy_get_group(buddy);
+	if(PURPLE_IS_GROUP(group)) {
+		tag = g_strdup_printf("group:%s", purple_group_get_name(group));
+		purple_tags_add(tags, tag);
+		g_free(tag);
+	}
+
+	/* Get the settings for the buddy's blist node and convert them to tags. */
+	settings = purple_blist_node_get_settings(PURPLE_BLIST_NODE(buddy));
+	g_hash_table_iter_init(&iter, settings);
+	while(g_hash_table_iter_next(&iter, &key, &value)) {
+		GString *tag = g_string_new(NULL);
+
+		g_string_append_printf(tag, "%s:", (char *)key);
+
+		if(G_VALUE_HOLDS_BOOLEAN(value)) {
+			g_string_append_printf(tag, "%d", g_value_get_boolean(value));
+		} else if(G_VALUE_HOLDS_INT(value)) {
+			g_string_append_printf(tag, "%d", g_value_get_int(value));
+		} else if(G_VALUE_HOLDS_STRING(value)) {
+			g_string_append(tag, g_value_get_string(value));
+		} else {
+			g_warning("unknown type for setting '%s'", (char *)key);
+			g_string_free(tag, TRUE);
+
+			continue;
+		}
+
+		purple_tags_add(tags, tag->str);
+
+		g_string_free(tag, TRUE);
+	}
+}
+
+static PurpleContact *
+purple_contact_manager_find_from_buddy(PurpleContactManager *manager,
+                                       PurpleBuddy *buddy)
+{
+	PurpleAccount *account = NULL;
+	const char *name = NULL;
+
+	account = purple_buddy_get_account(buddy);
+	name = purple_buddy_get_name(buddy);
+
+	return purple_contact_manager_find_with_username(manager, account, name);
+}
+
+static void
+purple_contact_manager_sync_node(PurpleContactManager *manager,
+                                 PurpleBlistNode *node)
+{
+	PurpleContact *contact = NULL;
+
+	if(!PURPLE_IS_BUDDY(node)) {
+		return;
+	}
+
+	contact = purple_contact_manager_find_from_buddy(manager,
+	                                                 PURPLE_BUDDY(node));
+
+	purple_contact_manager_sync_tags(contact);
+
+	purple_contact_manager_update_contact_full(manager, contact, NULL);
+}
+
+/******************************************************************************
+ * Migration Callbacks
+ *****************************************************************************/
+static void
+purple_contact_manager_blist_node_added_cb(PurpleBlistNode *node,
+                                           gpointer data)
+{
+	purple_contact_manager_sync_node(data, node);
+}
+
+static void
+purple_contact_manager_blist_node_removed_cb(PurpleBlistNode *node,
+                                             gpointer data)
+{
+	purple_contact_manager_sync_node(data, node);
+}
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static void
+purple_contact_manager_contact_update_cb(GObject *obj,
+                                         G_GNUC_UNUSED GParamSpec *pspec,
+                                         gpointer data)
+{
+	PurpleContact *contact = PURPLE_CONTACT(obj);
+	PurpleContactManager *manager = data;
+	GError *error = NULL;
+
+	if(BLIST_MIGRATION) {
+		purple_contact_manager_sync_tags(contact);
+	}
+
+	if(!purple_contact_manager_update_contact_full(manager, contact, &error)) {
+		g_warning("failed to update contact: %s",
+		          (error != NULL) ? error->message : "unknown error");
+		g_clear_error(&error);
+	}
+}
+
 /******************************************************************************
  * GObject Implementation
  *****************************************************************************/
@@ -148,8 +538,13 @@
 
 	manager = PURPLE_CONTACT_MANAGER(obj);
 
+	g_clear_pointer(&manager->db, sqlite3_close);
 	g_clear_pointer(&manager->accounts, g_hash_table_destroy);
 
+	if(BLIST_MIGRATION) {
+		purple_signals_disconnect_by_handle(manager);
+	}
+
 	G_OBJECT_CLASS(purple_contact_manager_parent_class)->finalize(obj);
 }
 
@@ -157,6 +552,17 @@
 purple_contact_manager_init(PurpleContactManager *manager) {
 	manager->accounts = g_hash_table_new_full(g_direct_hash, g_direct_equal,
 	                                          g_object_unref, g_object_unref);
+
+	if(BLIST_MIGRATION) {
+		gpointer *handle = purple_blist_get_handle();
+
+		purple_signal_connect(handle, "blist-node-added", manager,
+		                      G_CALLBACK(purple_contact_manager_blist_node_added_cb),
+		                      manager);
+		purple_signal_connect(handle, "blist-node-removed", manager,
+		                      G_CALLBACK(purple_contact_manager_blist_node_removed_cb),
+		                      manager);
+	}
 }
 
 static void
@@ -212,13 +618,24 @@
 /******************************************************************************
  * Private API
  *****************************************************************************/
-void
-purple_contact_manager_startup(void) {
+gboolean
+purple_contact_manager_startup(GError **error) {
 	if(default_manager == NULL) {
+		char *filename = NULL;
 		default_manager = g_object_new(PURPLE_TYPE_CONTACT_MANAGER, NULL);
+
+		filename = g_build_filename(purple_config_dir(), "contacts.db", NULL);
+		if(!purple_contact_manager_open(default_manager, filename, error)) {
+			g_clear_object(&default_manager);
+
+			return FALSE;
+		}
+
 		g_object_add_weak_pointer(G_OBJECT(default_manager),
 		                          (gpointer)&default_manager);
 	}
+
+	return TRUE;
 }
 
 void
@@ -274,6 +691,9 @@
 	}
 
 	if(added) {
+		g_signal_connect_object(contact, "notify",
+		                        G_CALLBACK(purple_contact_manager_contact_update_cb),
+		                        manager, 0);
 		g_signal_emit(manager, signals[SIG_ADDED], 0, contact);
 	}
 }
@@ -439,7 +859,7 @@
 	PurpleContactInfo *info = NULL;
 	PurplePresence *buddy_presence = NULL;
 	PurplePresence *contact_presence = NULL;
-	const gchar *id = NULL;
+	const char *id = NULL;
 
 	g_return_if_fail(PURPLE_IS_CONTACT_MANAGER(manager));
 	g_return_if_fail(PURPLE_IS_BUDDY(buddy));
@@ -450,6 +870,9 @@
 	contact = purple_contact_new(account, id);
 	info = PURPLE_CONTACT_INFO(contact);
 
+	/* Stash the buddy on the contact for lookups. */
+	g_object_set_data(G_OBJECT(contact), "buddy", buddy);
+
 	/* Bind all of the properties. */
 	g_object_bind_property(buddy, "name", contact, "username",
 	                       G_BINDING_SYNC_CREATE | G_BINDING_BIDIRECTIONAL);
diff --git a/libpurple/purpleprivate.h b/libpurple/purpleprivate.h
--- a/libpurple/purpleprivate.h
+++ b/libpurple/purpleprivate.h
@@ -166,12 +166,15 @@
 
 /**
  * purple_contact_manager_startup:
+ * @error: (nullable): A return address for a [type@GLib.GError].
  *
  * Starts up the contact manager by creating the default instance.
  *
+ * Returns: %TRUE if started successfully, otherwise %FALSE.
+ *
  * Since: 3.0.0
  */
-void purple_contact_manager_startup(void);
+gboolean purple_contact_manager_startup(GError **error);
 
 /**
  * purple_contact_manager_shutdown:
diff --git a/libpurple/purpletags.h b/libpurple/purpletags.h
--- a/libpurple/purpletags.h
+++ b/libpurple/purpletags.h
@@ -130,6 +130,16 @@
 gboolean purple_tags_remove(PurpleTags *tags, const gchar *tag);
 
 /**
+ * purple_tags_remove_all:
+ * @tags: The instance.
+ *
+ * Removes all tags from @tags.
+ *
+ * Since: 3.0.0
+ */
+void purple_tags_remove_all(PurpleTags *tags);
+
+/**
  * purple_tags_get_count:
  * @tags: The instance.
  *
diff --git a/libpurple/purpletags.c b/libpurple/purpletags.c
--- a/libpurple/purpletags.c
+++ b/libpurple/purpletags.c
@@ -165,6 +165,15 @@
 	return FALSE;
 }
 
+void
+purple_tags_remove_all(PurpleTags *tags) {
+	g_return_if_fail(PURPLE_IS_TAGS(tags));
+
+	g_list_free_full(tags->tags, g_free);
+
+	tags->tags = NULL;
+}
+
 guint
 purple_tags_get_count(PurpleTags *tags) {
 	g_return_val_if_fail(PURPLE_IS_TAGS(tags), 0);
diff --git a/libpurple/resources/contactmanager/01-initial.sql b/libpurple/resources/contactmanager/01-initial.sql
new file mode 100644
--- /dev/null
+++ b/libpurple/resources/contactmanager/01-initial.sql
@@ -0,0 +1,32 @@
+CREATE TABLE contacts(
+	account_id TEXT NOT NULL,
+	contact_id TEXT NOT NULL,
+	username TEXT,
+	display_name TEXT,
+	alias TEXT,
+	avatar TEXT,
+	person TEXT,
+
+	-- This order is required, because we do query for all contacts belonging
+	-- to an account.
+	PRIMARY KEY(account_id, contact_id)
+);
+
+CREATE TABLE tags(
+	id INTEGER PRIMARY KEY AUTOINCREMENT,
+	name TEXT NOT NULL,
+	UNIQUE(name)
+);
+
+CREATE TABLE contact_tags(
+	account_id TEXT NOT NULL,
+	contact_id TEXT NOT NULL,
+	tag_id INTEGER NOT NULL,
+	value TEXT,
+
+	-- This order is intentional so that we can optimize lookups for tags by
+	-- account and contacts.
+	PRIMARY KEY(account_id, contact_id, tag_id, value)
+);
+
+CREATE INDEX idx_contact_tags_tags_index ON contact_tags (tag_id, value);
diff --git a/libpurple/resources/libpurple.gresource.xml b/libpurple/resources/libpurple.gresource.xml
--- a/libpurple/resources/libpurple.gresource.xml
+++ b/libpurple/resources/libpurple.gresource.xml
@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <gresources>
   <gresource prefix="/im/pidgin/libpurple/">
+    <file compressed="true">contactmanager/01-initial.sql</file>
     <file compressed="true">sqlitehistoryadapter/01-schema.sql</file>
   </gresource>
 </gresources>
