From 52294b774b8ea9725db3206d6eb930b34e1959eb Mon Sep 17 00:00:00 2001 From: moss Date: Sun, 26 Oct 2025 01:12:27 -0500 Subject: [PATCH] initial commit --- build.sh | 8 + fmj.c | 327 ++++++++++++++++++++++++++++ readme.md | 23 ++ tests/defaultauthor/.fmjconf | 3 + tests/defaultauthor/alices_post.fmj | 2 + tests/defaultauthor/bobs_post.fmj | 3 + tests/example/.fmjconf | 2 + tests/example/test1.fmj | 3 + tests/noconf/test1.fmj | 1 + 9 files changed, 372 insertions(+) create mode 100755 build.sh create mode 100644 fmj.c create mode 100644 readme.md create mode 100644 tests/defaultauthor/.fmjconf create mode 100644 tests/defaultauthor/alices_post.fmj create mode 100644 tests/defaultauthor/bobs_post.fmj create mode 100644 tests/example/.fmjconf create mode 100644 tests/example/test1.fmj create mode 100644 tests/noconf/test1.fmj diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..c7b09aa --- /dev/null +++ b/build.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +# works on my system and is a reference for yours + +zig 0.15.1 cc -std=c99 -pedantic -Wall -Wextra -Os fmj.c -o fmj +# this is anyzig, which will run an arbitrary version of the zig compiler, downloading it if necessary. +# zig ships many versions of libc and wraps clang, so its convenient for me +# tl;dr you can replace `zig 0.15.1 cc` with something like `clang` or `gcc` or `cc` diff --git a/fmj.c b/fmj.c new file mode 100644 index 0000000..f7e645c --- /dev/null +++ b/fmj.c @@ -0,0 +1,327 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + + +#define LINE_MAX UINT16_MAX + +// appended to the input directory to get the path to the feed's config +const char* fmjconfp = ".fmjconf"; + +const char* fmj_extension = ".fmj"; + +// .fmjconf and .fmj option names +const char* description = "description"; +const char* title = "title"; +const char* link = "link"; +const char* default_author = "default_author"; +const char* author = "author"; +const char* category = "category"; +const char* comments = "comments"; +const char* pub_date = "pub_date"; + +// global for the default_author from the .fmjconf +char* default_author_name = NULL; + +void print_usage(void) { + puts("usage:\nfmj dir [output file]"); +} + +// caller must free returned ptr +char* join_path(const char* a, const char* b) { + char* buf = malloc(strlen(a) + strlen(b) + 2); + strcpy(buf, a); + strncat(buf, "/", 2); + strncat(buf, b, strlen(b) + 1); + return buf; +} + +// parses an option from `line` that is formatted like this: +// "title = contents" +// +// it will ignore the whitespace around the "=", +// and return a pointer to the beginning of "contents", +// or return NULL if it didn't match. +const char* parse_option(const char* line, const char* option) { + size_t optlen = strlen(option); + // if it doesn't have an "=" and then at least one + // char of content then its not valid + if (strlen(line) < optlen + 2) { + return NULL; + } + + if (strncmp(line, option, optlen) != 0) { + return NULL; + } + + const char* cur = line + optlen; + + bool foundequals = false; + + while (true) { + if (isblank(*cur)) { + cur++; + continue; + } + if (!foundequals && *cur == '=') { + foundequals = true; + cur++; + continue; + } + // empty option is invalid + if (foundequals && *cur != '\n') { + break; + } + // at this point any valid cases have been exhausted + return NULL; + } + + return cur; +} + +void write_without_newline(const char* content, FILE* output) { + while (*content != '\0' && *content != '\n') { + fputc(*content, output); + content++; + } +} +/* broken old version with out of bounds read because of `||` xd + + void write_without_newline(FILE* output, const char* content) { + while (*content != '\0' || *content != '\n') { + fputc(*content, output); + content++; + } +}*/ + + +void write_feed_header(FILE* output, FILE* fmjconf) { + fputs("", output); + + bool found_title = false; + bool found_link = false; + bool found_description = false; + bool found_default_author = false; + + char buf[LINE_MAX]; + if (fmjconf != NULL) { + while (fgets(buf, LINE_MAX, fmjconf) != NULL) { + const char* opt; + if ((opt = parse_option(buf, title)) != NULL && !found_title) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + found_title = true; + continue; + } + if ((opt = parse_option(buf, link)) != NULL && !found_link) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + found_link = true; + continue; + } + if ((opt = parse_option(buf, description)) != NULL && !found_description) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + found_description = true; + continue; + } + if ((opt = parse_option(buf, default_author)) != NULL && !found_default_author) { + default_author_name = malloc(strlen(opt) + 1); + strcpy(default_author_name, opt); + found_default_author = true; + continue; + } + } + } + + if (!found_title) { + fputs("fmj generated feed", output); + } + + if (!found_link) { + fputs("http://example.com/", output); + } + + if (!found_description) { + fputs("an rss feed generated by fmj", output); + } + + fputs("fmj", output); +} + +void write_feed_item(FILE* output, const char* dir, const char* entryname) { + if (dir == NULL || entryname == NULL) { + return; + } + + // only consider .fmj files + size_t namelen = strlen(entryname); + size_t extlen = strlen(fmj_extension); + if (namelen < extlen) { + return; + } + if (strncmp(entryname + namelen - extlen, fmj_extension, extlen) != 0) { + return; + } + + char* path = join_path(dir, entryname); + FILE* file = fopen(path, "r"); + if (file == NULL) { + free(path); + return; + } + + char buf[LINE_MAX]; + + // make sure it has either a title or description + bool has_requirements = false; + while (fgets(buf, LINE_MAX, file) != NULL) { + if (parse_option(buf, title) || parse_option(buf, description)) { + has_requirements = true; + break; + } + } + if (!has_requirements) { + return; + } + rewind(file); + + bool found_title = false; + bool found_description = false; + bool found_link = false; + bool found_pub_date = false; + bool found_author = false; + + fputs("", output); + + while (fgets(buf, LINE_MAX, file) != NULL) { + const char* opt; + if ((opt = parse_option(buf, title)) != NULL && !found_title) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + } + if ((opt = parse_option(buf, link)) != NULL && !found_link) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + } + if ((opt = parse_option(buf, description)) != NULL && !found_description) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + } + if ((opt = parse_option(buf, author)) != NULL && !found_author) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + } + if ((opt = parse_option(buf, pub_date)) != NULL && !found_pub_date) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + } + if ((opt = parse_option(buf, category)) != NULL) { + fputs("", output); + write_without_newline(opt, output); + fputs("", output); + } + } + + if (!found_author && default_author_name != NULL) { + fputs("", output); + write_without_newline(default_author_name, output); + fputs("", output); + } + + fputs("", output); + + free(path); +} + +void write_feed_footer(FILE* output) { + fputs("", output); +} + + +int main(int argc, char** argv) { + if (argc < 2) { + print_usage(); + return 1; + } + + + DIR* dir = opendir(argv[1]); + if (dir == NULL) { + puts("failed to open the input directory!!!"); + return 1; + } + + + char* outfilepath = "rss.xml"; + + if (argc >= 3) { + outfilepath = argv[2]; + } + + FILE* output; + + if (*outfilepath == '-') { + output = stdout; + } else { + output = fopen(outfilepath, "w"); + if (output == NULL) { + puts("failed to open output file!!!!"); + return 1; + } + } + + + char* fmjconfpath = join_path(argv[1], fmjconfp); + + FILE* fmjconf = fopen(fmjconfpath, "r"); + + write_feed_header(output, fmjconf); + + if (fmjconf != NULL) { + fclose(fmjconf); + } + + free(fmjconfpath); + + + struct dirent* entry; + + while (true) { + errno = 0; + entry = readdir(dir); + if (entry != NULL) { + write_feed_item(output, argv[1], entry->d_name); + } else { + if (errno == 0) { + closedir(dir); + break; + } + // i am going to assume getting here is bad and exit + puts("assertion failed, readdir errored."); + return 1; + } + } + + + write_feed_footer(output); + + + if (output != stdout) { + fclose(output); + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..37823a3 --- /dev/null +++ b/readme.md @@ -0,0 +1,23 @@ +# fmj +fmj is an rss feed generator. it takes a directory of config files that describe the posts and outputs xml. + +the file format is more concise than handwriting xml: +```fmj +title = s/discord/??? +description = on replacing discord +link = https://bear.o7moon.dev/sdiscord/ + +anything that isnt one of the defined options is a comment +``` + +it does not yet properly handle utf-8 input but should eventually. +## usage examples: +- `fmj tests/examples -` will output to stdout +- `fmj tests/noconf filename.xml` will output to `filename.xml` +- `fmj tests/defaultauthor` will output to `rss.xml` +## building / platform support: +`fmj.c` is the only source file. if you have libc, and a `dirent.h` on your system then it should work. + +`build.sh` is what i use on my desktop to compile it, as an example. +## miscellaneous: +the name fmj comes from [my favorite band](https://feedmejack.bandcamp.com/), but i will lie and say its stands for "Feed m Jenerator" or something. diff --git a/tests/defaultauthor/.fmjconf b/tests/defaultauthor/.fmjconf new file mode 100644 index 0000000..1868466 --- /dev/null +++ b/tests/defaultauthor/.fmjconf @@ -0,0 +1,3 @@ +title = default author test case +description = contains a post with an author option and one without, to test the default author option from the .fmjconf file +default_author = alice diff --git a/tests/defaultauthor/alices_post.fmj b/tests/defaultauthor/alices_post.fmj new file mode 100644 index 0000000..1415725 --- /dev/null +++ b/tests/defaultauthor/alices_post.fmj @@ -0,0 +1,2 @@ +title = alice's post +description = this post implicitly has alice as the author due to the .fmjconf diff --git a/tests/defaultauthor/bobs_post.fmj b/tests/defaultauthor/bobs_post.fmj new file mode 100644 index 0000000..0d20eb1 --- /dev/null +++ b/tests/defaultauthor/bobs_post.fmj @@ -0,0 +1,3 @@ +title = bob's post +author = bob +description = bob wrote this post. diff --git a/tests/example/.fmjconf b/tests/example/.fmjconf new file mode 100644 index 0000000..394d7da --- /dev/null +++ b/tests/example/.fmjconf @@ -0,0 +1,2 @@ +title = example rss feed !!!!!!!!! +description = this is an example generated by fmj as a test case diff --git a/tests/example/test1.fmj b/tests/example/test1.fmj new file mode 100644 index 0000000..6aee6c9 --- /dev/null +++ b/tests/example/test1.fmj @@ -0,0 +1,3 @@ +title = TEST TITLE !!!!!! + +this is a comment diff --git a/tests/noconf/test1.fmj b/tests/noconf/test1.fmj new file mode 100644 index 0000000..a66d5a3 --- /dev/null +++ b/tests/noconf/test1.fmj @@ -0,0 +1 @@ +title = this is a test where the config file doesnt exist