Files
sovereign_browser/src/db.c
T

1157 lines
37 KiB
C

/*
* db.c — SQLite storage for sovereign_browser
*
* Stores Nostr events and misc data in ~/.sovereign_browser/browser.db.
* Uses SQLITE_OPEN_FULLMUTEX for thread safety (the relay fetch runs in
* a background thread).
*/
#include "db.h"
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
/* ── Global state ──────────────────────────────────────────────────── */
static sqlite3 *g_db = NULL;
/* Static buffer for db_kv_get() — valid until the next call. */
static char g_kv_buf[4096];
/* ── Path helpers ──────────────────────────────────────────────────── */
/* ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
static int sb_home_dir(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
return -1;
}
return 0;
}
/* ~/.sovereign_browser/browser.db (legacy default per-user path). */
static int db_path(char *out, size_t out_sz) {
char dir[512];
if (sb_home_dir(dir, sizeof(dir)) != 0) {
return -1;
}
int n = snprintf(out, out_sz, "%s/browser.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ~/.sovereign_browser/global.db (global settings + shortcuts). */
static int db_global_path(char *out, size_t out_sz) {
char dir[512];
if (sb_home_dir(dir, sizeof(dir)) != 0) {
return -1;
}
int n = snprintf(out, out_sz, "%s/global.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Schema ────────────────────────────────────────────────────────── */
static const char *SCHEMA_SQL =
"CREATE TABLE IF NOT EXISTS events ("
" id TEXT PRIMARY KEY,"
" pubkey TEXT NOT NULL,"
" kind INTEGER NOT NULL,"
" created_at INTEGER NOT NULL,"
" content TEXT,"
" sig TEXT,"
" raw_json TEXT NOT NULL,"
" fetched_at INTEGER NOT NULL"
");"
"CREATE TABLE IF NOT EXISTS event_tags ("
" event_id TEXT NOT NULL,"
" tag_name TEXT NOT NULL,"
" tag_value TEXT,"
" position INTEGER NOT NULL,"
" FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE"
");"
"CREATE TABLE IF NOT EXISTS key_value ("
" key TEXT PRIMARY KEY,"
" value TEXT"
");"
"CREATE TABLE IF NOT EXISTS history ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" url TEXT NOT NULL UNIQUE,"
" title TEXT,"
" visited_at INTEGER NOT NULL,"
" visit_count INTEGER DEFAULT 1"
");"
"CREATE INDEX IF NOT EXISTS idx_history_visited_at "
" ON history(visited_at DESC);"
"CREATE INDEX IF NOT EXISTS idx_history_url "
" ON history(url);"
"CREATE INDEX IF NOT EXISTS idx_history_title "
" ON history(title);"
"CREATE INDEX IF NOT EXISTS idx_history_visit_count "
" ON history(visit_count DESC);"
"CREATE TABLE IF NOT EXISTS session ("
" tab_index INTEGER PRIMARY KEY,"
" url TEXT NOT NULL,"
" title TEXT"
");"
"CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind "
" ON events(pubkey, kind, created_at DESC);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
" ON event_tags(tag_name, tag_value);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
" ON event_tags(event_id);"
"CREATE TABLE IF NOT EXISTS agent_sessions ("
" id TEXT PRIMARY KEY,"
" title TEXT,"
" created_at INTEGER,"
" updated_at INTEGER"
");"
"CREATE TABLE IF NOT EXISTS agent_messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" session_id TEXT,"
" role TEXT,"
" content TEXT,"
" tool_calls TEXT,"
" tool_call_id TEXT,"
" created_at INTEGER,"
" FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE"
");"
"CREATE INDEX IF NOT EXISTS idx_agent_messages_session "
" ON agent_messages(session_id, created_at);";
/* ── Init / Close ──────────────────────────────────────────────────── */
/*
* Open a SQLite database at the given path with the standard schema.
* Closes any currently-open database first. Returns 0 on success.
*/
int db_init_with_path(const char *path) {
if (path == NULL || path[0] == '\0') {
g_printerr("[db] db_init_with_path: NULL path\n");
return -1;
}
/* Close any currently-open database so we can switch databases. */
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
}
int rc = sqlite3_open_v2(path, &g_db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
SQLITE_OPEN_FULLMUTEX,
NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to open database '%s': %s\n",
path, g_db ? sqlite3_errmsg(g_db) : "(null)");
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
}
return -1;
}
/* Set busy timeout to 5 seconds (in case of contention). */
sqlite3_busy_timeout(g_db, 5000);
/* Enable foreign keys (for ON DELETE CASCADE on event_tags). */
char *err = NULL;
rc = sqlite3_exec(g_db, "PRAGMA foreign_keys = ON;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to enable foreign keys: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
}
/* Create schema (all tables use IF NOT EXISTS, so this is safe for
* both global.db and per-user browser.db). */
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to create schema: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
sqlite3_close(g_db);
g_db = NULL;
return -1;
}
g_print("[db] Database initialized at %s\n", path);
return 0;
}
/*
* Open the global database at ~/.sovereign_browser/global.db.
* Used at startup for global settings + shortcuts, before login.
*/
int db_init_global(void) {
char path[512];
if (db_global_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get global database path\n");
return -1;
}
return db_init_with_path(path);
}
/*
* Open the default per-user database at ~/.sovereign_browser/browser.db.
* Kept for compatibility; the normal flow now uses db_init_with_path()
* with a profile-specific path after login.
*/
int db_init(void) {
char path[512];
if (db_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get database path\n");
return -1;
}
return db_init_with_path(path);
}
void db_close(void) {
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
g_print("[db] Database closed\n");
}
}
/* ── Events ────────────────────────────────────────────────────────── */
/* Helper: get a string field from a cJSON event, or "" if missing. */
static const char *event_str(const cJSON *event, const char *field) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
if (cJSON_IsString(item)) return item->valuestring;
return "";
}
/* Helper: get an integer field from a cJSON event, or 0 if missing. */
static long event_int(const cJSON *event, const char *field) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(event, field);
if (cJSON_IsNumber(item)) return (long)item->valuedouble;
return 0;
}
int db_store_event(const cJSON *event) {
if (g_db == NULL || event == NULL) return -1;
const char *id = event_str(event, "id");
const char *pubkey = event_str(event, "pubkey");
long kind = event_int(event, "kind");
long created = event_int(event, "created_at");
const char *content = event_str(event, "content");
const char *sig = event_str(event, "sig");
if (id[0] == '\0' || pubkey[0] == '\0') {
g_printerr("[db] Event missing id or pubkey\n");
return -1;
}
/* Serialize the full event to JSON for round-tripping. */
char *raw_json = cJSON_PrintUnformatted(event);
if (raw_json == NULL) return -1;
/* Begin transaction. */
char *err = NULL;
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] BEGIN failed: %s\n", err ? err : "(unknown)");
if (err) sqlite3_free(err);
free(raw_json);
return -1;
}
/* Upsert the event (INSERT OR REPLACE). */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"INSERT OR REPLACE INTO events "
"(id, pubkey, kind, created_at, content, sig, raw_json, fetched_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'));",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare events insert failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
free(raw_json);
return -1;
}
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, kind);
sqlite3_bind_int64(stmt, 4, created);
sqlite3_bind_text(stmt, 5, content, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 6, sig, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 7, raw_json, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] Events insert failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
free(raw_json);
return -1;
}
/* Delete old tags for this event (if replacing) and insert new ones. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM event_tags WHERE event_id = ?;",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
/* Insert tags. */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (cJSON_IsArray(tags)) {
rc = sqlite3_prepare_v2(g_db,
"INSERT INTO event_tags (event_id, tag_name, tag_value, position) "
"VALUES (?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
int pos = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name_item = cJSON_GetArrayItem(tag, 0);
cJSON *value_item = cJSON_GetArrayItem(tag, 1);
const char *tag_name = (name_item && cJSON_IsString(name_item))
? name_item->valuestring : "";
const char *tag_value = (value_item && cJSON_IsString(value_item))
? value_item->valuestring : "";
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, tag_name, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, tag_value, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 4, pos);
sqlite3_step(stmt);
sqlite3_reset(stmt);
pos++;
}
sqlite3_finalize(stmt);
}
}
/* Commit. */
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] COMMIT failed: %s\n", err ? err : "(unknown)");
if (err) sqlite3_free(err);
free(raw_json);
return -1;
}
free(raw_json);
return 0;
}
cJSON *db_get_latest_event(const char *pubkey_hex, int kind) {
if (g_db == NULL || pubkey_hex == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE pubkey = ? AND kind = ? "
"ORDER BY created_at DESC LIMIT 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare latest_event failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
cJSON *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
result = cJSON_Parse(json_str);
}
}
sqlite3_finalize(stmt);
return result;
}
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit) {
if (g_db == NULL || pubkey_hex == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
/* If limit is 0, use a large default. */
int lim = (limit > 0) ? limit : 10000;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE pubkey = ? AND kind = ? "
"ORDER BY created_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare get_events failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
sqlite3_bind_int(stmt, 3, lim);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
cJSON *event = cJSON_Parse(json_str);
if (event) {
cJSON_AddItemToArray(array, event);
}
}
}
sqlite3_finalize(stmt);
return array;
}
int db_count_events(const char *pubkey_hex, int kind) {
if (g_db == NULL || pubkey_hex == NULL) return -1;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT COUNT(*) FROM events WHERE pubkey = ? AND kind = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, pubkey_hex, -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 2, kind);
int count = -1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return count;
}
cJSON *db_get_events_by_kind(int kind, int limit) {
if (g_db == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int lim = (limit > 0) ? limit : 10000;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE kind = ? "
"ORDER BY created_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare get_events_by_kind failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_int(stmt, 1, kind);
sqlite3_bind_int(stmt, 2, lim);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
cJSON *event = cJSON_Parse(json_str);
if (event) {
cJSON_AddItemToArray(array, event);
}
}
}
sqlite3_finalize(stmt);
return array;
}
int db_delete_event(const char *event_id) {
if (g_db == NULL || event_id == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
/* Delete tags first. */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM event_tags WHERE event_id = ?;",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
/* Delete the event row. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM events WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] delete_event prepare failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] delete_event step failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
return 0;
}
/* ── Key-Value store ───────────────────────────────────────────────── */
int db_kv_set(const char *key, const char *value) {
if (g_db == NULL || key == NULL || value == NULL) return -1;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? 0 : -1;
}
/*
* Write a key-value pair to a specific database file, opening a separate
* short-lived connection. This is used to save global settings to
* global.db while the per-user browser.db is the main open database.
* Creates the key_value table if it doesn't exist (safe for a fresh
* global.db). Returns 0 on success, -1 on error.
*/
int db_kv_set_to_file(const char *path, const char *key, const char *value) {
if (path == NULL || key == NULL || value == NULL) return -1;
sqlite3 *db = NULL;
int rc = sqlite3_open_v2(path, &db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
SQLITE_OPEN_FULLMUTEX,
NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] db_kv_set_to_file: open '%s' failed: %s\n",
path, db ? sqlite3_errmsg(db) : "(null)");
if (db) sqlite3_close(db);
return -1;
}
sqlite3_busy_timeout(db, 5000);
/* Ensure the key_value table exists (harmless if already present). */
char *err = NULL;
rc = sqlite3_exec(db,
"CREATE TABLE IF NOT EXISTS key_value ("
" key TEXT PRIMARY KEY,"
" value TEXT"
");", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] db_kv_set_to_file: create table failed: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
sqlite3_close(db);
return -1;
}
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(db,
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] db_kv_set_to_file: prepare failed: %s\n",
sqlite3_errmsg(db));
sqlite3_close(db);
return -1;
}
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
sqlite3_close(db);
return (rc == SQLITE_DONE) ? 0 : -1;
}
const char *db_kv_get(const char *key) {
if (g_db == NULL || key == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT value FROM key_value WHERE key = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return NULL;
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
const char *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *val = (const char *)sqlite3_column_text(stmt, 0);
if (val) {
snprintf(g_kv_buf, sizeof(g_kv_buf), "%s", val);
result = g_kv_buf;
}
}
sqlite3_finalize(stmt);
return result;
}
char *db_kv_get_copy(const char *key) {
const char *val = db_kv_get(key);
if (val == NULL) return NULL;
return g_strdup(val);
}
/* ── History ───────────────────────────────────────────────────────── */
int db_history_add(const char *url, const char *title) {
if (g_db == NULL || url == NULL || url[0] == '\0') return -1;
/* UPSERT: insert or update visited_at + visit_count. */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO history (url, title, visited_at, visit_count) "
"VALUES (?, ?, strftime('%s','now'), 1) "
"ON CONFLICT(url) DO UPDATE SET "
" title = excluded.title, "
" visited_at = strftime('%s','now'), "
" visit_count = visit_count + 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_text(stmt, 1, url, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? 0 : -1;
}
int db_history_get(char ***urls_out, char ***titles_out,
int *count_out, int limit) {
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
if (limit <= 0) limit = 50;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT url, title FROM history "
"ORDER BY visited_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
sqlite3_bind_int(stmt, 1, limit);
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* Allocate arrays. */
*urls_out = g_new0(char *, count);
if (titles_out) *titles_out = g_new0(char *, count);
if (count_out) *count_out = 0;
/* Second pass: fill. */
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *url = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*urls_out)[i] = g_strdup(url ? url : "");
if (titles_out) {
(*titles_out)[i] = g_strdup(title ? title : "");
}
i++;
}
sqlite3_finalize(stmt);
if (count_out) *count_out = i;
return i;
}
int db_history_clear(void) {
if (g_db == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "DELETE FROM history;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
return 0;
}
/* ── History search ────────────────────────────────────────────────── */
int db_history_search(const char *query,
char ***urls_out, char ***titles_out,
int *count_out, int limit) {
if (g_db == NULL || query == NULL || urls_out == NULL || count_out == NULL)
return -1;
if (limit <= 0) limit = 10;
/* Build a LIKE pattern: %query% (case-insensitive via COLLATE NOCASE
* is not needed — SQLite LIKE is case-insensitive for ASCII by default). */
char *pattern = g_strdup_printf("%%%s%%", query);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT url, title FROM history "
"WHERE url LIKE ? OR title LIKE ? "
"ORDER BY visit_count DESC, visited_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_free(pattern);
return -1;
}
sqlite3_bind_text(stmt, 1, pattern, -1, g_free);
sqlite3_bind_text(stmt, 2, pattern, -1, NULL); /* same pattern, freed once */
sqlite3_bind_int(stmt, 3, limit);
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* Re-bind (sqlite3_reset keeps bindings, but reset clears them in some
* versions — re-bind to be safe). */
char *pattern2 = g_strdup_printf("%%%s%%", query);
sqlite3_bind_text(stmt, 1, pattern2, -1, g_free);
sqlite3_bind_text(stmt, 2, pattern2, -1, NULL);
sqlite3_bind_int(stmt, 3, limit);
/* Allocate arrays. */
*urls_out = g_new0(char *, count);
if (titles_out) *titles_out = g_new0(char *, count);
if (count_out) *count_out = 0;
/* Second pass: fill. */
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *url = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*urls_out)[i] = g_strdup(url ? url : "");
if (titles_out) {
(*titles_out)[i] = g_strdup(title ? title : "");
}
i++;
}
sqlite3_finalize(stmt);
if (count_out) *count_out = i;
return i;
}
/* ── Session ───────────────────────────────────────────────────────── */
int db_session_save(const char **urls, const char **titles, int count) {
if (g_db == NULL || urls == NULL) return -1;
/* Clear the session table first. */
char *err = NULL;
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
/* Insert each tab. */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"INSERT INTO session (tab_index, url, title) VALUES (?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
for (int i = 0; i < count; i++) {
if (urls[i] == NULL) continue;
sqlite3_bind_int(stmt, 1, i);
sqlite3_bind_text(stmt, 2, urls[i], -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3,
(titles && titles[i]) ? titles[i] : "",
-1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_reset(stmt);
}
sqlite3_finalize(stmt);
return 0;
}
int db_session_load(char ***urls_out, char ***titles_out, int *count_out) {
if (g_db == NULL || urls_out == NULL || count_out == NULL) return -1;
/* Count rows. */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT url, title FROM session ORDER BY tab_index;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) return -1;
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
if (count == 0) {
sqlite3_finalize(stmt);
*count_out = 0;
return 0;
}
*urls_out = g_new0(char *, count);
if (titles_out) *titles_out = g_new0(char *, count);
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *url = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*urls_out)[i] = g_strdup(url ? url : "");
if (titles_out) {
(*titles_out)[i] = g_strdup(title ? title : "");
}
i++;
}
sqlite3_finalize(stmt);
*count_out = i;
return i;
}
int db_session_clear(void) {
if (g_db == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "DELETE FROM session;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
return 0;
}
/* ── Agent chat sessions ───────────────────────────────────────────── */
char *db_agent_session_create(const char *title) {
if (g_db == NULL) return NULL;
char *id = g_uuid_string_random();
if (id == NULL) {
g_printerr("[db] agent_session_create: g_uuid_string_random failed\n");
return NULL;
}
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO agent_sessions (id, title, created_at, updated_at) "
"VALUES (?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_create: prepare failed: %s\n",
sqlite3_errmsg(g_db));
g_free(id);
return NULL;
}
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, now);
sqlite3_bind_int64(stmt, 4, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_create: insert failed: %s\n",
sqlite3_errmsg(g_db));
g_free(id);
return NULL;
}
return id;
}
char *db_agent_session_get_latest(void) {
if (g_db == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_get_latest: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
char *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *id = (const char *)sqlite3_column_text(stmt, 0);
if (id) {
result = g_strdup(id);
}
}
sqlite3_finalize(stmt);
return result;
}
int db_agent_session_update(const char *session_id, const char *title) {
if (g_db == NULL || session_id == NULL) return -1;
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"UPDATE agent_sessions SET title = ?, updated_at = ? "
"WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_update: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, title ? title : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 2, now);
sqlite3_bind_text(stmt, 3, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_update: update failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return 0;
}
int db_agent_session_list(char ***ids_out, char ***titles_out,
int *count_out) {
if (g_db == NULL || ids_out == NULL || titles_out == NULL ||
count_out == NULL) {
return -1;
}
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id, title FROM agent_sessions "
"ORDER BY updated_at DESC;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_list: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* Allocate arrays. */
*ids_out = g_new0(char *, count);
*titles_out = g_new0(char *, count);
*count_out = 0;
/* Second pass: fill. */
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *id = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*ids_out)[i] = g_strdup(id ? id : "");
(*titles_out)[i] = g_strdup(title ? title : "");
i++;
}
sqlite3_finalize(stmt);
*count_out = i;
return i;
}
int db_agent_message_add(const char *session_id, const char *role,
const char *content, const char *tool_calls,
const char *tool_call_id) {
if (g_db == NULL || session_id == NULL || role == NULL) return -1;
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO agent_messages "
"(session_id, role, content, tool_calls, tool_call_id, created_at) "
"VALUES (?, ?, ?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_message_add: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, role, -1, SQLITE_TRANSIENT);
if (content) {
sqlite3_bind_text(stmt, 3, content, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 3);
}
if (tool_calls) {
sqlite3_bind_text(stmt, 4, tool_calls, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 4);
}
if (tool_call_id) {
sqlite3_bind_text(stmt, 5, tool_call_id, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 5);
}
sqlite3_bind_int64(stmt, 6, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_message_add: insert failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return (int)sqlite3_last_insert_rowid(g_db);
}
cJSON *db_agent_message_list(const char *session_id) {
if (g_db == NULL || session_id == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id, role, content, tool_calls, tool_call_id "
"FROM agent_messages WHERE session_id = ? "
"ORDER BY created_at ASC, id ASC;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_message_list: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON *msg = cJSON_CreateObject();
cJSON_AddNumberToObject(msg, "id",
(double)sqlite3_column_int64(stmt, 0));
const char *role = (const char *)sqlite3_column_text(stmt, 1);
cJSON_AddStringToObject(msg, "role", role ? role : "");
/* content may be NULL. */
if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "content");
} else {
const char *content = (const char *)sqlite3_column_text(stmt, 2);
cJSON_AddStringToObject(msg, "content", content ? content : "");
}
/* tool_calls may be NULL. */
if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "tool_calls");
} else {
const char *tc = (const char *)sqlite3_column_text(stmt, 3);
cJSON_AddStringToObject(msg, "tool_calls", tc ? tc : "");
}
/* tool_call_id may be NULL. */
if (sqlite3_column_type(stmt, 4) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "tool_call_id");
} else {
const char *tcid = (const char *)sqlite3_column_text(stmt, 4);
cJSON_AddStringToObject(msg, "tool_call_id", tcid ? tcid : "");
}
cJSON_AddItemToArray(array, msg);
}
sqlite3_finalize(stmt);
return array;
}
int db_agent_session_delete(const char *session_id) {
if (g_db == NULL || session_id == NULL) return -1;
/* Delete messages first (in case foreign keys are not enforced). */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"DELETE FROM agent_messages WHERE session_id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_delete: prepare messages failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_delete: delete messages failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
/* Delete the session row. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM agent_sessions WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_delete: prepare session failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_delete: delete session failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return 0;
}