# HG changeset patch
# Node ID c9d7723fdcd6f07fe6869f037e00f33b41180b19
# Parent  e56b5063fe9be20bcd34503c4d38eb4b64f0de6a
diff --git a/hiya/hiyatxt.c b/hiya/hiyatxt.c
new file mode 100644
--- /dev/null
+++ b/hiya/hiyatxt.c
@@ -0,0 +1,272 @@
+/*
+ * Copyright (C) 2025 Hiya Developers
+ *
+ * Hiya is the legal property of its developers, whose names are too
+ * numerous to list here. Please refer to the AUTHORS file distributed
+ * with this source distribution
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "hiyatxt.h"
+
+struct _HiyaTxt {
+	GObject parent;
+
+	GHashTable *records;
+};
+
+enum {
+	SIG_RECORD_ADDED,
+	SIG_RECORD_CHANGED,
+	SIG_RECORD_REMOVED,
+	N_SIGNALS,
+};
+static guint signals[N_SIGNALS] = {0, };
+
+/******************************************************************************
+ * Helpers
+ *****************************************************************************/
+static guint
+hiya_txt_str_hash(gconstpointer v) {
+	char *lower = NULL;
+	guint result = 0;
+
+	lower = g_ascii_strdown(v, -1);
+	result = g_str_hash(lower);
+	g_free(lower);
+
+	return result;
+}
+
+static gboolean
+hiya_txt_str_equal(gconstpointer v1, gconstpointer v2) {
+	char *lower1 = NULL;
+	char *lower2 = NULL;
+	gboolean result = FALSE;
+
+	lower1 = g_ascii_strdown(v1, -1);
+	lower2 = g_ascii_strdown(v2, -1);
+	result = g_str_equal(lower1, lower2);
+	g_free(lower1);
+	g_free(lower2);
+
+	return result;
+}
+
+static GBytes *
+hiya_txt_bytes_ref(GBytes *bytes) {
+	if(bytes != NULL) {
+		g_bytes_ref(bytes);
+	}
+
+	return bytes;
+}
+
+static void
+hiya_txt_bytes_unref(GBytes *bytes) {
+	if(bytes != NULL) {
+		g_bytes_unref(bytes);
+	}
+}
+
+/******************************************************************************
+ * GObject Implementation
+ *****************************************************************************/
+G_DEFINE_FINAL_TYPE(HiyaTxt, hiya_txt, G_TYPE_OBJECT)
+
+static void
+hiya_txt_finalize(GObject *obj) {
+	HiyaTxt *txt = HIYA_TXT(obj);
+
+	g_clear_pointer(&txt->records, g_hash_table_unref);
+
+	G_OBJECT_CLASS(hiya_txt_parent_class)->finalize(obj);
+}
+
+static void
+hiya_txt_init(HiyaTxt *txt) {
+	txt->records = g_hash_table_new_full(hiya_txt_str_hash,
+	                                     hiya_txt_str_equal,
+	                                     g_free,
+	                                     (GDestroyNotify)hiya_txt_bytes_unref);
+}
+
+static void
+hiya_txt_class_init(HiyaTxtClass *klass) {
+	GObjectClass *obj_class = G_OBJECT_CLASS(klass);
+
+	obj_class->finalize = hiya_txt_finalize;
+
+	/**
+	 * HiyaTxt::record-added:
+	 * @txt: the instance
+	 * @key: the key of the record
+	 * @value: the value of the record
+	 *
+	 * Emitted when a record is added.
+	 *
+	 * Since: 0.1
+	 */
+	signals[SIG_RECORD_ADDED] = g_signal_new_class_handler(
+		"record-added",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_RUN_LAST,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		2,
+		G_TYPE_STRING,
+		G_TYPE_BYTES);
+
+	/**
+	 * HiyaTxt::record-changed:
+	 * @txt: the instance
+	 * @key: the key of the record
+	 * @value: the value
+	 *
+	 * Emitted when a record changes.
+	 *
+	 * Since: 0.1
+	 */
+	signals[SIG_RECORD_CHANGED] = g_signal_new_class_handler(
+		"record-changed",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_DETAILED | G_SIGNAL_RUN_LAST,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		2,
+		G_TYPE_STRING,
+		G_TYPE_BYTES);
+
+	/**
+	 * HiyaTxt::record-removed:
+	 * @txt: the instance
+	 * @key: the key of the record
+	 *
+	 * Emitted when a record is removed.
+	 *
+	 * Since: 0.1
+	 */
+	signals[SIG_RECORD_REMOVED] = g_signal_new_class_handler(
+		"record-removed",
+		G_OBJECT_CLASS_TYPE(klass),
+		G_SIGNAL_RUN_LAST,
+		NULL,
+		NULL,
+		NULL,
+		NULL,
+		G_TYPE_NONE,
+		1,
+		G_TYPE_STRING);
+}
+
+/******************************************************************************
+ * Public API
+ *****************************************************************************/
+GBytes *
+hiya_txt_get_bytes(HiyaTxt *txt, const char *key) {
+	g_return_val_if_fail(HIYA_IS_TXT(txt), NULL);
+	g_return_val_if_fail(key != NULL, NULL);
+
+	return g_hash_table_lookup(txt->records, key);
+}
+
+gconstpointer
+hiya_txt_get_data(HiyaTxt *txt, const char *key, gboolean *found, gsize *size)
+{
+	gpointer bytes = NULL;
+	gboolean local_found = FALSE;
+
+	g_return_val_if_fail(HIYA_IS_TXT(txt), NULL);
+	g_return_val_if_fail(key != NULL, NULL);
+
+	local_found = g_hash_table_lookup_extended(txt->records, key, NULL,
+	                                           &bytes);
+
+	if(found) {
+		*found = local_found;
+	}
+
+	if(bytes == NULL) {
+		return NULL;
+	}
+
+	return g_bytes_get_data(bytes, size);
+}
+
+HiyaTxt *
+hiya_txt_new(void) {
+	return g_object_new(HIYA_TYPE_TXT, NULL);
+}
+
+gboolean
+hiya_txt_remove(HiyaTxt *txt, const char *key) {
+	g_return_val_if_fail(HIYA_IS_TXT(txt), FALSE);
+	g_return_val_if_fail(key != NULL, FALSE);
+
+	if(!g_hash_table_remove(txt->records, key)) {
+		return FALSE;
+	}
+
+	g_signal_emit(txt, signals[SIG_RECORD_REMOVED], 0, key);
+
+	return TRUE;
+}
+
+void
+hiya_txt_set_bytes(HiyaTxt *txt, const char *key, GBytes *bytes) {
+	gboolean added = FALSE;
+
+	g_return_if_fail(HIYA_IS_TXT(txt));
+	g_return_if_fail(key != NULL);
+
+	added = g_hash_table_insert(txt->records, g_strdup(key),
+	                            hiya_txt_bytes_ref(bytes));
+
+	if(added) {
+		g_signal_emit(txt, signals[SIG_RECORD_ADDED], 0, key, bytes);
+	} else {
+		g_signal_emit(txt, signals[SIG_RECORD_CHANGED],
+		              g_quark_from_string(key), key, bytes);
+	}
+}
+
+void
+hiya_txt_set_data(HiyaTxt *txt, const char *key, gconstpointer data,
+                  gsize size)
+{
+	GBytes *bytes = NULL;
+
+	g_return_if_fail(HIYA_IS_TXT(txt));
+	g_return_if_fail(key != NULL);
+
+	bytes = g_bytes_new(data, size);
+	hiya_txt_take_bytes(txt, key, bytes);
+}
+
+void
+hiya_txt_take_bytes(HiyaTxt *txt, const char *key, GBytes *bytes) {
+	g_return_if_fail(HIYA_IS_TXT(txt));
+	g_return_if_fail(key != NULL);
+	g_return_if_fail(bytes != NULL);
+
+	hiya_txt_set_bytes(txt, key, bytes);
+	g_bytes_unref(bytes);
+}
diff --git a/hiya/hiyatxt.h b/hiya/hiyatxt.h
new file mode 100644
--- /dev/null
+++ b/hiya/hiyatxt.h
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2025 Hiya Developers
+ *
+ * Hiya is the legal property of its developers, whose names are too
+ * numerous to list here. Please refer to the AUTHORS file distributed
+ * with this source distribution
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#ifndef HIYA_TXT_H
+#define HIYA_TXT_H
+
+#include <glib.h>
+
+#include <glib-object.h>
+
+#include "hiyaversion.h"
+
+G_BEGIN_DECLS
+
+#define HIYA_TYPE_TXT (hiya_txt_get_type())
+
+/**
+ * HiyaTxt:
+ *
+ * A data store for TXT records.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+G_DECLARE_FINAL_TYPE(HiyaTxt, hiya_txt, HIYA, TXT, GObject)
+
+/**
+ * hiya_txt_get_bytes:
+ * @key: the key to get
+ *
+ * Gets the value of a record as [type@GLib.Bytes].
+ *
+ * Returns: (transfer none) (nullable): The value of the record if found.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+GBytes *hiya_txt_get_bytes(HiyaTxt *txt, const char *key);
+
+/**
+ * hiya_txt_get_data:
+ * @key: the key to get
+ * @found: (out) (optional): a return address for whether or not the record was
+ *         found.
+ * @size: (out) (optional): a return address for the size
+ *
+ * Gets the value of a record as data.
+ *
+ * Returns: (transfer none) (nullable): The value of the record if found.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+gconstpointer hiya_txt_get_data(HiyaTxt *txt, const char *key, gboolean *found, gsize *size);
+
+/**
+ * hiya_txt_new:
+ *
+ * Creates a new txt instance.
+ *
+ * Returns: (transfer full): The new instance.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+HiyaTxt *hiya_txt_new(void);
+
+/**
+ * hiya_txt_remove:
+ * @key: the key to remove
+ *
+ * Remove a key.
+ *
+ * Returns: true if the key was found and removed.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+gboolean hiya_txt_remove(HiyaTxt *txt, const char *key);
+
+/**
+ * hiya_txt_set_bytes:
+ * @key: the key to set
+ * @bytes: (transfer none): the new value
+ *
+ * Sets the value of a record using [type@GLib.Bytes].
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+void hiya_txt_set_bytes(HiyaTxt *txt, const char *key, GBytes *bytes);
+
+/**
+ * hiya_txt_set_data:
+ * @key: the key to set
+ * @data: the new value
+ * @size: the length of @data
+ *
+ * Sets the value of a record.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+void hiya_txt_set_data(HiyaTxt *txt, const char *key, gconstpointer data, gsize size);
+
+/**
+ * hiya_txt_take_bytes:
+ * @key: the key to set
+ * @bytes: (transfer full): the bytes to take
+ *
+ * Sets the value of a record.
+ *
+ * Since: 0.1
+ */
+HIYA_AVAILABLE_IN_0_1
+void hiya_txt_take_bytes(HiyaTxt *txt, const char *key, GBytes *bytes);
+
+G_END_DECLS
+
+#endif /* HIYA_TXT_H */
diff --git a/hiya/meson.build b/hiya/meson.build
--- a/hiya/meson.build
+++ b/hiya/meson.build
@@ -1,10 +1,12 @@
 HIYA_LIBRARY_VERSION = '0.1.0'
 
 HIYA_SOURCES = [
+  'hiyatxt.c',
   'hiyaversion.c',
 ]
 
 HIYA_HEADERS = [
+  'hiyatxt.h',
   'hiyaversion.h',
 ]
 
