agendafs

A filesystem for your calendar.

git clone git://mccd.space/agendafs
commit ac0e41c804d8a5d5fbcac87e5d4d8fcb39bf63c4
parent 0d8cbe2f7bc83e01e2bb86a8d8aa7ff86e2741b1
Author: Marc Coquand <marc@coquand.email>
Date:   Sat, 31 May 2025 15:05:26 +0100

Add support for inotify

Diffstat:
Mmain.c | 527+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msample_ics/2025-01-01.ics | 4++--
Mshell.nix | 3+++
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