agendafs

A filesystem for your calendar.

git clone git://mccd.space/agendafs
commit 92da920839dd1da8652b0c143e94a184e44ed628
parent 15bb8424d696e8fa99fc53f78389024c9b64c6d3
Author: Marc Coquand <marc@coquand.email>
Date:   Sun,  1 Jun 2025 15:24:30 +0100

Cleanup and rename

Diffstat:
MMakefile | 10+++++-----
MREADME.md | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mmain.c | 145+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
3 files changed, 186 insertions(+), 68 deletions(-)
diff --git a/Makefile b/Makefile
@@ -3,13 +3,13 @@ CFLAGS = -Wall -O3
 CFLAGS_DEBUG = -Wall -g
 LIBS = `pkg-config fuse3 --cflags --libs`
 
-all: journalfs
+all: caldavfs
 
-journalfs: main.c hashmap.c
-	$(CC) $(CFLAGS) main.c hashmap.c -o journalfs $(LIBS)
+caldavfs: main.c hashmap.c
+	$(CC) $(CFLAGS) main.c hashmap.c -o caldavfs $(LIBS)
 
 debug: main.c hashmap.c
-	$(CC) $(CFLAGS_DEBUG) $(DEBUG_FLAGS) main.c hashmap.c -o journalfs-debug $(LIBS)
+	$(CC) $(CFLAGS_DEBUG) $(DEBUG_FLAGS) main.c hashmap.c -o caldavfs-debug $(LIBS)
 
 clean:
-	rm -f journalfs journalfs-debug
+	rm -f caldavfs caldavfs-debug
diff --git a/README.md b/README.md
@@ -1,22 +1,99 @@
-# journalfs
+# caldavfs - Mount your caldav server as a filesystem
 
-A true journaling file system for humans! Allows you to keep your journal
-in your CalDAV server.
+`caldavfs` is a FUSE-based filesystem written in C. Use it to manage your journal, todos, events et.c. as plain-text files that are easy to modify.
 
-This fuse-based filesystem integrates with a directory of ics
-to display files.
+## Motivation
 
