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