commit dfdc13b84fbcb99f3c3fb64fcd5dd8c1c75726c5
parent 882a17e9d78dfb2020ee57a7434c317fd85a6f2c
Author: citbl <citbl@citbl.org>
Date: Thu, 9 Oct 2025 22:22:27 +1000
added sqlite backing
Diffstat:
| M | config.h | | | 26 | +++++++++++++++++--------- |
| A | db.c | | | 228 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | db.h | | | 30 | ++++++++++++++++++++++++++++++ |
| M | feeds.c | | | 71 | ++++++++++++++++++++++++++++++----------------------------------------- |
| M | keys.c | | | 23 | +++++++++++++++-------- |
| M | makefile | | | 2 | +- |
| M | readr.c | | | 14 | ++++++++++---- |
| M | readr.h | | | 6 | +++--- |
| M | render.c | | | 10 | +++++----- |
| M | tui.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;
}