-This will mount a directory of journal entries created by
-[vdirsyncer](https://vdirsyncer.pimutils.org/en/stable/index.html) and
-allow you to create journal entries with your favorite tools. `vdirsyncer`
-then syncs these back to your caldav server.
+Caldav is great in the sense that it has a lot of support from various
+app. However, the format is not particularly human-friendly. In the iCalendar
+spec, VJOURNAL entries look like this:
 
-Usage:
+```
+  BEGIN:VJOURNAL
+  UID:19970901T130000Z-123405@host.com
+  DTSTAMP:19970901T1300Z
+  DTSTART;VALUE=DATE:19970317
+  SUMMARY:Staff meeting minutes
+  DESCRIPTION:1. Staff meeting: Participants include Joe\, Lisa
+    and Bob. Aurora project plans were reviewed. There is currently
+    no budget reserves for this project. Lisa will escalate to
+    management. Next meeting on Tuesday.\n
+    2. Telephone Conference: ABC Corp. sales representative called
+    to discuss new printer. Promised to get us a demo by Friday.\n
+    3. Henry Miller (Handsoff Insurance): Car was totaled by tree.
+    Is looking into a loaner car. 654-2323 (tel).
+  END:VJOURNAL
+```
+
+These are not really intended to be edited by users. Using `caldavfs`,
+you can mount a directory of `.ics` files. Once mounted, it will expose the
+entries in a way that is more human-readable:
+
+1997-09-01-13:00.txt
+```
+Staff meeting minutes
+---
+1. Staff meeting: Participants include Joe, Lisa and Bob. Aurora project plans were reviewed. There is currently no budget reserves for this project. Lisa will escalate to management. Next meeting on Tuesday.
+2. Telephone Conference: ABC Corp. sales representative called to discuss new printer. Promised to get us a demo by Friday.
+3. Henry Miller (Handsoff Insurance): Car was totaled by tree. Is looking into a loaner car. 654-2323 (tel).
+```
+
+Now you can open the files in your favorite editor and any changes you make
+will be reflected in the original journal. Use your favorite tools: grep, vim, ls et.c.
+
+
+## Dependencies
+
+Right now it's built with GCC, but the aim is for it using only [cproc](https://sr.ht/~mcf/cproc/). You'll also need libfuse and pkg-config.
+
+Right now you also need a tool that syncs .ics files with a caldav server,
+like [vdirsyncer](https://vdirsyncer.pimutils.org/en/stable/when.html#).
+
+Someday I hope that `caldavfs` handles it.
+
+## Why C?
+
+I started this project to improve my C skills.
+
+## Building
+
+Run
+
+```
+make
+```
+
+## Status
+
+POC stage, still very early alpha.
+
+Planned features:
+- [ ] VJOURNAL - Now the first true journaling filesystem!
+	- [ ] Create
+	- [ ] Delete
+	- [x] Read
+	- [x] Rename
+	- [x] Write
+	- [ ] Directory structure
+- [ ] VTODO
+- [ ] Categories (Maybe via symlinks?)
+- [ ] VEVENTS
+- [ ] Relationships / linking
+- [ ] VALARM
+- [ ] VFREEBUSY
+
+## Setup
+
+caldavfs works together with [vdirsyncer](https://vdirsyncer.pimutils.org/en/stable/when.html#). Vdirsyncer is used to periodically sync `.ics` files to your caldav server, allowing you to have an offline copy.
+
+In the future I might build the vdirsyncer functionality into this. But for now you will need it for this to work.
+
+
+## Usage
 
 ```
 mkdir /tmp/journal
 ./journalfs $HOME/.journal/XXX-XXX-XXX /tmp/journal
 # Add -d for debugging
 ```
-
 Will mount journalfs
diff --git a/main.c b/main.c
@@ -22,8 +22,7 @@
 #define EVENT_BUF_LEN (1024 * (sizeof(struct inotify_event) + 16))
 
 const char *date_title_fmt = "%Y%m%d-%H:%M";
-regex_t regex_summary, regex_description, regex_dtstamp, regex_lastmod,
-    regex_uid, regex_fuse_file;
+regex_t regex_dtstamp, regex_lastmod, regex_fuse_file;
 
 time_t INVALID_TIME = (time_t)-1;
 
@@ -72,24 +71,65 @@ 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,
-			   regex_t *regex_uid, regex_t *regex_fuse_file)
+// Folds are [\r]\n followed by ' ' or '\t'
+static int skip_fold(const char *p)
+{
+	// '\r\n' + ' ' OR '\t'
+	if (*p == '\r' && p[1] == '\n' && (p[2] == ' ' || p[2] == '\t'))
+		return 3;
+
+	// '\n' + ' ' OR '\t'
+	if (*p == '\n' && (p[1] == ' ' || p[1] == '\t'))
+		return 2;
+
+	return 0;
+}
+
+// Description is multiline and cuts of at 75 characters.
+// Once extracted it will also need to be escaped.
+// Field name should be without colon, I.E.
+// "DESCRIPTION"
+char *extract_ical_field(const char *ical, const char *field_name)
+{
+	size_t field_len = strlen(field_name);
+	const char *start = strstr(ical, field_name);
+
+	start += field_len;
+
+	// skip the colon
+	start++;
+
+	const char *p = start;
+	char *result = calloc(strlen(p) + 1, sizeof(char));
+
+	size_t len = 0;
+
+	while (*p) {
+		p += skip_fold(p);
+
+		// end of field
+		if ((*p == '\r' && p[1] == '\n') || *p == '\n') {
+			break;
+		}
+
+		result[len++] = *p++;
+	}
+
+	result[len] = '\0';
+
+	// Shrink the buffer to the actual size
+	char *final = realloc(result, len + 1);
+	return final;
+}
+
+static int compile_regexes(regex_t *regex_dtstamp, regex_t *regex_lastmod,
+			   regex_t *regex_fuse_file)
 {
-	// Parse text after SUMMARY: up until \r\n
-	if (regcomp(regex_summary, "SUMMARY:([^\r\n]+)", REG_EXTENDED) != 0)
-		return -1;
-	// Parse text after DESCRIPTION:, multiple lines up until \r\n
-	if (regcomp(regex_description, "DESCRIPTION:([^\r\n]*)",
-		    REG_EXTENDED) != 0)
-		return -1;
 	if (regcomp(regex_dtstamp, "DTSTAMP:([0-9T]+Z)", REG_EXTENDED) != 0)
 		return -1;
 	if (regcomp(regex_lastmod, "LAST-MODIFIED:([0-9T]+Z)", REG_EXTENDED) !=
 	    0)
 		return -1;
-	if (regcomp(regex_uid, "UID:([^\r\n]+)", REG_EXTENDED) != 0)
-		return -1;
 	if (regcomp(regex_fuse_file,
 		    "^([0-9]{8})-([0-9]{2}:[0-9]{2})(_[^/\\:*?\"<>|]+)?\\.txt$",
 		    REG_EXTENDED) != 0)
@@ -138,7 +178,8 @@ static void unescape_text(char *desc)
 				*dst++ = *src;
 				break;
 			}
-			src++;
+			if (*src)
+				src++;
 		}
 		else {
 			*dst++ = *src++;
@@ -152,14 +193,25 @@ done:
 // 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);
-	char *escaped = calloc(len * 2 + 1, 1);
-
+	// Safe amount
+	size_t max_len = len * 2;
+	char *escaped = calloc(max_len, 1);
 	char *dst = escaped;
+	int line_len = 0;
+
 	for (const char *p = src; *p; ++p) {
+		int esc_len =
+		    (*p == '\n' || *p == '\\' || *p == ',' || *p == ';') ? 2
+									 : 1;
+
+		if (line_len + esc_len > 75) {
+			*dst++ = '\r';
+			*dst++ = '\n';
+			*dst++ = ' ';
+			line_len = 1;
+		}
+
 		switch (*p) {
 		case '\n':
 			*dst++ = '\\';
@@ -181,9 +233,14 @@ static char *escape_text(const char *src)
 			*dst++ = *p;
 			break;
 		}
+
+		line_len += esc_len;
 	}
+
 	*dst = '\0';
-	return escaped;
+	size_t actual_len = strlen(escaped);
+
+	return realloc(escaped, actual_len);
 }
 
 static journal_entry *get_entry_from_fuse_path(const char *path)
@@ -229,45 +286,32 @@ static int parse_ics_file(const char *file_path, journal_entry *entry)
 	file_content[file_size] = '\0'; // Null-terminate the string
 	fclose(file);
 
-	regmatch_t matches_summary[2], matches_description[2],
-	    matches_dtstamp[2], matches_lastmod[2], matches_uid[2];
+	regmatch_t matches_dtstamp[2], matches_lastmod[2];
 
 	// SUMMARY
-	if (regexec(&regex_summary, file_content, 2, matches_summary, 0) != 0) {
-		fprintf(stderr, "SUMMARY field not found in: %s\n", file_path);
-		goto cleanup;
-	}
-
-	entry->summary = extract_match(file_content, matches_summary[1]);
+	entry->summary = extract_ical_field(file_content, "SUMMARY");
 	if (!entry->summary) {
-		perror("Memory allocation failed for SUMMARY");
+		fprintf(stderr, "SUMMARY field not found in: %s\n", file_path);
 		goto cleanup;
 	}
 	unescape_text(entry->summary);
 
 	// UID
-	if (regexec(&regex_uid, file_content, 2, matches_uid, 0) == 0) {
-		entry->uid = extract_match(file_content, matches_uid[1]);
-	}
-	else {
-		// If missing, generate one (fallback)
-		LOG("FAILED TO GET UID");
-		asprintf(&entry->uid, "%ld@fuse-journal", time(NULL));
+	entry->uid = extract_ical_field(file_content, "UID");
+	if (!entry->uid) {
+		fprintf(stderr, "UID field not found in: %s\n", file_path);
+		goto cleanup;
 	}
+	unescape_text(entry->uid);
 
 	// DESCRIPTION
-	if (regexec(&regex_description, file_content, 2, matches_description,
-		    0) != 0) {
+	entry->description = extract_ical_field(file_content, "DESCRIPTION");
+	if (!entry->description) {
 		fprintf(stderr, "DESCRIPTION field not found in: %s\n",
 			file_path);
 		goto cleanup;
 	}
-	entry->description =
-	    extract_match(file_content, matches_description[1]);
-	if (!entry->description) {
-		perror("Memory allocation failed for DESCRIPTION");
-		goto cleanup;
-	}
+
 	unescape_text(entry->description);
 
 	// Extract the DTSTAMP, our created_at
@@ -563,7 +607,7 @@ static int journal_read(const char *path, char *buf, size_t size, off_t offset,
 		goto unlock_and_return;
 	}
 
-	LOG("Got entry %s", entry->description);
+	LOG("Got entry %s", entry->summary);
 
 	// Calculate required length for full content
 	int needed_len = get_entry_content_size(entry);
@@ -630,6 +674,7 @@ char *journal_entry_to_ical(const journal_entry *entry)
 
 	snprintf(ical, total_size,
 		 "BEGIN:VCALENDAR\n"
+		 "PRODID:caldavfs\n"
 		 "BEGIN:VJOURNAL\n"
 		 "UID:%s\n"
 		 "DTSTAMP:%s\n"
@@ -990,9 +1035,8 @@ int main(int argc, char *argv[])
 		return 1;
 	}
 
-	if (compile_regexes(&regex_summary, &regex_description, &regex_dtstamp,
-			    &regex_lastmod, &regex_uid,
-			    &regex_fuse_file) != 0) {
+	if (compile_regexes(&regex_dtstamp, &regex_lastmod, &regex_fuse_file) !=
+	    0) {
 		fprintf(stderr, "Failed to compile regular expressions\n");
 		return -1;
 	}
@@ -1009,11 +1053,8 @@ int main(int argc, char *argv[])
 	hashmap_free(entries_original_key);
 	LOG("Hashmap freed");
 
-	regfree(&regex_summary);
-	regfree(&regex_description);
 	regfree(&regex_dtstamp);
 	regfree(&regex_lastmod);
-	regfree(&regex_uid);
 	regfree(&regex_fuse_file);
 	LOG("Regexp freed");
 	return ret;