agendafs
A filesystem for your calendar.
git clone git://mccd.space/agendafscommit ac0e41c804d8a5d5fbcac87e5d4d8fcb39bf63c4
parent 0d8cbe2f7bc83e01e2bb86a8d8aa7ff86e2741b1
Author: Marc Coquand <marc@coquand.email>
Date: Sat, 31 May 2025 15:05:26 +0100
Add support for inotify
Diffstat:
3 files changed, 505 insertions(+), 29 deletions(-)
diff --git a/main.c b/main.c
@@ -9,8 +9,14 @@
#include <time.h>
#include <sys/stat.h>
#include <regex.h>
+#include <pthread.h>
+#include <sys/inotify.h>
+#include <unistd.h>
+#define ICS_DIR "/home/mccd/dev/fuse-journal/sample_ics"
+#define LOG(fmt, ...) fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
-#define ICS_DIR "./sample_ics"
+// Listening to file changes of the original content
+#define EVENT_BUF_LEN (1024 * (sizeof(struct inotify_event) + 16))
const char *display_date_fmt = "%b %d, %Y at %H:%M";
const char *date_title_fmt = "%Y%m%d-%H%M%S";
@@ -28,11 +34,14 @@ typedef struct
static journal_entry *entries = NULL;
static size_t entry_count = 0;
+static pthread_mutex_t entries_mutex = PTHREAD_MUTEX_INITIALIZER;
+
static void free_entry(journal_entry *entry)
{
if (entry)
{
free(entry->filename);
+ free(entry->filename_original);
free(entry->summary);
free(entry->description);
}
@@ -45,6 +54,8 @@ static void free_all_entries()
free_entry(&entries[i]);
}
free(entries);
+ entries = NULL;
+ entry_count = 0;
}
static time_t parse_datetime(const char *datetime)
@@ -64,6 +75,9 @@ static time_t parse_datetime(const char *datetime)
return -1;
}
+
+
+
static int compile_regexes(regex_t *regex_summary, regex_t *regex_description, regex_t *regex_dtstamp, regex_t *regex_lastmod)
{
// Parse text after SUMMARY: up until \r\n
@@ -126,6 +140,51 @@ static void unescape_text(char *desc)
*dst = '\0';
}
+
+
+
+
+// RFC 5545 specifies how human-readable text is escaped
+// https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.11
+static char *escape_text(const char *src)
+{
+ if (!src) return NULL;
+
+ size_t len = strlen(src);
+ // Worst-case size (every char becomes \X): len * 2 + 1
+ char *escaped = calloc(len * 2 + 1, 1);
+ if (!escaped) return NULL;
+
+ char *dst = escaped;
+ for (const char *p = src; *p; ++p)
+ {
+ switch (*p)
+ {
+ case '\n':
+ *dst++ = '\\';
+ *dst++ = 'n';
+ break;
+ case '\\':
+ *dst++ = '\\';
+ *dst++ = '\\';
+ break;
+ case ',':
+ *dst++ = '\\';
+ *dst++ = ',';
+ break;
+ case ';':
+ *dst++ = '\\';
+ *dst++ = ';';
+ break;
+ default:
+ *dst++ = *p;
+ break;
+ }
+ }
+ *dst = '\0';
+ return escaped;
+}
+
static int parse_ics_file(const char *file_path, journal_entry *entry)
{
FILE *file = fopen(file_path, "r");
@@ -249,7 +308,10 @@ cleanup:
static void load_journal_entries()
{
- printf("Loading journal entries from: %s\n", ICS_DIR);
+ pthread_mutex_lock(&entries_mutex);
+
+ free_all_entries();
+ LOG("Loading journal entries from: %s\n", ICS_DIR);
DIR *dir = opendir(ICS_DIR);
if (!dir)
{
@@ -269,8 +331,9 @@ static void load_journal_entries()
if (parse_ics_file(filepath, &new_entry) == 0)
{
char filename_time_str[64];
- struct tm *tm_info = localtime(&new_entry.created_at);
- strftime(filename_time_str, sizeof(filename_time_str), date_title_fmt, tm_info);
+ struct tm tm_info;
+ localtime_r(&new_entry.created_at, &tm_info);
+ strftime(filename_time_str, sizeof(filename_time_str), date_title_fmt, &tm_info);
new_entry.filename = strdup(filename_time_str);
entries = realloc(entries, sizeof(journal_entry) * (entry_count + 1));
@@ -282,19 +345,26 @@ static void load_journal_entries()
}
}
}
- printf("Loaded %zu journal entries.\n", entry_count);
+ LOG("Loaded %zu journal entries.\n", entry_count);
closedir(dir);
+
+ pthread_mutex_unlock(&entries_mutex);
}
static int journal_getattr(const char *path, struct stat *stbuf, struct fuse_file_info *fi)
{
+ pthread_mutex_lock(&entries_mutex);
+
memset(stbuf, 0, sizeof(struct stat));
+ int ret_code = 0;
+
if (strcmp(path, "/") == 0)
{
stbuf->st_mode = S_IFDIR | 0755;
stbuf->st_nlink = 2;
- return 0;
+ ret_code = 0;
+ goto unlock_and_return;
}
for (size_t i = 0; i < entry_count; i++)
@@ -305,10 +375,12 @@ static int journal_getattr(const char *path, struct stat *stbuf, struct fuse_fil
{
// S_IFREG = Regular file
stbuf->st_mode = S_IFREG | 0444;
+ // Regular file has one link (name in directory)
stbuf->st_nlink = 1;
- struct tm *tm_info = localtime(&entries[i].timestamp);
+ struct tm tm_info;
+ localtime_r(&entries[i].timestamp, &tm_info);
char timestamp_str[64];
- strftime(timestamp_str, sizeof(timestamp_str), display_date_fmt, tm_info);
+ strftime(timestamp_str, sizeof(timestamp_str), display_date_fmt, &tm_info);
// Calculate the file size as in journal_read
int size = snprintf(NULL, 0, "%s\n%s\n---\n%s\n",
@@ -325,18 +397,29 @@ static int journal_getattr(const char *path, struct stat *stbuf, struct fuse_fil
// Access time is current time
stbuf->st_atime = time(NULL);
- return 0;
+
+ ret_code = 0;
+ goto unlock_and_return;
}
}
- return -ENOENT;
+ ret_code = -ENOENT;
+
+unlock_and_return:
+ pthread_mutex_unlock(&entries_mutex);
+ return ret_code;
}
static int journal_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
off_t offset, struct fuse_file_info *fi,
enum fuse_readdir_flags flags)
{
+
+ pthread_mutex_lock(&entries_mutex);
if (strcmp(path, "/") != 0)
+ {
+ pthread_mutex_unlock(&entries_mutex);
return -ENOENT;
+ }
filler(buf, ".", NULL, 0, 0);
filler(buf, "..", NULL, 0, 0);
@@ -348,24 +431,35 @@ static int journal_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
filler(buf, entry_name, NULL, 0, 0);
}
+ pthread_mutex_unlock(&entries_mutex);
return 0;
}
static int journal_open(const char *path, struct fuse_file_info *fi)
{
+ pthread_mutex_lock(&entries_mutex);
+
for (size_t i = 0; i < entry_count; i++)
{
char entry_path[256];
snprintf(entry_path, sizeof(entry_path), "/%s.txt", entries[i].filename);
if (strcmp(path, entry_path) == 0)
+ {
+ pthread_mutex_unlock(&entries_mutex);
return 0;
+ }
}
+ pthread_mutex_unlock(&entries_mutex);
return -ENOENT;
}
static int journal_read(const char *path, char *buf, size_t size, off_t offset,
struct fuse_file_info *fi)
{
+
+ int ret_code = 0;
+ pthread_mutex_lock(&entries_mutex);
+
for (size_t i = 0; i < entry_count; i++)
{
char entry_path[256];
@@ -374,21 +468,26 @@ static int journal_read(const char *path, char *buf, size_t size, off_t offset,
if (strcmp(path, entry_path) == 0)
{
// Format timestamp
- struct tm *tm_info = localtime(&entries[i].created_at);
+ struct tm tm_info;
+ localtime_r(&entries[i].created_at, &tm_info);
char timestamp_str[64];
- strftime(timestamp_str, sizeof(timestamp_str), display_date_fmt, tm_info);
+ strftime(timestamp_str, sizeof(timestamp_str), display_date_fmt, &tm_info);
// Build full content string using snprintf(NULL, 0) to get length
int needed_len = (size_t)snprintf(NULL, 0, "%s\n%s\n---\n%s\n",
entries[i].summary, timestamp_str, entries[i].description);
if (needed_len < 0)
- return -EIO; // snprintf error
+ {
+ ret_code = -EIO;
+ goto unlock_and_return;
+ }
char *full_content = calloc(needed_len + 1, sizeof(char));
if (!full_content)
{
perror("Memory allocation failed");
- return -ENOMEM;
+ ret_code = -ENOMEM;
+ goto unlock_and_return;
}
snprintf(full_content, needed_len + 1, "%s\n%s\n---\n%s\n",
@@ -398,7 +497,9 @@ static int journal_read(const char *path, char *buf, size_t size, off_t offset,
if (offset >= needed_len)
{
free(full_content);
- return 0; // EOF
+ //EOF
+ ret_code = 0;
+ goto unlock_and_return;
}
if (offset + size > needed_len)
@@ -407,27 +508,399 @@ static int journal_read(const char *path, char *buf, size_t size, off_t offset,
memcpy(buf, full_content + offset, size);
free(full_content);
- return size;
+
+ ret_code = size;
+ goto unlock_and_return;
}
}
- return -ENOENT;
+
+unlock_and_return:
+ pthread_mutex_unlock(&entries_mutex);
+ return ret_code;
+}
+
+static int journal_truncate(const char *path, off_t size, struct fuse_file_info *fi)
+{
+ (void)fi; // Unused
+
+ // We only support truncate to zero
+ if (size != 0)
+ return -EOPNOTSUPP;
+
+ int ret_code = 0;
+ pthread_mutex_lock(&entries_mutex);
+
+ for (size_t i = 0; i < entry_count; i++)
+ {
+ char entry_path[256];
+ snprintf(entry_path, sizeof(entry_path), "/%s.txt", entries[i].filename);
+
+ if (strcmp(path, entry_path) == 0)
+ {
+ // Clear summary and description
+ free(entries[i].summary);
+ entries[i].summary = strdup("Untitled");
+
+ free(entries[i].description);
+ entries[i].description = strdup("");
+
+ time_t now = time(NULL);
+ entries[i].timestamp = now;
+
+ // Format time for LAST-MODIFIED
+ struct tm *tm_info = gmtime(&now);
+ char timestamp_str[32];
+ strftime(timestamp_str, sizeof(timestamp_str), "%Y%m%dT%H%M%SZ", tm_info);
+
+ // Load original .ics file
+ char full_path[512];
+ snprintf(full_path, sizeof(full_path), "%s/%s", ICS_DIR, entries[i].filename_original);
+ printf("Attempting to open: %s\n", full_path);
+ FILE *fp = fopen(full_path, "r+");
+
+ if (!fp) {
+ ret_code = -EIO;
+ goto unlock_and_return;
+ }
+
+ fseek(fp, 0, SEEK_END);
+ long length = ftell(fp);
+ fseek(fp, 0, SEEK_SET);
+
+ char *content = calloc(length + 1, 1);
+ if (!content) {
+ fclose(fp);
+ ret_code = -ENOMEM;
+ goto unlock_and_return;
+ }
+
+ fread(content, 1, length, fp);
+ fclose(fp);
+
+ char *updated = content;
+ regex_t re;
+
+ // Replace SUMMARY
+ if (regcomp(&re, "SUMMARY:[^\r\n]*", REG_EXTENDED) == 0) {
+ regmatch_t match;
+ if (regexec(&re, content, 1, &match, 0) == 0) {
+ size_t pre = match.rm_so;
+ size_t post = strlen(content) - match.rm_eo;
+ size_t new_len = pre + 8 + strlen("Untitled") + post + 1;
+ updated = malloc(new_len);
+ snprintf(updated, new_len, "%.*sSUMMARY:Untitled%s",
+ (int)pre, content, content + match.rm_eo);
+ free(content);
+ content = updated;
+ }
+ regfree(&re);
+ }
+
+ // Replace DESCRIPTION
+ if (regcomp(&re, "DESCRIPTION:[^\r\n]*", REG_EXTENDED) == 0) {
+ regmatch_t match;
+ if (regexec(&re, content, 1, &match, 0) == 0) {
+ size_t pre = match.rm_so;
+ size_t post = strlen(content) - match.rm_eo;
+ size_t new_len = pre + strlen("DESCRIPTION:") + post + 1;
+ updated = malloc(new_len);
+ snprintf(updated, new_len, "%.*sDESCRIPTION:%s",
+ (int)pre, content, content + match.rm_eo);
+ free(content);
+ content = updated;
+ }
+ regfree(&re);
+ }
+
+ // Replace LAST-MODIFIED
+ if (regcomp(&re, "LAST-MODIFIED:[^\r\n]*", REG_EXTENDED) == 0) {
+ regmatch_t match;
+ if (regexec(&re, content, 1, &match, 0) == 0) {
+ size_t pre = match.rm_so;
+ size_t post = strlen(content) - match.rm_eo;
+ size_t new_len = pre + strlen(timestamp_str) + post + 16;
+ updated = malloc(new_len);
+ snprintf(updated, new_len, "%.*sLAST-MODIFIED:%s%s",
+ (int)pre, content, timestamp_str, content + match.rm_eo);
+ free(content);
+ content = updated;
+ }
+ regfree(&re);
+ }
+
+ // Write back to file
+ fp = fopen(full_path, "w");
+ if (!fp) {
+ free(content);
+ ret_code = -EIO;
+ goto unlock_and_return;
+ }
+
+ fwrite(content, 1, strlen(content), fp);
+ fclose(fp);
+ free(content);
+
+ ret_code = 0;
+ goto unlock_and_return;
+ }
+ }
+
+ ret_code = -ENOENT;
+
+unlock_and_return:
+ pthread_mutex_unlock(&entries_mutex);
+ return ret_code;
+
+}
+
+static int journal_write(const char *path, const char *buf, size_t size,
+ off_t offset, struct fuse_file_info *fi)
+{
+ (void)fi; // unused
+
+ int ret_code = 0;
+ pthread_mutex_lock(&entries_mutex);
+
+ for (size_t i = 0; i < entry_count; i++)
+ {
+ char entry_path[256];
+ snprintf(entry_path, sizeof(entry_path), "/%s.txt", entries[i].filename);
+
+ if (strcmp(path, entry_path) == 0)
+ {
+ // Copy buffer into a temporary string for parsing
+ char *temp_buf = strndup(buf, size);
+ if (!temp_buf)
+ {
+ ret_code = -ENOMEM;
+ goto unlock_and_return;
+ }
+
+ // Split into lines
+ char *summary = strtok(temp_buf, "\n");
+ char *line;
+ char *description = NULL;
+
+ while ((line = strtok(NULL, "\n")) != NULL) {
+ if (strcmp(line, "---") == 0) {
+ description = strtok(NULL, "");
+ break;
+ }
+ }
+
+ if (!summary || !description) {
+ free(temp_buf);
+ ret_code = -EINVAL;
+ goto unlock_and_return;
+ }
+
+ // Escape summary and description
+ char *escaped_summary = escape_text(summary);
+ char *escaped_description = escape_text(description);
+ if (!escaped_summary || !escaped_description) {
+ free(temp_buf);
+ ret_code = -ENOMEM;
+ goto unlock_and_return;
+ }
+
+ // Escape per RFC 5545
+ for (char *p = escaped_summary; *p; p++) {
+ if (*p == '\n') *p = ' ';
+ }
+
+ // Get timestamp
+ time_t now = time(NULL);
+ struct tm *tm_info = gmtime(&now);
+ char timestamp_str[32];
+ strftime(timestamp_str, sizeof(timestamp_str), "%Y%m%dT%H%M%SZ", tm_info);
+
+ // Load original file content
+ char full_path[512];
+ snprintf(full_path, sizeof(full_path), "%s/%s", ICS_DIR, entries[i].filename_original);
+ printf("Attempting to open: %s\n", full_path);
+
+ FILE *fp = fopen(full_path, "r+");
+ if (!fp) {
+ perror("Failed to open file for writing");
+ free(temp_buf);
+ ret_code = -EIO;
+ goto unlock_and_return;
+ }
+
+ // Read original content
+ fseek(fp, 0, SEEK_END);
+ long length = ftell(fp);
+ fseek(fp, 0, SEEK_SET);
+ char *original = calloc(length + 1, 1);
+ if (!original) {
+ fclose(fp);
+ free(temp_buf);
+ ret_code = -ENOMEM;
+ goto unlock_and_return;
+ }
+ fread(original, 1, length, fp);
+ fclose(fp);
+
+ // Replace the fields using regex
+ regex_t re;
+ char *updated = NULL;
+
+ // Replace SUMMARY
+ if (regcomp(&re, "SUMMARY:[^\r\n]*", REG_EXTENDED) == 0) {
+ regmatch_t match;
+ if (regexec(&re, original, 1, &match, 0) == 0) {
+ size_t pre_len = match.rm_so;
+ size_t post_len = strlen(original) - match.rm_eo;
+ size_t new_len = pre_len + strlen(escaped_summary) + post_len + 8;
+ updated = malloc(new_len);
+ snprintf(updated, new_len, "%.*sSUMMARY:%s%s",
+ (int)pre_len, original, escaped_summary, original + match.rm_eo);
+ free(original);
+ original = updated;
+ }
+ regfree(&re);
+ }
+
+ // Replace DESCRIPTION
+ if (regcomp(&re, "DESCRIPTION:[^\r\n]*", REG_EXTENDED) == 0) {
+ regmatch_t match;
+ if (regexec(&re, original, 1, &match, 0) == 0) {
+ size_t pre_len = match.rm_so;
+ size_t post_len = strlen(original) - match.rm_eo;
+ size_t new_len = pre_len + strlen(escaped_description) + post_len + 13;
+ updated = malloc(new_len);
+ snprintf(updated, new_len, "%.*sDESCRIPTION:%s%s",
+ (int)pre_len, original, escaped_description, original + match.rm_eo);
+ free(original);
+ original = updated;
+ }
+ regfree(&re);
+ }
+
+ // Replace LAST-MODIFIED
+ if (regcomp(&re, "LAST-MODIFIED:[^\r\n]*", REG_EXTENDED) == 0) {
+ regmatch_t match;
+ if (regexec(&re, original, 1, &match, 0) == 0) {
+ size_t pre_len = match.rm_so;
+ size_t post_len = strlen(original) - match.rm_eo;
+ size_t new_len = pre_len + strlen(timestamp_str) + post_len + 20;
+ updated = malloc(new_len);
+ snprintf(updated, new_len, "%.*sLAST-MODIFIED:%s%s",
+ (int)pre_len, original, timestamp_str, original + match.rm_eo);
+ free(original);
+ original = updated;
+ }
+ regfree(&re);
+ }
+
+ // Write updated content back
+ fp = fopen(full_path, "w");
+ if (!fp) {
+ free(original);
+ ret_code = -EIO;
+ goto unlock_and_return;
+ }
+ fwrite(original, 1, strlen(original), fp);
+ fclose(fp);
+
+ // Update in-memory values
+ free(entries[i].summary);
+ entries[i].summary = strdup(escaped_summary);
+
+ free(entries[i].description);
+ entries[i].description = strdup(escaped_description);
+
+ entries[i].timestamp = now;
+
+ free(temp_buf);
+ free(escaped_summary);
+ free(escaped_description);
+ free(original);
+ ret_code = size;
+ goto unlock_and_return;
+ }
+ }
+
+ ret_code = -ENOENT;
+
+unlock_and_return:
+ pthread_mutex_unlock(&entries_mutex);
+ return ret_code;
+}
+
+void *watch_ics_dir(void *arg) {
+ int fd = inotify_init1(IN_NONBLOCK);
+ if (fd < 0) {
+ perror("inotify_init");
+ return NULL;
+ }
+
+ int wd = inotify_add_watch(fd, ICS_DIR, IN_CREATE | IN_DELETE | IN_MODIFY);
+ if (wd < 0) {
+ perror("inotify_add_watch");
+ close(fd);
+ return NULL;
+ }
+
+ char buffer[EVENT_BUF_LEN];
+
+ while (1) {
+ ssize_t length = read(fd, buffer, EVENT_BUF_LEN);
+ if (length < 0) {
+ if (errno == EAGAIN || errno == EINTR) {
+ sleep(1);
+ continue;
+ }
+ perror("read");
+ break;
+ }
+
+ size_t i = 0;
+ while (i < (size_t)length) {
+ struct inotify_event *event = (struct inotify_event *)&buffer[i];
+
+ if (event->len && strstr(event->name, ".ics")) {
+ printf("Detected change in ICS file: %s\n", event->name);
+ load_journal_entries(); // Reload entries on any change
+ break; // Avoid excessive reloads in batch events
+ }
+
+ i += sizeof(struct inotify_event) + event->len;
+ }
+
+ sleep(1); // Debounce for a second
+ }
+
+ inotify_rm_watch(fd, wd);
+ close(fd);
+ return NULL;
}
static const struct fuse_operations journal_oper =
{
- .getattr = journal_getattr,
- .readdir = journal_readdir,
- .open = journal_open,
- .read = journal_read,
+ .getattr = journal_getattr,
+ .readdir = journal_readdir,
+ .open = journal_open,
+ .read = journal_read,
+ .write = journal_write,
+ .truncate = journal_truncate
};
int main(int argc, char *argv[])
-{
- load_journal_entries();
- int ret = fuse_main(argc, argv, &journal_oper, NULL);
+{
+ pthread_t watcher_thread;
+ load_journal_entries();
- // Cleanup before exit
- free_all_entries();
+ if (pthread_create(&watcher_thread, NULL, watch_ics_dir, NULL) != 0) {
+ perror("Failed to create inotify watcher thread");
+ return 1;
+ }
+
+ int ret = fuse_main(argc, argv, &journal_oper, NULL);
+
+ pthread_cancel(watcher_thread); // Stop the watcher when FUSE ends
+ pthread_join(watcher_thread, NULL); // Clean up
- return ret;
+ free_all_entries();
+ return ret;
}
diff --git a/sample_ics/2025-01-01.ics b/sample_ics/2025-01-01.ics
@@ -3,6 +3,6 @@ BEGIN:VJOURNAL
UID:20250101T120000Z-001@host.com
DTSTAMP:20250101T120000Z
SUMMARY:New Year's Day Reflections
-DESCRIPTION:Reflecting on the past year and setting goals for the new one.
+DESCRIPTION:the past year and setting goals for the new one HELLO.\n\nHi world\n\n\n
END:VJOURNAL
-END:VCALENDAR
+END:VCA
diff --git a/shell.nix b/shell.nix
@@ -4,6 +4,9 @@
pkgs.mkShell {
hardeningDisable = [ "all" ];
+ nativeBuildInputs = with pkgs; [
+ pkg-config
+ ];
packages = with pkgs; [
clang-tools
fuse3