initial commit
This commit is contained in:
commit
52294b774b
8
build.sh
Executable file
8
build.sh
Executable file
@ -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`
|
||||
327
fmj.c
Normal file
327
fmj.c
Normal file
@ -0,0 +1,327 @@
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <ctype.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
|
||||
#include <dirent.h>
|
||||
|
||||
|
||||
#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("<?xml version=\"1.0\" encoding=\"UTF-8\" ?><rss version=\"2.0\"><channel>", 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("<title>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</title>", output);
|
||||
found_title = true;
|
||||
continue;
|
||||
}
|
||||
if ((opt = parse_option(buf, link)) != NULL && !found_link) {
|
||||
fputs("<link>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</link>", output);
|
||||
found_link = true;
|
||||
continue;
|
||||
}
|
||||
if ((opt = parse_option(buf, description)) != NULL && !found_description) {
|
||||
fputs("<description>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</description>", 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("<title>fmj generated feed</title>", output);
|
||||
}
|
||||
|
||||
if (!found_link) {
|
||||
fputs("<link>http://example.com/</link>", output);
|
||||
}
|
||||
|
||||
if (!found_description) {
|
||||
fputs("<description>an rss feed generated by fmj</description>", output);
|
||||
}
|
||||
|
||||
fputs("<generator>fmj</generator>", 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("<item>", output);
|
||||
|
||||
while (fgets(buf, LINE_MAX, file) != NULL) {
|
||||
const char* opt;
|
||||
if ((opt = parse_option(buf, title)) != NULL && !found_title) {
|
||||
fputs("<title>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</title>", output);
|
||||
}
|
||||
if ((opt = parse_option(buf, link)) != NULL && !found_link) {
|
||||
fputs("<link>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</link>", output);
|
||||
}
|
||||
if ((opt = parse_option(buf, description)) != NULL && !found_description) {
|
||||
fputs("<description>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</description>", output);
|
||||
}
|
||||
if ((opt = parse_option(buf, author)) != NULL && !found_author) {
|
||||
fputs("<author>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</author>", output);
|
||||
}
|
||||
if ((opt = parse_option(buf, pub_date)) != NULL && !found_pub_date) {
|
||||
fputs("<pubDate>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</pubDate>", output);
|
||||
}
|
||||
if ((opt = parse_option(buf, category)) != NULL) {
|
||||
fputs("<category>", output);
|
||||
write_without_newline(opt, output);
|
||||
fputs("</category>", output);
|
||||
}
|
||||
}
|
||||
|
||||
if (!found_author && default_author_name != NULL) {
|
||||
fputs("<author>", output);
|
||||
write_without_newline(default_author_name, output);
|
||||
fputs("</author>", output);
|
||||
}
|
||||
|
||||
fputs("</item>", output);
|
||||
|
||||
free(path);
|
||||
}
|
||||
|
||||
void write_feed_footer(FILE* output) {
|
||||
fputs("</channel></rss>", 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);
|
||||
}
|
||||
}
|
||||
23
readme.md
Normal file
23
readme.md
Normal file
@ -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.
|
||||
3
tests/defaultauthor/.fmjconf
Normal file
3
tests/defaultauthor/.fmjconf
Normal file
@ -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
|
||||
2
tests/defaultauthor/alices_post.fmj
Normal file
2
tests/defaultauthor/alices_post.fmj
Normal file
@ -0,0 +1,2 @@
|
||||
title = alice's post
|
||||
description = this post implicitly has alice as the author due to the .fmjconf
|
||||
3
tests/defaultauthor/bobs_post.fmj
Normal file
3
tests/defaultauthor/bobs_post.fmj
Normal file
@ -0,0 +1,3 @@
|
||||
title = bob's post
|
||||
author = bob
|
||||
description = bob wrote this post.
|
||||
2
tests/example/.fmjconf
Normal file
2
tests/example/.fmjconf
Normal file
@ -0,0 +1,2 @@
|
||||
title = example rss feed !!!!!!!!!
|
||||
description = this is an example generated by fmj as a test case
|
||||
3
tests/example/test1.fmj
Normal file
3
tests/example/test1.fmj
Normal file
@ -0,0 +1,3 @@
|
||||
title = TEST TITLE !!!!!!
|
||||
|
||||
this is a comment
|
||||
1
tests/noconf/test1.fmj
Normal file
1
tests/noconf/test1.fmj
Normal file
@ -0,0 +1 @@
|
||||
title = this is a test where the config file doesnt exist
|
||||
Loading…
x
Reference in New Issue
Block a user