Add core engine: tarot + astrology reading generator with CLI and i18n

Plain-C, dependency-free engine that produces a personalized daily
Celtic Cross tarot spread and natal-chart/transit astrology reading,
plus a standalone CLI (dist/deck-engine) and run.sh wrapper. Includes
English/German output via a simple key=value translation format, and
docs/diagrams describing the architecture and I/O.
This commit is contained in:
ml
2026-07-04 06:49:18 +02:00
commit e4ea31270f
59 changed files with 17134 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
#define _DEFAULT_SOURCE /* for timegm */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "reading.h"
#include "i18n.h"
typedef enum { FORMAT_TEXT, FORMAT_HTML } OutputFormat;
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html]\n"
" [--lang <code>] [--i18n-dir <path>]\n\n"
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
" --birth-date/time Birth date/time in local clock time.\n"
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
" --format Output format, defaults to text. html links card art via\n"
" img/<file>, i.e. it expects to be saved next to the img/\n"
" directory that `make images` copies into dist/.\n"
" --lang Reading language code, defaults to en. Matches a file\n"
" named <code>.lang in --i18n-dir (see engine/i18n/).\n"
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
" i18n/ directory next to this binary (`make i18n`\n"
" copies engine/i18n/ there as dist/i18n/).\n",
prog);
}
/* Default --i18n-dir: <directory this binary was invoked from>/i18n, so
* `dist/deck-engine ...` finds dist/i18n/ regardless of the caller's cwd.
* If argv[0] has no '/' (looked up via $PATH), falls back to a relative
* "i18n" - callers in that situation should pass --i18n-dir explicitly. */
static void default_i18n_path(const char *argv0, const char *lang, char *out, size_t out_size) {
const char *slash = strrchr(argv0, '/');
if (slash) {
int dir_len = (int)(slash - argv0);
snprintf(out, out_size, "%.*s/i18n/%s.lang", dir_len, argv0, lang);
} else {
snprintf(out, out_size, "i18n/%s.lang", lang);
}
}
static int require_arg(int argc, char **argv, int *i, const char *name, const char **out) {
if (*i + 1 >= argc) {
fprintf(stderr, "Missing value for %s\n", name);
return -1;
}
*out = argv[++*i];
return 0;
}
int main(int argc, char **argv) {
const char *seed = NULL;
const char *birth_date = NULL, *birth_time = NULL;
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
const char *date_str = NULL, *format_str = "text";
const char *lang = "en", *i18n_dir_arg = NULL;
for (int i = 1; i < argc; i++) {
const char *arg = argv[i];
int rc = 0;
if (strcmp(arg, "--seed") == 0) rc = require_arg(argc, argv, &i, arg, &seed);
else if (strcmp(arg, "--birth-date") == 0) rc = require_arg(argc, argv, &i, arg, &birth_date);
else if (strcmp(arg, "--birth-time") == 0) rc = require_arg(argc, argv, &i, arg, &birth_time);
else if (strcmp(arg, "--birth-utc-offset") == 0) rc = require_arg(argc, argv, &i, arg, &birth_utc_offset);
else if (strcmp(arg, "--birth-lat") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lat);
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
print_usage(argv[0]);
return 0;
} else {
fprintf(stderr, "Unknown argument: %s\n", arg);
print_usage(argv[0]);
return 1;
}
if (rc != 0) {
print_usage(argv[0]);
return 1;
}
}
if (!seed || !birth_date || !birth_time || !birth_utc_offset || !birth_lat || !birth_lon) {
fprintf(stderr, "Missing required argument(s).\n\n");
print_usage(argv[0]);
return 1;
}
BirthData birth = {0};
if (sscanf(birth_date, "%d-%d-%d", &birth.year, &birth.month, &birth.day) != 3) {
fprintf(stderr, "Invalid --birth-date, expected YYYY-MM-DD\n");
return 1;
}
if (sscanf(birth_time, "%d:%d", &birth.hour, &birth.minute) != 2) {
fprintf(stderr, "Invalid --birth-time, expected HH:MM\n");
return 1;
}
birth.utc_offset_hours = atof(birth_utc_offset);
birth.latitude = atof(birth_lat);
birth.longitude = atof(birth_lon);
time_t utc_moment;
if (date_str) {
struct tm tm_date = {0};
int second = 0;
if (sscanf(date_str, "%d-%d-%dT%d:%d:%d", &tm_date.tm_year, &tm_date.tm_mon,
&tm_date.tm_mday, &tm_date.tm_hour, &tm_date.tm_min, &second) < 5) {
fprintf(stderr, "Invalid --date, expected YYYY-MM-DDTHH:MM[:SS]\n");
return 1;
}
tm_date.tm_year -= 1900;
tm_date.tm_mon -= 1;
tm_date.tm_sec = second;
utc_moment = timegm(&tm_date);
} else {
utc_moment = time(NULL);
}
OutputFormat format;
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
else {
fprintf(stderr, "Invalid --format, expected text or html\n");
return 1;
}
char i18n_path[512];
if (i18n_dir_arg) {
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
} else {
default_i18n_path(argv[0], lang, i18n_path, sizeof i18n_path);
}
if (!i18n_load(i18n_path) && strcmp(lang, "en") != 0) {
fprintf(stderr, "warning: could not load translations for '%s' from %s - "
"falling back to built-in English\n", lang, i18n_path);
}
DailyReading reading;
reading_generate(seed, &birth, utc_moment, &reading);
if (format == FORMAT_HTML) {
reading_print_html(&reading, stdout);
} else {
reading_print_text(&reading, stdout);
}
return 0;
}