readr

Minimal RSS reader (WIP)
Log | Files | Refs | README | LICENSE

commit f2ba00300f4fa81398c6e1237e0e158a2590f499
parent 7ebb052b753ab830687a3016fbcd85ce8cffb6aa
Author: citbl <citbl@citbl.org>
Date:   Thu,  9 Oct 2025 22:22:27 +1000

added sqlite backing

Diffstat:
Mconfig.h | 26+++++++++++++++++---------
Adb.c | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adb.h | 30++++++++++++++++++++++++++++++
Mfeeds.c | 71++++++++++++++++++++++++++++++-----------------------------------------
Mkeys.c | 23+++++++++++++++--------
Mmakefile | 2+-
Mreadr.c | 14++++++++++----
Mreadr.h | 6+++---
Mrender.c | 10+++++-----
Mtui.c | 5+++--
10 files changed, 342 insertions(+), 73 deletions(-)

diff --git a/config.h b/config.h @@ -2,19 +2,18 @@ #define VERSION "v1.1" +// source file for RSS URLS, one per line +// folder and file must exist #define FEEDS "~/.config/readr/feeds" -// the width of the left column: -#define FEED_CAP 26 +// folder must exist, file will be automatically created +// can technically be cloud shared, dropbox etc. for replication +#define DB_PATH "~/.config/readr/posts.sqlite" -// allocation caps -#define POSTS_CAP 128 -#define FEEDS_CAP 32 -#define TITLE_CAP 128 -#define URL_CAP 8192 - -// for colour details see termbox2.h +// the width of the left column (in chars) +#define FEED_CAP 26 +// colours, for colour options, see termbox2.h #define LOGO_COLOR (TB_WHITE | TB_BRIGHT) #define TEXT_COLOR (TB_WHITE | TB_DIM) #define BACK_COLOR TB_DEFAULT @@ -22,3 +21,12 @@ #define FEED_COLOR TB_CYAN #define POST_COLOR (TB_YELLOW) #define SEEN_COLOR (TB_YELLOW | TB_DIM) + +// how many posts we want to see per feed +#define MAX_POST_PER_FEED 100 + +// allocation caps, best not to fiddle with this +#define POSTS_CAP 128 +#define FEEDS_CAP 32 +#define TITLE_CAP 128 +#define URL_CAP 8192 diff --git a/db.c b/db.c @@ -0,0 +1,228 @@ + +#include <stdlib.h> +#include <sqlite3.h> +#include <sys/param.h> + +#include "db.h" +#include "utils.h" +#include "config.h" + +static sqlite3* db = NULL; + +void +db_create(void) +{ + const char* dbpath = expand_tilde(DB_PATH); + + const char* create_table = "CREATE TABLE IF NOT EXISTS posts (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " title TEXT NOT NULL," + " link TEXT NOT NULL," + " feed_url TEXT NOT NULL," + " comments TEXT NULL," + " pub_date TEXT," + " summary TEXT," + " seen INTEGER DEFAULT 0," + " UNIQUE(feed_url, link)" + ");"; + + if (sqlite3_open(dbpath, &db) != SQLITE_OK) { + fprintf(stderr, "could not open/create db file at path %s\n", dbpath); + exit(1); + } + + if (sqlite3_exec(db, create_table, NULL, NULL, NULL) != SQLITE_OK) { + fprintf(stderr, "could not create table in db at path %s\n", DB_PATH); + exit(1); + } +} + +void +db_insert_post(db_post_t dbp) +{ + if (db == NULL) { + fprintf(stderr, "fetch_posts: bad args / missing db\n"); + exit(1); + } + if (dbp.link == NULL) { + fprintf(stderr, "trying to insert a dbpost without a link!\n"); + exit(1); + } else if (dbp.title == NULL) { + fprintf(stderr, "trying to insert a dbpost without a title.\n"); + exit(1); + } else if (dbp.feed_url == NULL) { + fprintf(stderr, "trying to insert a dbpost without a feed.\n"); + exit(1); + } + + sqlite3_stmt* ins = NULL; + const char* ins_sql = "INSERT INTO posts(title, link, feed_url, comments, pub_date, summary, seen)" + " VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(feed_url, link) DO NOTHING;"; + + int rc = sqlite3_prepare_v2(db, ins_sql, -1, &ins, NULL); + if (rc != SQLITE_OK) { + fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); + exit(1); + } + + sqlite3_reset(ins); + + sqlite3_clear_bindings(ins); + + if (sqlite3_bind_text(ins, 1, dbp.title, -1, SQLITE_TRANSIENT) != SQLITE_OK) + perror("failed db insertion of: title"); + + if (sqlite3_bind_text(ins, 2, dbp.link, -1, SQLITE_TRANSIENT) != SQLITE_OK) + perror("failed db insertion of: link"); + + if (sqlite3_bind_text(ins, 3, dbp.feed_url, -1, SQLITE_TRANSIENT) != SQLITE_OK) + perror("failed db insertion of: feed_url"); + + if (sqlite3_bind_text(ins, 4, dbp.comments ? dbp.comments : NULL, -1, SQLITE_TRANSIENT) != SQLITE_OK) + perror("failed db insertion of: comments"); + + if (sqlite3_bind_text(ins, 5, dbp.pub_date ? dbp.pub_date : NULL, -1, SQLITE_TRANSIENT) != SQLITE_OK) + perror("failed db insertion of: pub_date"); + + if (sqlite3_bind_text(ins, 6, dbp.summary ? dbp.summary : NULL, -1, SQLITE_TRANSIENT) != SQLITE_OK) + perror("failed db insertion of: summary"); + + if (sqlite3_bind_int(ins, 7, dbp.seen ? dbp.seen : 0) != SQLITE_OK) + perror("failed db insertion of: seen"); + + rc = sqlite3_step(ins); + if (rc != SQLITE_DONE) { + printf("%s\n", dbp.link); + fprintf(stderr, "step failed: %s\n", sqlite3_errmsg(db)); + } else { + int changes = sqlite3_changes(db); + if (changes == 0) { + // fprintf(stderr, "no change\n"); + } else { + // sqlite3_int64 rowid = sqlite3_last_insert_rowid(db); + // fprintf(stderr, "inserted rowid = %lld\n", (long long)rowid); + } + } + + sqlite3_finalize(ins); +} + +static char* +dup_col(sqlite3_stmt* st, int i) +{ + const unsigned char* t = sqlite3_column_text(st, i); + if (!t) return NULL; + size_t n = strlen((const char*)t); + char* s = (char*)malloc(n + 1); + if (!s) return NULL; + memcpy(s, t, n + 1); + return s; +} + +db_fetch_post_t +db_fetch_posts(const char* feed_url) +{ + if (!db || !feed_url) { + fprintf(stderr, "fetch_posts: bad args / missing db\n"); + return (db_fetch_post_t) { .success = 0 }; + } + + const char* sql = "SELECT id, title, link, feed_url, comments, pub_date, summary, seen " + "FROM posts WHERE feed_url = ? " + "ORDER BY id DESC LIMIT ?;"; + + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); + return (db_fetch_post_t) { .success = 0 }; + } + sqlite3_bind_text(st, 1, feed_url, -1, SQLITE_TRANSIENT); + sqlite3_bind_int(st, 2, MAX_POST_PER_FEED); + + int len = 0, cap = 0; + db_post_t* rows = NULL; + + while (sqlite3_step(st) == SQLITE_ROW) { + if (len == cap) { + cap = cap ? cap * 2 : MAX_POST_PER_FEED; + db_post_t* tmp = (db_post_t*)realloc(rows, (size_t)cap * sizeof(*rows)); + if (!tmp) { + fprintf(stderr, "could not reallocate for list of posts fetch from db\n"); + break; + } + rows = tmp; + } + db_post_t* p = &rows[len++]; + p->id = sqlite3_column_int(st, 0); + p->title = dup_col(st, 1); + p->link = dup_col(st, 2); + p->feed_url = dup_col(st, 3); + p->comments = dup_col(st, 4); + p->pub_date = dup_col(st, 5); + p->summary = dup_col(st, 6); + p->seen = sqlite3_column_int(st, 7); + } + + int rc = sqlite3_finalize(st); + + if (rc != SQLITE_OK) { + fprintf(stderr, "finalize: %s\n", sqlite3_errmsg(db)); + for (int i = 0; i < len; i++) { + free(rows[i].title); + free(rows[i].link); + free(rows[i].feed_url); + free(rows[i].comments); + free(rows[i].pub_date); + free(rows[i].summary); + } + free(rows); + return (db_fetch_post_t) { .success = 0 }; + } + + return (db_fetch_post_t) { + .posts = rows, + .count = len, + .success = 1, + }; +} + +int +db_mark_as_seen(int id) +{ + if (!db) { + fprintf(stderr, "db_mark_as_seen: missing db\n"); + return 1; + } + + const char* sql = "UPDATE posts SET seen=1 WHERE id=?;"; + sqlite3_stmt* st = NULL; + + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + fprintf(stderr, "db_mark_as_seen: prepare: %s\n", sqlite3_errmsg(db)); + return 1; + } + if (sqlite3_bind_int(st, 1, id) != SQLITE_OK) { + fprintf(stderr, "db_mark_as_seen: bind id: %s\n", sqlite3_errmsg(db)); + sqlite3_finalize(st); + return 1; + } + + int rc = sqlite3_step(st); + if (rc != SQLITE_DONE) { + fprintf(stderr, "db_mark_as_seen: step: %s\n", sqlite3_errmsg(db)); + sqlite3_finalize(st); + return 1; + } + + sqlite3_finalize(st); + return 0; +} + +void +db_close() +{ + if (sqlite3_close(db) != SQLITE_OK) { + fprintf(stderr, "prepare insert failed\n"); + exit(1); + } +} diff --git a/db.h b/db.h @@ -0,0 +1,30 @@ +#pragma once + +typedef struct db_post_t { + int id; + char* feed_url; + char* title; + char* link; + char* comments; + char* pub_date; + char* summary; + int seen; +} db_post_t; + +typedef struct db_fetch_post_t { + db_post_t* posts; + int count; + int success; +} db_fetch_post_t; + +// + +void db_create(void); + +void db_insert_post(db_post_t); + +db_fetch_post_t db_fetch_posts(const char* feed_url); + +int db_mark_as_seen(int id); + +void db_close(void); diff --git a/feeds.c b/feeds.c @@ -8,8 +8,10 @@ #include "config.h" #include "utils.h" +#include "db.h" void fetch_feed(feed_t*, char*); +static void populate_feed(feed_t*); app_t load_app(char* contents) @@ -27,7 +29,11 @@ load_app(char* contents) if (feed == NULL) perror("could not alloc feed"); - fetch_feed(feed, line); + fetch_feed(feed, line); // gets the content and loads it into db + printf("."); + fflush(stdout); + + populate_feed(feed); // fetch back from db top 100 if (app.feeds_cap == app.feeds_len) { app.feeds_cap *= 2; @@ -43,8 +49,7 @@ load_app(char* contents) void fetch_feed(feed_t* feed, char* url) { - *feed = (feed_t) { .url = url, .posts_cap = POSTS_CAP }; - feed->posts = (post_t**)calloc(POSTS_CAP, sizeof(post_t*)); + *feed = (feed_t) { .url = url, /*.posts_cap = POSTS_CAP*/ }; static mrss_t* rss = NULL; mrss_error_t rc = mrss_parse_url(url, &rss); @@ -61,51 +66,35 @@ fetch_feed(feed_t* feed, char* url) for (mrss_item_t* it = rss->item; it; it = it->next) { char* title = (it->title && *it->title) ? it->title : ""; - const char* link = (it->link && *it->link) ? it->link : ""; - const char* comments = (it->comments && *it->comments) ? it->comments : ""; + char* link = (it->link && *it->link) ? it->link : ""; + char* comments = (it->comments && *it->comments) ? it->comments : ""; char* desc = (it->description && *it->description) ? it->description : ""; - const char* date = (it->pubDate && *it->pubDate) ? it->pubDate : ""; - const char* author = (it->author && *it->author) ? it->author : ""; + char* date = (it->pubDate && *it->pubDate) ? it->pubDate : ""; + // char* author = (it->author && *it->author) ? it->author : ""; remove_all_tags(desc); - const size_t title_ = strlen(title), link_ = strlen(link), comments_ = strlen(comments), - desc_ = strlen(desc), date_ = strlen(date), author_ = strlen(author); - - size_t data_len = title_ + link_ + comments_ + desc_ + date_ + author_ + 1; - - char* d = calloc(data_len + 1, sizeof(char)); - - if (d == NULL) { perror("could not alloc post data"); } - - strncat(d, title, title_); - strncat(d, link, link_); - strncat(d, comments, comments_); - strncat(d, desc, desc_); - strncat(d, date, date_); - strncat(d, author, author_); - d[data_len] = '\0'; - - post_t* post = (post_t*)malloc(sizeof(post_t)); - - if (post == NULL) { perror("could not alloc post"); } - - *post = (post_t) { - .data = d, - .data_len = data_len, - .title = (slice_t) { .start = 0, .len = title_ }, - .link = (slice_t) { .start = title_, .len = link_ }, - .comments = (slice_t) { .start = title_ + link_, .len = comments_ }, - .date = (slice_t) { .start = title_ + link_ + comments_, .len = date_ }, - .author = (slice_t) { .start = title_ + link_ + comments_ + date_, .len = author_ }, + db_post_t db_post = { + .title = title, + .link = link, + .comments = comments, + .pub_date = date, + .summary = desc, + .feed_url = url, }; - if (feed->posts_len == feed->posts_cap) { - feed->posts_cap *= 2; - feed->posts = realloc(feed->posts, feed->posts_cap); - } - feed->posts[feed->posts_len++] = post; + db_insert_post(db_post); } mrss_free(rss); } + +static void +populate_feed(feed_t* feed) +{ + const char* url = feed->url; + db_fetch_post_t dbposts = db_fetch_posts(url); + if (!dbposts.success) return; + feed->posts = dbposts.posts; + feed->posts_len = dbposts.count; +} diff --git a/keys.c b/keys.c @@ -1,6 +1,7 @@ #include <sys/param.h> #include "config.h" +#include "db.h" #include "readr.h" #include "tui.h" #include "utils.h" @@ -40,12 +41,15 @@ handle_key(app_t* app, struct tb_event ev) case TB_KEY_ENTER: { if (app->selected_panel == 1) { char url[URL_CAP] = { 0 }; - post_t* post = app->feeds[app->selected_feed]->posts[app->selected_post]; - size_t len = MIN(URL_CAP, post->link.len); + db_post_t post = app->feeds[app->selected_feed]->posts[app->selected_post]; + size_t len = MIN(URL_CAP, strlen(post.link)); if (len == 0) return; - strncpy(url, &post->data[post->link.start], len); + strncpy(url, post.link, len); url[len] = '\0'; - post->seen = 1; + if (db_mark_as_seen(post.id)) { + fprintf(stderr, "could not mark post as seen, id %d\n", post.id); + } + post.seen = 1; open_url(url); } break; @@ -57,12 +61,15 @@ handle_key(app_t* app, struct tb_event ev) if (ev.ch == ' ') { // TB_KEY_SPACE for some reason doesn't work /shrug if (app->selected_panel == 1) { char url[URL_CAP] = { 0 }; - post_t* post = app->feeds[app->selected_feed]->posts[app->selected_post]; - size_t len = MIN(URL_CAP, post->comments.len); + db_post_t post = app->feeds[app->selected_feed]->posts[app->selected_post]; + size_t len = MIN(URL_CAP, strlen(post.comments)); if (len == 0) return; - strncpy(url, &post->data[post->comments.start], len); + strncpy(url, post.comments, len); url[len] = '\0'; - post->seen = 1; + if (db_mark_as_seen(post.id)) { + fprintf(stderr, "could not mark post as seen, id %d\n", post.id); + } + post.seen = 1; open_url(url); } } diff --git a/makefile b/makefile @@ -1,5 +1,5 @@ APP := readr -PKG := $(shell pkg-config --cflags --libs mrss) +PKG := $(shell pkg-config --cflags --libs mrss sqlite3) CFLAGS := -std=c99 -g -Wall -Wextra -fsanitize=address -fsanitize=undefined CREL := -std=c99 -Wall -Wextra -Wpedantic -Werror -O3 diff --git a/readr.c b/readr.c @@ -1,12 +1,13 @@ -#include <mrss.h> #include <stdlib.h> #include <string.h> #include <locale.h> +#include <mrss.h> #define UTILS_IMPL #include "utils.h" #include "config.h" +#include "db.h" #include "readr.h" #include "feeds.h" #include "tui.h" @@ -18,10 +19,12 @@ main(void) const char* ok = setlocale(LC_ALL, "C.UTF-8"); if (!ok) { - perror("setlocale"); + perror("setlocale failed"); return 1; } + db_create(); + const char* path = expand_tilde(FEEDS); if (!path) return 1; @@ -34,9 +37,12 @@ main(void) return 0; } - printf("Fetching feeds...\n\n%s\n...", feeds_contents); + printf("Fetching feeds...\n\n%s\n", feeds_contents); app_t app = load_app(feeds_contents); - return present(&app); + int display = present(&app); + + db_close(); + return display; } diff --git a/readr.h b/readr.h @@ -1,7 +1,7 @@ #pragma once #include <stdio.h> -#include "termbox2.h" +#include "db.h" // // data @@ -21,8 +21,8 @@ typedef struct { typedef struct { const char* url; char* title; - post_t** posts; - int posts_len, posts_cap; + db_post_t* posts; + int posts_len; } feed_t; typedef struct { diff --git a/render.c b/render.c @@ -62,7 +62,7 @@ render(app_t* app) uintattr_t color; size_t title_len; char title[TITLE_CAP] = { 0 }; - post_t* post; + db_post_t post; feed_t* feed; feed_t* selected_feed = app->feeds[app->selected_feed]; @@ -84,16 +84,16 @@ render(app_t* app) for (i = 0; i < selected_feed->posts_len; i++) { post = selected_feed->posts[i]; - title_len = MIN(TITLE_CAP, post->title.len); - strncpy(title, &post->data[post->title.start], title_len); + title_len = MIN(TITLE_CAP, strlen(post.title)); + strncpy(title, post.title, title_len); title[title_len] = '\0'; if (app->selected_panel == 1 && i == app->selected_post) - if (post->seen) + if (post.seen) color = (POST_COLOR | TB_REVERSE | TB_DIM); else color = (POST_COLOR | TB_REVERSE | TB_BRIGHT | TB_BOLD); - else if (post->seen) + else if (post.seen) color = (SEEN_COLOR); else color = (POST_COLOR); diff --git a/tui.c b/tui.c @@ -14,7 +14,7 @@ present(app_t* app) if (cannot_init) { fprintf(stderr, "could not TUI\n"); - exit(1); + return 1; } render(app); @@ -54,5 +54,6 @@ present(app_t* app) RIP: tb_shutdown(); - exit(1); + printf("\n"); + return 0; }