webui: store category/tag taxonomy in the webui DB
Move the category + tag lists out of the daemon (labels.json + the get/set_label_taxonomy RPCs) and into the webui's own SQLite DB (categories, tags tables). The daemon no longer persists webui taxonomy; per-torrent labels still flow through set_labels for Lua. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1b79fe8079
commit
9474e6f31a
4 changed files with 160 additions and 146 deletions
|
|
@ -114,13 +114,6 @@ struct daemon_state {
|
|||
json_t *script_settings_schema; /* array of {key,label,type,default} */
|
||||
json_t *script_settings; /* object: key -> value string (user-set)*/
|
||||
char settings_file[PATH_MAX]; /* <state_dir>/script_settings.json */
|
||||
/* Label taxonomy: the web UI's full category + tag lists (including ones
|
||||
* created but not yet assigned). Web-layer schema; the daemon just persists
|
||||
* it so they survive restarts. */
|
||||
pthread_mutex_t taxonomy_lock;
|
||||
json_t *label_categories; /* array of {name, savePath} */
|
||||
json_t *label_tags; /* array of tag name strings */
|
||||
char taxonomy_file[PATH_MAX]; /* <state_dir>/labels.json */
|
||||
char data_dir[PATH_MAX]; /* the resolved state dir (for blobs) */
|
||||
pthread_mutex_t blob_lock; /* guards webui_<key>.json blob files */
|
||||
pthread_mutex_t torrent_lock;
|
||||
|
|
@ -1320,89 +1313,6 @@ static json_t *rpc_set_script_settings(void *opaque, const json_t *params,
|
|||
return script_status_json(state);
|
||||
}
|
||||
|
||||
/* --- label taxonomy (web-layer category + tag lists, persisted here) ------- */
|
||||
|
||||
static void persist_taxonomy(daemon_state *state) {
|
||||
if (!state->persist_enabled) return;
|
||||
pthread_mutex_lock(&state->taxonomy_lock);
|
||||
json_t *cats = state->label_categories
|
||||
? json_deep_copy(state->label_categories) : json_array();
|
||||
json_t *tags = state->label_tags
|
||||
? json_deep_copy(state->label_tags) : json_array();
|
||||
pthread_mutex_unlock(&state->taxonomy_lock);
|
||||
json_t *doc = json_object();
|
||||
if (!doc) { json_decref(cats); json_decref(tags); return; }
|
||||
json_object_set_new(doc, "categories", cats ? cats : json_array());
|
||||
json_object_set_new(doc, "tags", tags ? tags : json_array());
|
||||
char tmp[PATH_MAX + 8];
|
||||
snprintf(tmp, sizeof tmp, "%s.tmp", state->taxonomy_file);
|
||||
if (json_dump_file(doc, tmp, JSON_INDENT(2)) != 0 ||
|
||||
rename(tmp, state->taxonomy_file) != 0) {
|
||||
NAUT_WARN("persist: write %s failed", state->taxonomy_file);
|
||||
unlink(tmp);
|
||||
}
|
||||
json_decref(doc);
|
||||
}
|
||||
|
||||
static void load_taxonomy(daemon_state *state) {
|
||||
if (!state->persist_enabled) return;
|
||||
json_error_t jerr;
|
||||
json_t *doc = json_load_file(state->taxonomy_file, 0, &jerr);
|
||||
if (!doc) return;
|
||||
json_t *cats = json_object_get(doc, "categories");
|
||||
json_t *tags = json_object_get(doc, "tags");
|
||||
pthread_mutex_lock(&state->taxonomy_lock);
|
||||
if (json_is_array(cats)) {
|
||||
json_decref(state->label_categories);
|
||||
state->label_categories = json_deep_copy(cats);
|
||||
}
|
||||
if (json_is_array(tags)) {
|
||||
json_decref(state->label_tags);
|
||||
state->label_tags = json_deep_copy(tags);
|
||||
}
|
||||
pthread_mutex_unlock(&state->taxonomy_lock);
|
||||
json_decref(doc);
|
||||
}
|
||||
|
||||
static json_t *rpc_get_label_taxonomy(void *opaque, const json_t *params,
|
||||
naut_err *error) {
|
||||
(void)params;
|
||||
daemon_state *state = opaque;
|
||||
pthread_mutex_lock(&state->taxonomy_lock);
|
||||
json_t *cats = state->label_categories
|
||||
? json_deep_copy(state->label_categories) : json_array();
|
||||
json_t *tags = state->label_tags
|
||||
? json_deep_copy(state->label_tags) : json_array();
|
||||
pthread_mutex_unlock(&state->taxonomy_lock);
|
||||
json_t *out = json_object();
|
||||
if (!out) { json_decref(cats); json_decref(tags); *error = NAUT_ERR_NOMEM; return NULL; }
|
||||
json_object_set_new(out, "categories", cats ? cats : json_array());
|
||||
json_object_set_new(out, "tags", tags ? tags : json_array());
|
||||
*error = NAUT_OK;
|
||||
return out;
|
||||
}
|
||||
|
||||
static json_t *rpc_set_label_taxonomy(void *opaque, const json_t *params,
|
||||
naut_err *error) {
|
||||
daemon_state *state = opaque;
|
||||
if (!json_is_object(params)) { *error = NAUT_ERR_INVAL; return NULL; }
|
||||
json_t *cats = json_object_get(params, "categories");
|
||||
json_t *tags = json_object_get(params, "tags");
|
||||
pthread_mutex_lock(&state->taxonomy_lock);
|
||||
if (json_is_array(cats)) {
|
||||
json_decref(state->label_categories);
|
||||
state->label_categories = json_deep_copy(cats);
|
||||
}
|
||||
if (json_is_array(tags)) {
|
||||
json_decref(state->label_tags);
|
||||
state->label_tags = json_deep_copy(tags);
|
||||
}
|
||||
pthread_mutex_unlock(&state->taxonomy_lock);
|
||||
persist_taxonomy(state);
|
||||
*error = NAUT_OK;
|
||||
return json_object();
|
||||
}
|
||||
|
||||
/* --- generic web-UI blob store (RSS feeds, indexer config, ...) ------------ *
|
||||
* The web layer owns these schemas; the daemon only persists them, one JSON
|
||||
* document per key, under <state_dir>/webui_<key>.json. Keys are sanitized to a
|
||||
|
|
@ -2393,8 +2303,6 @@ static bool register_commands(daemon_state *state) {
|
|||
naut_rpc_register(state->rpc, "script_status", rpc_script_status, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "update_script", rpc_update_script, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "set_script_settings", rpc_set_script_settings, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "get_label_taxonomy", rpc_get_label_taxonomy, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "set_label_taxonomy", rpc_set_label_taxonomy, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "get_webui_blob", rpc_get_webui_blob, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "set_webui_blob", rpc_set_webui_blob, state) == NAUT_OK &&
|
||||
naut_rpc_register(state->rpc, "unload_script", rpc_unload_script, state) == NAUT_OK &&
|
||||
|
|
@ -2685,9 +2593,6 @@ static bool resolve_state_dir(daemon_state *state, const char *override) {
|
|||
"%s/script_settings.json", dir) >=
|
||||
sizeof state->settings_file)
|
||||
return false;
|
||||
if ((size_t)snprintf(state->taxonomy_file, sizeof state->taxonomy_file,
|
||||
"%s/labels.json", dir) >= sizeof state->taxonomy_file)
|
||||
return false;
|
||||
if ((size_t)snprintf(state->data_dir, sizeof state->data_dir, "%s", dir) >=
|
||||
sizeof state->data_dir)
|
||||
return false;
|
||||
|
|
@ -2796,8 +2701,6 @@ int main(int argc, char **argv) {
|
|||
pthread_mutex_init(&state.script_lock, NULL);
|
||||
pthread_mutex_init(&state.settings_lock, NULL);
|
||||
load_script_settings(&state); /* user-set values; schema comes from the script */
|
||||
pthread_mutex_init(&state.taxonomy_lock, NULL);
|
||||
load_taxonomy(&state); /* persisted category + tag lists for the web UI */
|
||||
pthread_mutex_init(&state.blob_lock, NULL);
|
||||
state.events = naut_event_bus_create();
|
||||
state.rpc = naut_rpc_registry_create();
|
||||
|
|
@ -2881,9 +2784,6 @@ int main(int argc, char **argv) {
|
|||
json_decref(state.script_settings);
|
||||
json_decref(state.script_settings_schema);
|
||||
pthread_mutex_destroy(&state.settings_lock);
|
||||
json_decref(state.label_categories);
|
||||
json_decref(state.label_tags);
|
||||
pthread_mutex_destroy(&state.taxonomy_lock);
|
||||
pthread_mutex_destroy(&state.blob_lock);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ typedef struct {
|
|||
char host_name[64];
|
||||
char auth_user[64]; /* bootstrap admin name (for startup banner) */
|
||||
char auth_password[64]; /* generated bootstrap password (banner only) */
|
||||
webui_store *auth; /* SQLite-backed account store */
|
||||
webui_store *store; /* SQLite store: accounts, taxonomy, RSS */
|
||||
int port;
|
||||
int listener;
|
||||
bool generated_password;
|
||||
|
|
@ -308,8 +308,8 @@ static void init_auth(void) {
|
|||
log_msg(0, "webui: cannot resolve account DB path; set NAUT_WEBUI_DB");
|
||||
return;
|
||||
}
|
||||
g_webui.auth = webui_store_open(db_path);
|
||||
if (!g_webui.auth) {
|
||||
g_webui.store = webui_store_open(db_path);
|
||||
if (!g_webui.store) {
|
||||
log_msg(0, "webui: failed to open account database");
|
||||
return;
|
||||
}
|
||||
|
|
@ -318,7 +318,7 @@ static void init_auth(void) {
|
|||
if (!user || !*user) user = "admin";
|
||||
snprintf(g_webui.auth_user, sizeof g_webui.auth_user, "%s", user);
|
||||
|
||||
if (webui_store_user_count(g_webui.auth) > 0) return; /* already set up */
|
||||
if (webui_store_user_count(g_webui.store) > 0) return; /* already set up */
|
||||
|
||||
/* No accounts yet — create the initial admin. */
|
||||
const char *password = getenv("NAUT_AUTH_PASSWORD");
|
||||
|
|
@ -332,7 +332,7 @@ static void init_auth(void) {
|
|||
log_msg(0, "webui: no CSPRNG; set NAUT_AUTH_PASSWORD to create the admin");
|
||||
return;
|
||||
}
|
||||
if (!webui_store_create_user(g_webui.auth, user, password, "admin"))
|
||||
if (!webui_store_create_user(g_webui.store, user, password, "admin"))
|
||||
log_msg(0, "webui: failed to create the initial admin account");
|
||||
}
|
||||
|
||||
|
|
@ -959,39 +959,29 @@ static void webui_sync_all_labels(void) {
|
|||
|
||||
/* Persist the full category + tag lists (including unassigned ones) to the
|
||||
* daemon so they survive restarts. */
|
||||
/* Persist the category + tag lists to the web-UI's own database. */
|
||||
static void webui_sync_taxonomy(void) {
|
||||
if (!g_webui.store) return;
|
||||
pthread_mutex_lock(&g_webui.meta_lock);
|
||||
json_t *cats = json_deep_copy(g_webui.categories);
|
||||
json_t *tags = json_deep_copy(g_webui.tags);
|
||||
pthread_mutex_unlock(&g_webui.meta_lock);
|
||||
json_t *params = json_pack("{s:o,s:o}",
|
||||
"categories", cats ? cats : json_array(),
|
||||
"tags", tags ? tags : json_array());
|
||||
if (!params) { json_decref(cats); json_decref(tags); return; }
|
||||
json_t *reply = rpc_call_json("set_label_taxonomy", params);
|
||||
json_decref(params);
|
||||
if (reply) json_decref(reply);
|
||||
if (cats) { webui_store_save_categories(g_webui.store, cats); json_decref(cats); }
|
||||
if (tags) { webui_store_save_tags(g_webui.store, tags); json_decref(tags); }
|
||||
}
|
||||
|
||||
/* Seed the category + tag lists from the daemon's persisted copy at startup. */
|
||||
/* Seed the category + tag lists from the database at startup. */
|
||||
static void webui_load_taxonomy(void) {
|
||||
json_t *params = json_object();
|
||||
json_t *reply = rpc_call_json("get_label_taxonomy", params);
|
||||
json_decref(params);
|
||||
if (!json_is_object(reply)) { json_decref(reply); return; }
|
||||
json_t *cats = json_object_get(reply, "categories");
|
||||
json_t *tags = json_object_get(reply, "tags");
|
||||
if (!g_webui.store) return;
|
||||
json_t *cats = json_array(), *tags = json_array();
|
||||
bool ok_c = webui_store_load_categories(g_webui.store, cats);
|
||||
bool ok_t = webui_store_load_tags(g_webui.store, tags);
|
||||
pthread_mutex_lock(&g_webui.meta_lock);
|
||||
if (json_is_array(cats)) {
|
||||
json_decref(g_webui.categories);
|
||||
g_webui.categories = json_deep_copy(cats);
|
||||
}
|
||||
if (json_is_array(tags)) {
|
||||
json_decref(g_webui.tags);
|
||||
g_webui.tags = json_deep_copy(tags);
|
||||
}
|
||||
if (ok_c) { json_decref(g_webui.categories); g_webui.categories = cats; }
|
||||
else json_decref(cats);
|
||||
if (ok_t) { json_decref(g_webui.tags); g_webui.tags = tags; }
|
||||
else json_decref(tags);
|
||||
pthread_mutex_unlock(&g_webui.meta_lock);
|
||||
json_decref(reply);
|
||||
}
|
||||
|
||||
static bool store_get_name(uint64_t id, char *out, size_t out_size) {
|
||||
|
|
@ -2736,7 +2726,7 @@ static bool valid_username(const char *u) {
|
|||
/* GET /api/users → [{username, role, createdAt}] (admin only). */
|
||||
static void api_users_list(int fd) {
|
||||
json_t *users = json_array();
|
||||
if (g_webui.auth) webui_store_list_users(g_webui.auth, users);
|
||||
if (g_webui.store) webui_store_list_users(g_webui.store, users);
|
||||
http_json(fd, 200, users);
|
||||
json_decref(users);
|
||||
}
|
||||
|
|
@ -2753,8 +2743,8 @@ static void api_user_create(int fd, const char *body, size_t len) {
|
|||
"username (letters/digits/._-) and password are required");
|
||||
return;
|
||||
}
|
||||
bool ok = g_webui.auth &&
|
||||
webui_store_create_user(g_webui.auth, user, pass,
|
||||
bool ok = g_webui.store &&
|
||||
webui_store_create_user(g_webui.store, user, pass,
|
||||
role && *role ? role : "user");
|
||||
json_decref(req);
|
||||
if (!ok) { http_text(fd, 409, "Conflict", "user already exists"); return; }
|
||||
|
|
@ -2773,18 +2763,18 @@ static void api_user_delete(int fd, const char *body, size_t len,
|
|||
char role[16] = {0};
|
||||
/* Look up the target's role to guard the last-admin rule. */
|
||||
json_t *list = json_array();
|
||||
if (g_webui.auth) webui_store_list_users(g_webui.auth, list);
|
||||
if (g_webui.store) webui_store_list_users(g_webui.store, list);
|
||||
size_t i; json_t *u;
|
||||
json_array_foreach(list, i, u)
|
||||
if (strcasecmp(json_string_or(u, "username", ""), user) == 0)
|
||||
snprintf(role, sizeof role, "%s", json_string_or(u, "role", ""));
|
||||
json_decref(list);
|
||||
if (strcmp(role, "admin") == 0 && webui_store_admin_count(g_webui.auth) <= 1) {
|
||||
if (strcmp(role, "admin") == 0 && webui_store_admin_count(g_webui.store) <= 1) {
|
||||
json_decref(req);
|
||||
http_text(fd, 409, "Conflict", "cannot delete the last admin");
|
||||
return;
|
||||
}
|
||||
bool ok = g_webui.auth && webui_store_delete_user(g_webui.auth, user);
|
||||
bool ok = g_webui.store && webui_store_delete_user(g_webui.store, user);
|
||||
json_decref(req);
|
||||
if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; }
|
||||
drop_user_sessions(user);
|
||||
|
|
@ -2804,7 +2794,7 @@ static void api_user_set_password(int fd, const char *body, size_t len) {
|
|||
}
|
||||
char user[64];
|
||||
snprintf(user, sizeof user, "%s", uname);
|
||||
bool ok = g_webui.auth && webui_store_set_password(g_webui.auth, user, pass);
|
||||
bool ok = g_webui.store && webui_store_set_password(g_webui.store, user, pass);
|
||||
json_decref(req);
|
||||
if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; }
|
||||
drop_user_sessions(user); /* force re-login with the new password */
|
||||
|
|
@ -2823,11 +2813,11 @@ static void api_user_set_role(int fd, const char *body, size_t len) {
|
|||
http_text(fd, 400, "Bad Request", "username and role (admin|user) required");
|
||||
return;
|
||||
}
|
||||
if (strcmp(role, "user") == 0 && webui_store_admin_count(g_webui.auth) <= 1) {
|
||||
if (strcmp(role, "user") == 0 && webui_store_admin_count(g_webui.store) <= 1) {
|
||||
/* Only block if the target is currently the sole admin. */
|
||||
char cur[16] = {0};
|
||||
json_t *list = json_array();
|
||||
if (g_webui.auth) webui_store_list_users(g_webui.auth, list);
|
||||
if (g_webui.store) webui_store_list_users(g_webui.store, list);
|
||||
size_t i; json_t *u;
|
||||
json_array_foreach(list, i, u)
|
||||
if (strcasecmp(json_string_or(u, "username", ""), user) == 0)
|
||||
|
|
@ -2839,7 +2829,7 @@ static void api_user_set_role(int fd, const char *body, size_t len) {
|
|||
return;
|
||||
}
|
||||
}
|
||||
bool ok = g_webui.auth && webui_store_set_role(g_webui.auth, user, role);
|
||||
bool ok = g_webui.store && webui_store_set_role(g_webui.store, user, role);
|
||||
json_decref(req);
|
||||
if (!ok) { http_text(fd, 404, "Not Found", "no such user"); return; }
|
||||
api_users_list(fd);
|
||||
|
|
@ -2857,12 +2847,12 @@ static void api_account_password(int fd, const char *body, size_t len,
|
|||
return;
|
||||
}
|
||||
char role[16] = {0};
|
||||
if (!g_webui.auth || !webui_store_verify(g_webui.auth, actor, oldp, role, sizeof role)) {
|
||||
if (!g_webui.store || !webui_store_verify(g_webui.store, actor, oldp, role, sizeof role)) {
|
||||
json_decref(req);
|
||||
http_text(fd, 403, "Forbidden", "current password is incorrect");
|
||||
return;
|
||||
}
|
||||
bool ok = webui_store_set_password(g_webui.auth, actor, newp);
|
||||
bool ok = webui_store_set_password(g_webui.store, actor, newp);
|
||||
json_decref(req);
|
||||
if (!ok) { http_text(fd, 500, "Internal Server Error", "could not update password"); return; }
|
||||
json_t *reply = json_pack("{s:b}", "ok", 1);
|
||||
|
|
@ -2895,8 +2885,8 @@ static void handle_api(int fd, const char *method, char *path,
|
|||
const char *password =
|
||||
json_string_value(json_object_get(req, "password"));
|
||||
char role[16] = {0};
|
||||
bool ok = g_webui.auth && user && password &&
|
||||
webui_store_verify(g_webui.auth, user, password, role, sizeof role);
|
||||
bool ok = g_webui.store && user && password &&
|
||||
webui_store_verify(g_webui.store, user, password, role, sizeof role);
|
||||
if (!ok) {
|
||||
json_t *json = json_pack("{s:b,s:s}", "ok", 0,
|
||||
"error", "invalid credentials");
|
||||
|
|
@ -3240,7 +3230,7 @@ static naut_err start_server(void) {
|
|||
if (strcmp(g_webui.host_name, DEFAULT_HOST) != 0)
|
||||
log_msg(1, "webui: bound to a non-loopback address; credentials cross "
|
||||
"the network in plaintext (set NAUT_AUTH_PASSWORD)");
|
||||
if (!g_webui.auth) {
|
||||
if (!g_webui.store) {
|
||||
log_msg(0, "webui: account store unavailable; logins will fail");
|
||||
} else if (g_webui.generated_password && g_webui.auth_password[0]) {
|
||||
/* First run: surface the generated admin credentials once. */
|
||||
|
|
@ -3373,7 +3363,7 @@ naut_err naut_plugin_shutdown(void) {
|
|||
pthread_cond_destroy(&g_webui.conn_cond);
|
||||
pthread_mutex_destroy(&g_webui.conn_lock);
|
||||
pthread_mutex_destroy(&g_webui.auth_lock);
|
||||
webui_store_close(g_webui.auth);
|
||||
g_webui.auth = NULL;
|
||||
webui_store_close(g_webui.store);
|
||||
g_webui.store = NULL;
|
||||
return NAUT_OK;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,32 @@ webui_store *webui_store_open(const char *path) {
|
|||
" pw_salt TEXT NOT NULL,"
|
||||
" pw_iters INTEGER NOT NULL,"
|
||||
" role TEXT NOT NULL DEFAULT 'user',"
|
||||
" created_at INTEGER NOT NULL);";
|
||||
" created_at INTEGER NOT NULL);"
|
||||
"CREATE TABLE IF NOT EXISTS categories ("
|
||||
" name TEXT PRIMARY KEY,"
|
||||
" save_path TEXT NOT NULL DEFAULT '');"
|
||||
"CREATE TABLE IF NOT EXISTS tags (name TEXT PRIMARY KEY);"
|
||||
"CREATE TABLE IF NOT EXISTS feeds ("
|
||||
" name TEXT PRIMARY KEY,"
|
||||
" url TEXT NOT NULL,"
|
||||
" last_update INTEGER NOT NULL DEFAULT 0,"
|
||||
" articles TEXT NOT NULL DEFAULT '[]');"
|
||||
"CREATE TABLE IF NOT EXISTS rules ("
|
||||
" name TEXT PRIMARY KEY,"
|
||||
" enabled INTEGER NOT NULL DEFAULT 1,"
|
||||
" use_regex INTEGER NOT NULL DEFAULT 0,"
|
||||
" add_paused INTEGER NOT NULL DEFAULT 0,"
|
||||
" must_contain TEXT NOT NULL DEFAULT '',"
|
||||
" must_not_contain TEXT NOT NULL DEFAULT '',"
|
||||
" assigned_category TEXT NOT NULL DEFAULT '',"
|
||||
" save_path TEXT NOT NULL DEFAULT '',"
|
||||
" affected_feeds TEXT NOT NULL DEFAULT '[]',"
|
||||
" last_match INTEGER NOT NULL DEFAULT 0);"
|
||||
"CREATE TABLE IF NOT EXISTS indexers ("
|
||||
" name TEXT PRIMARY KEY,"
|
||||
" url TEXT NOT NULL DEFAULT '',"
|
||||
" apikey TEXT NOT NULL DEFAULT '',"
|
||||
" enabled INTEGER NOT NULL DEFAULT 1);";
|
||||
char *err = NULL;
|
||||
if (sqlite3_exec(s->db, schema, NULL, NULL, &err) != SQLITE_OK) {
|
||||
sqlite3_free(err);
|
||||
|
|
@ -272,3 +297,94 @@ bool webui_store_list_users(webui_store *s, json_t *out) {
|
|||
pthread_mutex_unlock(&s->lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* --- taxonomy ------------------------------------------------------------- */
|
||||
|
||||
/* Replace one table's contents from a json array, inside a transaction. The
|
||||
* `bind` callback binds each element's columns onto the prepared INSERT. */
|
||||
static bool replace_table(webui_store *s, const char *del_sql,
|
||||
const char *ins_sql, json_t *items,
|
||||
void (*bind)(sqlite3_stmt *, json_t *)) {
|
||||
if (!s || !json_is_array(items)) return false;
|
||||
pthread_mutex_lock(&s->lock);
|
||||
bool ok = sqlite3_exec(s->db, "BEGIN;", NULL, NULL, NULL) == SQLITE_OK &&
|
||||
sqlite3_exec(s->db, del_sql, NULL, NULL, NULL) == SQLITE_OK;
|
||||
sqlite3_stmt *st = NULL;
|
||||
if (ok && sqlite3_prepare_v2(s->db, ins_sql, -1, &st, NULL) == SQLITE_OK) {
|
||||
size_t i; json_t *v;
|
||||
json_array_foreach(items, i, v) {
|
||||
bind(st, v);
|
||||
if (sqlite3_step(st) != SQLITE_DONE) { ok = false; break; }
|
||||
sqlite3_reset(st);
|
||||
}
|
||||
} else ok = false;
|
||||
sqlite3_finalize(st);
|
||||
sqlite3_exec(s->db, ok ? "COMMIT;" : "ROLLBACK;", NULL, NULL, NULL);
|
||||
pthread_mutex_unlock(&s->lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static const char *str_or(json_t *o, const char *k, const char *fallback) {
|
||||
const char *v = json_string_value(json_object_get(o, k));
|
||||
return v ? v : fallback;
|
||||
}
|
||||
|
||||
static void bind_category(sqlite3_stmt *st, json_t *c) {
|
||||
sqlite3_bind_text(st, 1, str_or(c, "name", ""), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(st, 2, str_or(c, "savePath", ""), -1, SQLITE_TRANSIENT);
|
||||
}
|
||||
|
||||
bool webui_store_save_categories(webui_store *s, json_t *cats) {
|
||||
return replace_table(s, "DELETE FROM categories;",
|
||||
"INSERT OR REPLACE INTO categories (name, save_path) VALUES (?,?);",
|
||||
cats, bind_category);
|
||||
}
|
||||
|
||||
bool webui_store_load_categories(webui_store *s, json_t *out) {
|
||||
if (!s || !json_is_array(out)) return false;
|
||||
pthread_mutex_lock(&s->lock);
|
||||
sqlite3_stmt *st = NULL;
|
||||
bool ok = false;
|
||||
if (sqlite3_prepare_v2(s->db,
|
||||
"SELECT name, save_path FROM categories ORDER BY name;",
|
||||
-1, &st, NULL) == SQLITE_OK) {
|
||||
ok = true;
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const char *n = (const char *)sqlite3_column_text(st, 0);
|
||||
const char *p = (const char *)sqlite3_column_text(st, 1);
|
||||
json_array_append_new(out, json_pack("{s:s,s:s}",
|
||||
"name", n ? n : "", "savePath", p ? p : ""));
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
pthread_mutex_unlock(&s->lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static void bind_tag(sqlite3_stmt *st, json_t *t) {
|
||||
sqlite3_bind_text(st, 1, json_string_value(t) ? json_string_value(t) : "",
|
||||
-1, SQLITE_TRANSIENT);
|
||||
}
|
||||
|
||||
bool webui_store_save_tags(webui_store *s, json_t *tags) {
|
||||
return replace_table(s, "DELETE FROM tags;",
|
||||
"INSERT OR REPLACE INTO tags (name) VALUES (?);", tags, bind_tag);
|
||||
}
|
||||
|
||||
bool webui_store_load_tags(webui_store *s, json_t *out) {
|
||||
if (!s || !json_is_array(out)) return false;
|
||||
pthread_mutex_lock(&s->lock);
|
||||
sqlite3_stmt *st = NULL;
|
||||
bool ok = false;
|
||||
if (sqlite3_prepare_v2(s->db, "SELECT name FROM tags ORDER BY name;",
|
||||
-1, &st, NULL) == SQLITE_OK) {
|
||||
ok = true;
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const char *n = (const char *)sqlite3_column_text(st, 0);
|
||||
json_array_append_new(out, json_string(n ? n : ""));
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
pthread_mutex_unlock(&s->lock);
|
||||
return ok;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,4 +44,12 @@ bool webui_store_delete_user(webui_store *s, const char *username);
|
|||
* json array `out`. Returns false on error. */
|
||||
bool webui_store_list_users(webui_store *s, json_t *out);
|
||||
|
||||
/* --- category / tag taxonomy (web-UI organization, owned here) ------------- *
|
||||
* The save_* calls replace the whole list atomically; the load_* calls append
|
||||
* to the (array) `out`. Categories are {name, savePath}; tags are strings. */
|
||||
bool webui_store_save_categories(webui_store *s, json_t *cats);
|
||||
bool webui_store_load_categories(webui_store *s, json_t *out);
|
||||
bool webui_store_save_tags(webui_store *s, json_t *tags);
|
||||
bool webui_store_load_tags(webui_store *s, json_t *out);
|
||||
|
||||
#endif /* NAUT_WEBUI_STORE_H */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue