Files
deck_in_a_dash/interpreter/src/main.c
T
ml 7a77c4a07d
build / build (push) Successful in 19s
Adding gender to the config
2026-07-16 22:49:06 +02:00

564 lines
23 KiB
C

#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../../engine/src/i18n.h"
#include "guidance.h"
#include "narrative.h"
#include "reading_io.h"
#include "significance.h"
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s [--format text|html|json] [--lang <code>] [--i18n-dir <path>]\n"
" [reading.json]\n\n"
"Reads a DailyReading in deck-engine's --format json shape - from the\n"
"given file, or from stdin if no file is given (or it is \"-\") - and\n"
"prints a full report: the day's overall significance level, the\n"
"top aspects driving it (each with a short narrative note), a full\n"
"readout of the Celtic Cross spread with every card's meaning, and\n"
"finally guidance tying the day's top transit to the spread's\n"
"Attitude/Outcome cards.\n\n"
" --format Output format, defaults to text. html links card art via\n"
" img/<file>, same convention as deck-engine's own html\n"
" output - save/run this next to the img/ directory\n"
" `make images` copies into dist/. json embeds the same\n"
" narrative/guidance prose as text (in whatever --lang was\n"
" loaded) alongside stable, language-independent slugs.\n"
" --lang Reading language code, defaults to en. Matches a file\n"
" named <code>.lang in --i18n-dir (see engine/i18n/) -\n"
" note this is this binary's OWN output language, not\n"
" related to whatever --lang deck-engine was run with to\n"
" produce its --format json input (which is always\n"
" language-independent - see docs/input-output-format.md).\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\n"
"Typical use:\n"
" dist/deck-engine ... --format json | %s\n"
" dist/deck-engine ... --format json > reading.json && %s reading.json\n",
prog, prog, prog);
}
/* Same convention as engine/src/main.c's own default_i18n_path() -
* duplicated rather than shared, since the two binaries are never
* linked together (see CLAUDE.md's "Interpretation" section). */
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 char *read_all(FILE *f, size_t *out_length) {
size_t cap = 4096;
size_t len = 0;
char *buf = malloc(cap);
size_t n;
while ((n = fread(buf + len, 1, cap - len, f)) > 0) {
len += n;
if (len == cap) {
cap *= 2;
buf = realloc(buf, cap);
}
}
buf = realloc(buf, len + 1);
buf[len] = '\0';
*out_length = len;
return buf;
}
static const char *ui(const char *key, const char *fallback) { return i18n_get(key, fallback); }
/* Slugs for i18n key building (body.<slug>/sign.<slug>/aspect.<slug>,
* mirroring astro.c's own tables) and reused as-is for --format json's
* language-independent fields - deliberately a small local table rather
* than linking astro.c (would pull in the vendored Astronomy Engine just
* for cosmetic text/slugs). Same policy as reading_io.c's own tables. */
static const char *const k_body_slug[NUM_BODIES] = {
"sun", "moon", "mercury", "venus", "mars",
"jupiter", "saturn", "uranus", "neptune", "pluto",
};
static const char *const k_sign_slug[12] = {
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
};
static const char *const k_aspect_slug[5] = {
"conjunction", "sextile", "square", "trine", "opposition",
};
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
"quiet", "notable", "significant", "major",
};
static const char *body_display_name(Body body) {
static const char *const fallback[NUM_BODIES] = {
"Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
};
char key[32];
snprintf(key, sizeof key, "body.%s", k_body_slug[body]);
return ui(key, fallback[body]);
}
static const char *aspect_display_name(AspectType type) {
static const char *const fallback[5] = {
"Conjunction", "Sextile", "Square", "Trine", "Opposition",
};
char key[32];
snprintf(key, sizeof key, "aspect.%s", k_aspect_slug[type]);
return ui(key, fallback[type]);
}
static const char *sign_display_name(ZodiacSign sign) {
static const char *const fallback[12] = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
};
char key[32];
snprintf(key, sizeof key, "sign.%s", k_sign_slug[sign]);
return ui(key, fallback[sign]);
}
/* "YYYY-MM-DD" for reading->utc_moment - the day this reading is for
* (see reading_load_json()/parse_date() in reading_io.c), not today's
* real date if the two ever differ (e.g. reading.json was generated
* earlier and interpreted later). */
static void format_date(time_t utc_moment, char *out, size_t out_size) {
struct tm *utc = gmtime(&utc_moment);
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
}
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return ui("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return ui("interp.day_level.significant", "Significant");
case DAY_MAJOR: return ui("interp.day_level.major", "Major");
default: return "Unknown";
}
}
static const char *reversed_marker(bool reversed) {
static char buf[32];
if (!reversed) return "";
snprintf(buf, sizeof buf, " %s", ui("ui.reversed", "(Reversed)"));
return buf;
}
/* pos->house is 0 when reading_load_json had no sign/house data for this
* body (see parse_bodies() in reading_io.c) - a valid whole-sign house
* is always 1-12, so this is a safe "unknown, say nothing" sentinel. */
static void print_body_in_sign_text(FILE *out, const PlanetPosition *pos) {
if (pos->house < 1 || pos->house > 12) return;
char buf[64];
snprintf(buf, sizeof buf, ui("interp.in_sign_house", "in %s (house %d)"),
sign_display_name(pos->sign), pos->house);
fprintf(out, " %s", buf);
}
/* Captures narrative_write()/guidance_write()'s output into a heap
* string, for embedding into JSON - a stack buffer plus a single
* malloc()+memcpy() copy, since main.c doesn't need to stream this
* anywhere. Strips a single trailing newline; caller must free() the
* result. */
static char *capture_narrative(const Aspect *aspect, int house) {
char stack_buf[NARRATIVE_TEXT_MAX];
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
char stack_buf[GUIDANCE_TEXT_MAX];
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
/* Prints one significant-transit item's full detail (title line with
* sign/house/orb/score, plus its narrative sentence) - shared between
* the "Significant Transits" list and the day's top item, which is
* repeated in full just above the guidance paragraph (see
* interp_print_text) so the reader has that same detail in view right
* next to the guidance text it informs. `index` is 1-based and prefixes
* the line with "N. "; pass 0 to omit it, for the repeated top item,
* which isn't part of the numbered list. */
static void print_transit_item_text(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
if (index > 0) fprintf(out, " %d. ", index);
fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
char narrative_buf[NARRATIVE_TEXT_MAX];
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
reading->transits.bodies[a->transiting_planet].house);
fputs(narrative_buf, out);
}
static void print_top_transits_header(FILE *out, int count) {
if (count == 1) {
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
} else {
char buf[64];
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count);
fprintf(out, "%s\n", buf);
}
}
/* ===== --format text ===== */
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread, Gender gender) {
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &spread->positions[i];
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i, gender),
tarot_card_name(draw->card), reversed_marker(draw->reversed));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender));
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
}
}
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
fprintf(out, "%s\n", ui("interp.major_framing",
"(a major transit today - worth a deeper Celtic Cross look)"));
}
fprintf(out, "\n");
fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
if (interp->top_item_count > 0) {
print_transit_item_text(out, reading, &interp->top_items[0], 0);
}
char guidance_buf[GUIDANCE_TEXT_MAX];
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
fputs(guidance_buf, out);
fprintf(out, "\n");
if (interp->top_item_count == 0) {
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
print_top_transits_header(out, interp->top_item_count);
for (int i = 0; i < interp->top_item_count; i++) {
print_transit_item_text(out, reading, &interp->top_items[i], i + 1);
}
}
fprintf(out, "\n");
print_celtic_cross_text(out, &reading->spread, reading->gender);
}
/* ===== --format html ===== */
/* HTML counterpart of print_transit_item_text() - same shared-between-
* the-repeated-top-item-and-the-full-list purpose, one table row (title
* + orb/score) plus a narrative row if non-empty. `index` is 1-based;
* pass 0 to omit the "N." cell, for the repeated top item. */
static void print_transit_item_html(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
char index_buf[16] = "";
if (index > 0) snprintf(index_buf, sizeof index_buf, "%d.", index);
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
fprintf(out, "<tr><td>%s</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
index_buf, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"), body_display_name(a->natal_planet),
ui("ui.orb", "orb"), a->orb, ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
/* One Celtic Cross card as a ".card" div - shared between the
* Attitude/Outcome preview (right after guidance) and the full spread
* further down, so both render identically. */
static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw,
Gender gender) {
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name(position, gender), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description(position, gender),
tarot_card_meaning(draw->card, draw->reversed));
}
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out,
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
"<title>%s</title>\n"
"<style>\n"
"body { font-family: sans-serif; max-width: 900px; margin: 2em auto; }\n"
"table { border-collapse: collapse; margin-bottom: 1.5em; }\n"
"td { padding: 2px 10px 2px 0; }\n"
"h1, h2 { border-bottom: 1px solid #ccc; }\n"
".spread { display: flex; flex-wrap: wrap; gap: 1.5em; }\n"
".card { width: 160px; }\n"
".card img { width: 140px; display: block; }\n"
".card img.reversed { transform: rotate(180deg); }\n"
".card .position { font-weight: bold; }\n"
".card .name { font-style: italic; }\n"
".guidance { background: #f6f6f6; padding: 1em; border-radius: 6px; }\n"
"</style></head><body>\n",
ui("interp.page_title", "Deck in a Dash - Interpretation"));
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "<p class=\"reading-date\">%s %s</p>\n",
ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
ui("interp.heading_day_significance", "Day Significance"),
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
fprintf(out, "<br>%s", ui("interp.major_framing",
"(a major transit today - worth a deeper Celtic Cross look)"));
}
fprintf(out, "</p>\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "<div class=\"spread attitude-outcome\">\n");
print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE], reading->gender);
print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME], reading->gender);
fprintf(out, "</div>\n");
fprintf(out, "<div class=\"guidance\">\n");
if (interp->top_item_count > 0) {
fprintf(out, "<table>\n");
print_transit_item_html(out, reading, &interp->top_items[0], 0);
fprintf(out, "</table>\n");
}
const char *start = guidance;
for (const char *p = guidance; ; p++) {
if (*p == '\n' || *p == '\0') {
if (p > start) fprintf(out, "<p>%.*s</p>\n", (int)(p - start), start);
if (*p == '\0') break;
start = p + 1;
}
}
free(guidance);
fprintf(out, "</div>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
print_transit_item_html(out, reading, &interp->top_items[i], i + 1);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i], reading->gender);
}
fprintf(out, "</div>\n");
fprintf(out, "</body></html>\n");
}
/* ===== --format json ===== */
static void json_string(FILE *out, const char *s) {
fputc('"', out);
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
switch (c) {
case '"': fputs("\\\"", out); break;
case '\\': fputs("\\\\", out); break;
case '\n': fputs("\\n", out); break;
case '\r': fputs("\\r", out); break;
case '\t': fputs("\\t", out); break;
default:
if (c < 0x20) fprintf(out, "\\u%04x", c);
else fputc((char)c, out);
}
}
fputc('"', out);
}
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "{\n");
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, " \"date\": \"%s\",\n", date_buf);
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
fprintf(out, " \"count\": %d,\n", DAY_SIGNIFICANCE_COUNT);
fprintf(out, " \"deserves_framing\": %s\n", interpretation_deserves_framing(interp) ? "true" : "false");
fprintf(out, " },\n");
fprintf(out, " \"significant_transits\": [\n");
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
const PlanetPosition *tp = &reading->transits.bodies[a->transiting_planet];
const PlanetPosition *np = &reading->natal.bodies[a->natal_planet];
char *text = capture_narrative(a, tp->house);
fprintf(out, " {\n");
fprintf(out, " \"transiting_planet\": \"%s\",\n", k_body_slug[a->transiting_planet]);
fprintf(out, " \"transiting_sign\": \"%s\",\n", k_sign_slug[tp->sign]);
fprintf(out, " \"transiting_house\": %d,\n", tp->house);
fprintf(out, " \"natal_planet\": \"%s\",\n", k_body_slug[a->natal_planet]);
fprintf(out, " \"natal_sign\": \"%s\",\n", k_sign_slug[np->sign]);
fprintf(out, " \"natal_house\": %d,\n", np->house);
fprintf(out, " \"aspect\": \"%s\",\n", k_aspect_slug[a->type]);
fprintf(out, " \"orb\": %.4f,\n", a->orb);
fprintf(out, " \"score\": %.4f,\n", item->score);
fprintf(out, " \"narrative\": ");
json_string(out, text);
fprintf(out, "\n }%s\n", i + 1 < interp->top_item_count ? "," : "");
free(text);
}
fprintf(out, " ],\n");
fprintf(out, " \"celtic_cross\": [\n");
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
fprintf(out, " {\"position\": \"%s\", \"card\": \"%s\", \"reversed\": %s, \"card_name\": ",
tarot_position_slug((CelticCrossPosition)i), tarot_card_slug(draw->card),
draw->reversed ? "true" : "false");
json_string(out, tarot_card_name(draw->card));
fprintf(out, ", \"meaning\": ");
json_string(out, tarot_card_meaning(draw->card, draw->reversed));
fprintf(out, "}%s\n", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
}
fprintf(out, " ],\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, " \"guidance\": ");
json_string(out, guidance);
fprintf(out, "\n");
free(guidance);
fprintf(out, "}\n");
}
int main(int argc, char **argv) {
const char *format_str = "text";
const char *lang = "en", *i18n_dir_arg = NULL;
const char *input_path = NULL;
for (int i = 1; i < argc; i++) {
const char *arg = argv[i];
if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
print_usage(argv[0]);
return 0;
} else if (strcmp(arg, "--format") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
format_str = argv[i];
} else if (strcmp(arg, "--lang") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
lang = argv[i];
} else if (strcmp(arg, "--i18n-dir") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
i18n_dir_arg = argv[i];
} else if (arg[0] == '-' && strcmp(arg, "-") != 0) {
fprintf(stderr, "Unknown argument: %s\n", arg);
print_usage(argv[0]);
return 1;
} else if (input_path == NULL) {
input_path = arg;
} else {
print_usage(argv[0]);
return 1;
}
}
OutputFormat format;
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
else if (strcmp(format_str, "json") == 0) format = FORMAT_JSON;
else {
fprintf(stderr, "Invalid --format, expected text, html, or json\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);
}
FILE *in = stdin;
bool opened_file = false;
if (input_path && strcmp(input_path, "-") != 0) {
in = fopen(input_path, "r");
if (!in) {
fprintf(stderr, "error: could not open %s\n", input_path);
return 1;
}
opened_file = true;
}
size_t length;
char *text = read_all(in, &length);
if (opened_file) fclose(in);
DailyReading reading;
bool loaded = reading_load_json(text, length, &reading);
free(text);
if (!loaded) {
fprintf(stderr, "error: could not parse input as deck-engine --format json output\n");
return 1;
}
DailyInterpretation interp;
interpret_daily_reading(&reading, &interp);
if (format == FORMAT_HTML) {
interp_print_html(stdout, &reading, &interp);
} else if (format == FORMAT_JSON) {
interp_print_json(stdout, &reading, &interp);
} else {
interp_print_text(stdout, &reading, &interp);
}
return 0;
}