#include #include #include #include #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 ] [--i18n-dir ]\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/, 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 .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 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./sign./aspect., * 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, "%s%s %s %s %s %s" "%s %.1f°, %s %.2f\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, "%s\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, "
\n" "
%s
\n" " \"%s\"\n" "
%s%s
\n" "
%s
\n" "
%s
\n" "
\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, "\n\n" "%s\n" "\n", ui("interp.page_title", "Deck in a Dash - Interpretation")); fprintf(out, "

%s

\n", ui("interp.heading", "Daily Interpretation")); char date_buf[32]; format_date(reading->utc_moment, date_buf, sizeof date_buf); fprintf(out, "

%s %s

\n", ui("interp.reading_date", "Reading for:"), date_buf); fprintf(out, "

%s

\n

%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, "
%s", ui("interp.major_framing", "(a major transit today - worth a deeper Celtic Cross look)")); } fprintf(out, "

\n"); char *guidance = capture_guidance(interp, &reading->spread); fprintf(out, "

%s

\n", ui("interp.heading_guidance", "Guidance")); fprintf(out, "
\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, "
\n"); fprintf(out, "
\n"); if (interp->top_item_count > 0) { fprintf(out, "\n"); print_transit_item_html(out, reading, &interp->top_items[0], 0); fprintf(out, "
\n"); } const char *start = guidance; for (const char *p = guidance; ; p++) { if (*p == '\n' || *p == '\0') { if (p > start) fprintf(out, "

%.*s

\n", (int)(p - start), start); if (*p == '\0') break; start = p + 1; } } free(guidance); fprintf(out, "
\n"); fprintf(out, "

%s

\n", ui("interp.heading_transits", "Significant Transits")); if (interp->top_item_count == 0) { fprintf(out, "

%s

\n", ui("interp.no_notable_transits", "No notable transits today.")); } else { fprintf(out, "\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, "
\n"); } fprintf(out, "

%s

\n
\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, "
\n"); fprintf(out, "\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; }