diff --git a/hiya/tests/meson.build b/hiya/tests/meson.build
--- a/hiya/tests/meson.build
+++ b/hiya/tests/meson.build
@@ -1,4 +1,5 @@
 PROGRAMS = [
+  'txt',
 ]
 
 foreach PROGRAM : PROGRAMS
diff --git a/hiya/tests/test_txt.c b/hiya/tests/test_txt.c
new file mode 100644
--- /dev/null
+++ b/hiya/tests/test_txt.c
@@ -0,0 +1,246 @@
+/*
+ * Copyright (C) 2025 Hiya Developers
+ *
+ * Hiya is the legal property of its developers, whose names are too
+ * numerous to list here. Please refer to the AUTHORS file distributed
+ * with this source distribution
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include <hiya.h>
+
+/******************************************************************************
+ * Callbacks
+ *****************************************************************************/
+static void
+test_hiya_record_added_counter_cb(HiyaTxt *txt, const char *key, GValue *value,
+                                  gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(HIYA_IS_TXT(txt));
+	g_assert_nonnull(key);
+	g_assert_nonnull(value);
+
+	*counter = *counter + 1;
+}
+
+static void
+test_hiya_record_changed_counter_cb(HiyaTxt *txt, const char *key, GValue *value,
+                                    gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(HIYA_IS_TXT(txt));
+	g_assert_nonnull(key);
+	g_assert_nonnull(value);
+
+	*counter = *counter + 1;
+}
+
+static void
+test_hiya_record_removed_counter_cb(HiyaTxt *txt, const char *key,
+                                    gpointer data)
+{
+	guint *counter = data;
+
+	g_assert_true(HIYA_IS_TXT(txt));
+	g_assert_nonnull(key);
+
+	*counter = *counter + 1;
+}
+
+/******************************************************************************
+ * Tests
+ *****************************************************************************/
+static void
+test_hiya_txt_new(void) {
+	HiyaTxt *txt = NULL;
+
+	txt = hiya_txt_new();
+
+	g_assert_true(HIYA_IS_TXT(txt));
+
+	g_assert_finalize_object(txt);
+}
+
+static void
+test_hiya_txt_set_data_normal(void) {
+	HiyaTxt *txt = NULL;
+	guint added_counter = 0;
+	guint changed_counter = 0;
+	guint detailed_counter = 0;
+	guint removed_counter = 0;
+	gboolean found = FALSE;
+	gboolean result = FALSE;
+	gconstpointer data = NULL;
+	gsize size = 0;
+
+	txt = hiya_txt_new();
+	g_signal_connect(txt, "record-added",
+	                 G_CALLBACK(test_hiya_record_added_counter_cb),
+	                 &added_counter);
+	g_signal_connect(txt, "record-changed",
+	                 G_CALLBACK(test_hiya_record_changed_counter_cb),
+	                 &changed_counter);
+	g_signal_connect(txt, "record-changed::txtvers",
+	                 G_CALLBACK(test_hiya_record_changed_counter_cb),
+	                 &detailed_counter);
+	g_signal_connect(txt, "record-removed",
+	                 G_CALLBACK(test_hiya_record_removed_counter_cb),
+	                 &removed_counter);
+
+	/* Set the record to create it. */
+	hiya_txt_set_data(txt, "txtvers", "1", 1);
+	g_assert_cmpuint(added_counter, ==, 1);
+	g_assert_cmpuint(changed_counter, ==, 0);
+	g_assert_cmpuint(detailed_counter, ==, 0);
+	g_assert_cmpuint(removed_counter, ==, 0);
+
+	/* Make sure the record was set. */
+	data = hiya_txt_get_data(txt, "txtvers", &found, &size);
+	g_assert_cmpmem(data, size, "1", 1);
+
+	/* Reset the counters and change the record. */
+	added_counter = 0;
+	changed_counter = 0;
+	detailed_counter = 0;
+	removed_counter = 0;
+
+	hiya_txt_set_data(txt, "txtvers", "two", 3);
+	g_assert_cmpuint(added_counter, ==, 0);
+	g_assert_cmpuint(changed_counter, ==, 1);
+	g_assert_cmpuint(detailed_counter, ==, 1);
+	g_assert_cmpuint(removed_counter, ==, 0);
+
+	/* Reset the counters and remove the record. */
+	added_counter = 0;
+	changed_counter = 0;
+	detailed_counter = 0;
+	removed_counter = 0;
+
+	result = hiya_txt_remove(txt, "txtvers");
+	g_assert_true(result);
+	g_assert_cmpuint(added_counter, ==, 0);
+	g_assert_cmpuint(changed_counter, ==, 0);
+	g_assert_cmpuint(detailed_counter, ==, 0);
+	g_assert_cmpuint(removed_counter, ==, 1);
+
+	g_assert_finalize_object(txt);
+}
+
+static void
+test_hiya_txt_set_data_null(void) {
+	HiyaTxt *txt = NULL;
+	gboolean found = FALSE;
+	gconstpointer value = NULL;
+	gsize size = 0;
+
+	txt = hiya_txt_new();
+
+	hiya_txt_set_data(txt, "txtvers", NULL, 0);
+
+	value = hiya_txt_get_data(txt, "txtvers", &found, &size);
+	g_assert_true(found);
+	g_assert_null(value);
+	g_assert_cmpuint(size, ==, 0);
+
+	g_assert_finalize_object(txt);
+}
+
+void
+test_hiya_txt_set_bytes_normal(void) {
+	HiyaTxt *txt = NULL;
+	GBytes *bytes = NULL;
+	GBytes *bytes1 = NULL;
+
+	txt = hiya_txt_new();
+
+	bytes = g_bytes_new_static("8", 1);
+	hiya_txt_set_bytes(txt, "txtvers", bytes);
+
+	bytes1 = hiya_txt_get_bytes(txt, "txtvers");
+	g_assert_nonnull(bytes1);
+
+	g_assert_true(g_bytes_equal(bytes1, bytes));
+
+	g_assert_finalize_object(txt);
+	g_clear_pointer(&bytes, g_bytes_unref);
+}
+
+static void
+test_hiya_txt_set_bytes_null(void) {
+	HiyaTxt *txt = NULL;
+	GBytes *bytes = NULL;
+
+	txt = hiya_txt_new();
+
+	hiya_txt_set_bytes(txt, "txtvers", NULL);
+
+	bytes = hiya_txt_get_bytes(txt, "txtvers");
+	g_assert_null(bytes);
+
+	g_assert_finalize_object(txt);
+}
+
+static void
+test_hiya_txt_take_bytes(void) {
+	HiyaTxt *txt = NULL;
+	GBytes *bytes = NULL;
+	GBytes *bytes1 = NULL;
+
+	txt = hiya_txt_new();
+
+	bytes = g_bytes_new_static("hiya", 4);
+
+	/* Ref bytes to avoid a use after free. */
+	g_bytes_ref(bytes);
+
+	hiya_txt_take_bytes(txt, "txtvers", bytes);
+
+	bytes1 = hiya_txt_get_bytes(txt, "txtvers");
+	g_assert_nonnull(bytes1);
+
+	g_assert_true(g_bytes_equal(bytes1, bytes));
+
+	/* Remove our ref to avoid the use after free. */
+	g_bytes_unref(bytes);
+
+	g_assert_finalize_object(txt);
+}
+
+/******************************************************************************
+ * Main
+ *****************************************************************************/
+int
+main(int argc, char *argv[]) {
+	g_test_init(&argc, &argv, NULL);
+
+	g_test_add_func("/hiya/txt/new", test_hiya_txt_new);
+
+	g_test_add_func("/hiya/txt/set-data/normal",
+	                test_hiya_txt_set_data_normal);
+	g_test_add_func("/hiya/txt/set-data/null",
+	                test_hiya_txt_set_data_null);
+
+	g_test_add_func("/hiya/txt/set-bytes/normal",
+	                test_hiya_txt_set_bytes_normal);
+	g_test_add_func("/hiya/txt/set-bytes/null",
+	                test_hiya_txt_set_bytes_null);
+
+	g_test_add_func("/hiya/txt/take-bytes", test_hiya_txt_take_bytes);
+
+	return g_test_run();
+}
