From 7a77c4a07d7a0ac4b42f97d5b27d426cadb50942 Mon Sep 17 00:00:00 2001 From: ml Date: Thu, 16 Jul 2026 22:49:06 +0200 Subject: [PATCH] Adding gender to the config --- CLAUDE.md | 34 +++++- docs/input-output-format.md | 38 ++++-- engine/i18n/de.lang | 52 +++++++-- engine/i18n/en.lang | 51 +++++++-- engine/scripts/run-engine.sh | 3 + engine/scripts/run-interpreter.sh | 3 + engine/scripts/user.properties.template | 7 ++ engine/src/main.c | 17 ++- engine/src/reading.c | 12 +- engine/src/reading.h | 6 +- engine/src/tarot.h | 16 ++- engine/src/tarot_data.c | 146 ++++++++++++++++++------ engine/tests/smoke_test.c | 42 ++++++- interpreter/src/main.c | 21 ++-- interpreter/src/reading_io.c | 29 +++++ interpreter/src/reading_io.h | 9 +- interpreter/tests/json_test.c | 36 ++++++ watch/package.json | 1 + watch/src/c/app_message.c | 1 + watch/src/c/config.c | 8 +- watch/src/c/config.h | 4 + watch/src/c/main.c | 4 +- watch/src/c/reading_state.c | 4 +- watch/src/c/reading_state.h | 6 +- watch/src/c/ui_report_window.c | 4 +- watch/src/pkjs/index.js | 25 ++++ 26 files changed, 470 insertions(+), 109 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85bd025..eb20835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -200,6 +200,23 @@ more common (but less original) ordering; don't use it as a reference for the position enum order. Card and position text in `tarot_data.c` is condensed from Waite's own wording in Part III §3 (public domain). +**Gender** (`Gender` in `tarot.h`: `GENDER_UNSPECIFIED`/`GENDER_MALE`/ +`GENDER_FEMALE`) is who the reading is for — it only affects the +pronouns in the 9 Celtic Cross position names/descriptions that have one +(Waite addresses the querent as "him" throughout; every other position +text in this codebase — card meanings, narrative, guidance — is already +pronoun-neutral or direct "you" address, so nothing else varies with +it). `DailyReading.gender` is set by `reading_generate()` the same way +`utc_moment` is, `deck-engine`'s `--gender male|female|unspecified` +flag/`dist/user.properties`' `gender=` field/the watch config page's +Gender field all feed it, and `reading_print_json`/`reading_load_json` +carry it across the `deck-engine` → `interpreter-cli` JSON boundary +(`tarot_gender_slug()`) the same way `date` does. `GENDER_UNSPECIFIED` +is the default and renders pronoun-neutral phrasing (singular "they" in +English; German rephrases around a noun like "die fragende Person" +rather than a pronoun, since German has no equivalent singular-they +construction in common use). + ### Translations (`i18n.c`, `engine/i18n/*.lang`) All display text (planet/sign/moon-phase/aspect names, tarot card names @@ -217,7 +234,12 @@ Keys are namespaced by the *slug* tables in `tarot_data.c`/`astro.c` `moonphase.`, `aspect.`), plus `ui.*` keys for `reading.c`'s own labels — deliberately keyed off separate slug strings rather than the C enum names, so renaming an enumerator can never silently break a -`.lang` file. **To add a language**: copy `engine/i18n/en.lang` to +`.lang` file. `position..name/desc` additionally take an optional +`.male`/`.female` suffix (e.g. `position.attitude.name.male`) for the +`Gender`-varying wording (see "Tarot" above); the unsuffixed key is +looked up first as the fallback for a missing gendered key, so a +position whose wording doesn't vary by gender only needs the one +unsuffixed key. **To add a language**: copy `engine/i18n/en.lang` to `.lang` in the same directory (keys must match exactly) and translate the values — no source changes needed, `make` picks up any `*.lang` file there automatically. @@ -444,10 +466,16 @@ built once and linked into *both* binaries. `VENDORED.md` for the pinned upstream commit. - Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public - domain. + domain. The `GENDER_MALE` variant of the Celtic Cross position text + keeps Waite's own "him"/"his" wording verbatim; the + `GENDER_UNSPECIFIED`/`GENDER_FEMALE` variants are original paraphrases + written for this project, not Waite's own text (see "Tarot" above). - `engine/i18n/de.lang`'s card/spread text is an original translation of that same public-domain English text, made for this project — not - taken from any specific published German edition. + taken from any specific published German edition. Its `Gender`-varying + position text follows the same policy as the English original: `.male` + is a direct translation of Waite's own wording, `.female`/unsuffixed + are original paraphrases for this project. - Transit narrative text in `interpreter/src/narrative.c`: condensed from Sepharial's *Transits and Planetary Periods* (1920, public domain — `res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto diff --git a/docs/input-output-format.md b/docs/input-output-format.md index 4cd449a..b2450cb 100644 --- a/docs/input-output-format.md +++ b/docs/input-output-format.md @@ -17,6 +17,7 @@ same `--lang`) for its own report, independently of `deck-engine`. deck-engine --seed --birth-date YYYY-MM-DD --birth-time HH:MM --birth-utc-offset --birth-lat --birth-lon [--date YYYY-MM-DDTHH:MM] [--format text|html|json] + [--gender male|female|unspecified] ``` | Flag | Required | Format | Meaning | @@ -29,6 +30,7 @@ deck-engine --seed --birth-date YYYY-MM-DD --birth-time HH:MM | `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. | | `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. | | `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. | +| `--gender` | no | `male` \| `female` \| `unspecified` | Who the reading is for. Only affects the Celtic Cross position text's pronouns (e.g. "His House" vs. "Her House" vs. "Their House") - never the chart/draw itself. Defaults to `unspecified` (pronoun-neutral phrasing). | | `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. | | `--i18n-dir` | no | path | Directory to look for `.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). | @@ -52,6 +54,7 @@ use: birth_utc_offset=2 birth_lat=52.5200 birth_lon=13.4050 + gender=unspecified lang=en ``` @@ -59,8 +62,8 @@ use: with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh` checks every birth field for an empty value or a leftover `YOUR_` placeholder and refuses to run with a clear error until the file is - filled in; `lang` has no such check since it always has a usable - default (`en`). Since `dist/` itself can be wiped (`make clean`, or + filled in; `gender` and `lang` have no such check since they always + have a usable default (`unspecified`/`en`). Since `dist/` itself can be wiped (`make clean`, or deleting the directory), the build also keeps a durable backup at `engine/scripts/user.properties.local` (gitignored) once the file looks filled in, and restores from it if `dist/user.properties` is @@ -81,6 +84,14 @@ catalog with the built-in English text as the fallback for any key the catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the key-naming convention and how to add a new language. +Celtic Cross position text additionally varies with `--gender`: the +plain key (e.g. `position.present.desc`) is the pronoun-neutral wording +used for `unspecified`, and `.male`/`.female`-suffixed keys (e.g. +`position.present.desc.male`) hold the gendered wording where it +differs. A position whose wording doesn't vary by gender (e.g. "The +Outcome") simply has no `.male`/`.female` keys - the neutral key's +translation is used for every `--gender` value in that case. + `engine/i18n/en.lang` and `de.lang` are the shipped translation files; `make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every build, like `dist/img/`). Adding a language is just dropping another @@ -146,7 +157,7 @@ Transiting Sun Opposition natal Moon (orb 3.7°) ===== Celtic Cross ===== The Present The High Priestess (Reversed) - This covers him: the general influence affecting the matter. + This covers them: the general influence affecting the matter. Passion, moral or physical ardour, conceit, surface knowledge. ... ``` @@ -171,8 +182,8 @@ cards rendered as an image: ### `--format json` -A single JSON object mirroring `DailyReading` exactly — `date`, `natal`, -`transits`, `spread` — using the **slug** accessors +A single JSON object mirroring `DailyReading` exactly — `date`, `gender`, +`natal`, `transits`, `spread` — using the **slug** accessors (`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`, `astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`) rather than the display-name ones, so the output is identical regardless @@ -183,6 +194,7 @@ read. ```json { "date": "2026-07-16", + "gender": "unspecified", "natal": { "bodies": [ {"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3}, @@ -218,6 +230,10 @@ read. serialized here since that's the only thing the rest of this format (and the interpreter's own reports, which read this field back — see "The interpreter" below) ever display. +- `gender` is `--gender`'s value (`male`/`female`/`unspecified`, + `tarot_gender_slug()`) — who the reading is for. Only affects the + Celtic Cross position text's pronouns, nothing else in this document. + The interpreter reads this field back the same way it reads `date`. - `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in `CelticCrossPosition` enum order (Present, Challenge, Crown, @@ -333,14 +349,14 @@ Top 5 significant transits: Celtic Cross: 1. The Present: The Emperor (Reversed) - This covers him: the general influence affecting the matter. + This covers them: the general influence affecting the matter. Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity. 2. The Challenge: The Devil - This crosses him: the nature of the obstacle in the matter. + This crosses them: the nature of the obstacle in the matter. Ravage, violence, vehemence, extraordinary efforts, force, fatality. ... - 7. Himself: The Magician - His position or attitude in the circumstances. + 7. Themself: The Magician + Their position or attitude in the circumstances. Skill, diplomacy, address, subtlety; self-confidence, will. ... 10. The Outcome: Strength @@ -381,7 +397,7 @@ Top 5 wichtige Transits: Keltisches Kreuz: 1. Die Gegenwart: Der Herrscher (Umgekehrt) - Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft. + Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft. Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife. ... @@ -407,7 +423,7 @@ HTML output:
The Present
The Emperor
The Emperor (Reversed)
-
This covers him: the general influence affecting the matter.
+
This covers them: the general influence affecting the matter.
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
``` diff --git a/engine/i18n/de.lang b/engine/i18n/de.lang index 3eaafc4..56c8fd4 100644 --- a/engine/i18n/de.lang +++ b/engine/i18n/de.lang @@ -71,25 +71,53 @@ aspect.square=Quadrat aspect.trine=Trigon aspect.opposition=Opposition -# --- Positionen des Keltischen Kreuzes (tarot_data.c) --- +# --- Positionen des Keltischen Kreuzes (tarot_data.c). Waite spricht die +# fragende Person durchgehend mit "ihn/ihm/sein" an; der unsuffigierte +# Schlüssel ist eine geschlechtsneutrale Umschreibung (über "die fragende +# Person"/"die eigene", ohne Pronomen), die bei nicht angegebenem +# Geschlecht verwendet wird, ".male" ist Waites eigener Wortlaut +# unverändert, ".female" eine für dieses Projekt geschriebene Umschreibung - +# siehe tarot_data.c's Kommentar zu Gender. --- position.present.name=Die Gegenwart -position.present.desc=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft. +position.present.desc=Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft. +position.present.desc.male=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft. +position.present.desc.female=Dies bedeckt sie: der allgemeine Einfluss, der die Angelegenheit betrifft. position.challenge.name=Die Herausforderung -position.challenge.desc=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit. +position.challenge.desc=Dies kreuzt die fragende Person: die Art des Hindernisses in der Angelegenheit. +position.challenge.desc.male=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit. +position.challenge.desc.female=Dies kreuzt sie: die Art des Hindernisses in der Angelegenheit. position.crown.name=Die Krone -position.crown.desc=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann. +position.crown.desc=Dies krönt die fragende Person: das Ziel oder Ideal, das Beste, was erreicht werden kann. +position.crown.desc.male=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann. +position.crown.desc.female=Dies krönt sie: das Ziel oder Ideal, das Beste, was erreicht werden kann. position.foundation.name=Das Fundament -position.foundation.desc=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit. +position.foundation.desc=Dies liegt unter der fragenden Person: die Grundlage der Angelegenheit, bereits Wirklichkeit. +position.foundation.desc.male=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit. +position.foundation.desc.female=Dies liegt unter ihr: die Grundlage der Angelegenheit, bereits Wirklichkeit. position.recent_past.name=Die jüngste Vergangenheit -position.recent_past.desc=Dies liegt hinter ihm: der Einfluss, der gerade vergeht. +position.recent_past.desc=Dies liegt hinter der fragenden Person: der Einfluss, der gerade vergeht. +position.recent_past.desc.male=Dies liegt hinter ihm: der Einfluss, der gerade vergeht. +position.recent_past.desc.female=Dies liegt hinter ihr: der Einfluss, der gerade vergeht. position.near_future.name=Die nahe Zukunft -position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird. -position.attitude.name=Er selbst -position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen. -position.environment.name=Sein Haus -position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen. +position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird. +position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird. +position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird. +position.attitude.name=Die fragende Person selbst +position.attitude.name.male=Er selbst +position.attitude.name.female=Sie selbst +position.attitude.desc=Die eigene Haltung oder Einstellung in den gegebenen Umständen. +position.attitude.desc.male=Seine Haltung oder Einstellung in den gegebenen Umständen. +position.attitude.desc.female=Ihre Haltung oder Einstellung in den gegebenen Umständen. +position.environment.name=Das eigene Haus +position.environment.name.male=Sein Haus +position.environment.name.female=Ihr Haus +position.environment.desc=Das eigene Umfeld und die darin wirkenden Tendenzen. +position.environment.desc.male=Sein Umfeld und die darin wirkenden Tendenzen. +position.environment.desc.female=Ihr Umfeld und die darin wirkenden Tendenzen. position.hopes_and_fears.name=Hoffnungen und Ängste -position.hopes_and_fears.desc=Seine Hoffnungen oder Ängste in der Angelegenheit. +position.hopes_and_fears.desc=Die eigenen Hoffnungen oder Ängste in der Angelegenheit. +position.hopes_and_fears.desc.male=Seine Hoffnungen oder Ängste in der Angelegenheit. +position.hopes_and_fears.desc.female=Ihre Hoffnungen oder Ängste in der Angelegenheit. position.outcome.name=Das Ergebnis position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit. diff --git a/engine/i18n/en.lang b/engine/i18n/en.lang index c05d82e..b3cea26 100644 --- a/engine/i18n/en.lang +++ b/engine/i18n/en.lang @@ -79,25 +79,52 @@ aspect.trine=Trine aspect.opposition=Opposition # --- Celtic Cross position names/descriptions (tarot_data.c), Waite's -# own wording from "An Ancient Celtic Method of Divination" --- +# own wording from "An Ancient Celtic Method of Divination". Waite +# addresses the querent as "him" throughout; the unsuffixed key is a +# pronoun-neutral paraphrase (singular "they") used when no gender is +# configured, the ".male" key is Waite's own wording verbatim, and the +# ".female" key is an original paraphrase - see tarot_data.c's own +# comment on Gender. --- position.present.name=The Present -position.present.desc=This covers him: the general influence affecting the matter. +position.present.desc=This covers them: the general influence affecting the matter. +position.present.desc.male=This covers him: the general influence affecting the matter. +position.present.desc.female=This covers her: the general influence affecting the matter. position.challenge.name=The Challenge -position.challenge.desc=This crosses him: the nature of the obstacle in the matter. +position.challenge.desc=This crosses them: the nature of the obstacle in the matter. +position.challenge.desc.male=This crosses him: the nature of the obstacle in the matter. +position.challenge.desc.female=This crosses her: the nature of the obstacle in the matter. position.crown.name=The Crown -position.crown.desc=This crowns him: the aim or ideal, the best that can be achieved. +position.crown.desc=This crowns them: the aim or ideal, the best that can be achieved. +position.crown.desc.male=This crowns him: the aim or ideal, the best that can be achieved. +position.crown.desc.female=This crowns her: the aim or ideal, the best that can be achieved. position.foundation.name=The Foundation -position.foundation.desc=This is beneath him: the basis of the matter, already actual. +position.foundation.desc=This is beneath them: the basis of the matter, already actual. +position.foundation.desc.male=This is beneath him: the basis of the matter, already actual. +position.foundation.desc.female=This is beneath her: the basis of the matter, already actual. position.recent_past.name=The Recent Past -position.recent_past.desc=This is behind him: the influence that is just passing away. +position.recent_past.desc=This is behind them: the influence that is just passing away. +position.recent_past.desc.male=This is behind him: the influence that is just passing away. +position.recent_past.desc.female=This is behind her: the influence that is just passing away. position.near_future.name=The Near Future -position.near_future.desc=This is before him: the influence now coming into action. -position.attitude.name=Himself -position.attitude.desc=His position or attitude in the circumstances. -position.environment.name=His House -position.environment.desc=His environment and the tendencies at work therein. +position.near_future.desc=This is before them: the influence now coming into action. +position.near_future.desc.male=This is before him: the influence now coming into action. +position.near_future.desc.female=This is before her: the influence now coming into action. +position.attitude.name=Themself +position.attitude.name.male=Himself +position.attitude.name.female=Herself +position.attitude.desc=Their position or attitude in the circumstances. +position.attitude.desc.male=His position or attitude in the circumstances. +position.attitude.desc.female=Her position or attitude in the circumstances. +position.environment.name=Their House +position.environment.name.male=His House +position.environment.name.female=Her House +position.environment.desc=Their environment and the tendencies at work therein. +position.environment.desc.male=His environment and the tendencies at work therein. +position.environment.desc.female=Her environment and the tendencies at work therein. position.hopes_and_fears.name=Hopes and Fears -position.hopes_and_fears.desc=His hopes or fears in the matter. +position.hopes_and_fears.desc=Their hopes or fears in the matter. +position.hopes_and_fears.desc.male=His hopes or fears in the matter. +position.hopes_and_fears.desc.female=Her hopes or fears in the matter. position.outcome.name=The Outcome position.outcome.desc=What will come: the final result of the matter. diff --git a/engine/scripts/run-engine.sh b/engine/scripts/run-engine.sh index ab331e2..a5bce62 100644 --- a/engine/scripts/run-engine.sh +++ b/engine/scripts/run-engine.sh @@ -29,6 +29,7 @@ birth_time="" birth_utc_offset="" birth_lat="" birth_lon="" +gender="unspecified" lang="en" while IFS='=' read -r key value || [ -n "$key" ]; do @@ -43,6 +44,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do birth_utc_offset) birth_utc_offset="$value" ;; birth_lat) birth_lat="$value" ;; birth_lon) birth_lon="$value" ;; + gender) [ -n "$value" ] && gender="$value" ;; lang) [ -n "$value" ] && lang="$value" ;; esac done <"$PROPERTIES_FILE" @@ -77,5 +79,6 @@ exec "$BINARY" \ --birth-lon "$birth_lon" \ --date "$transit_date" \ --format "$format" \ + --gender "$gender" \ --lang "$lang" \ --i18n-dir "$SCRIPT_DIR/i18n" diff --git a/engine/scripts/run-interpreter.sh b/engine/scripts/run-interpreter.sh index fd87b12..5332ed4 100644 --- a/engine/scripts/run-interpreter.sh +++ b/engine/scripts/run-interpreter.sh @@ -41,6 +41,7 @@ birth_time="" birth_utc_offset="" birth_lat="" birth_lon="" +gender="unspecified" lang="en" while IFS='=' read -r key value || [ -n "$key" ]; do @@ -55,6 +56,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do birth_utc_offset) birth_utc_offset="$value" ;; birth_lat) birth_lat="$value" ;; birth_lon) birth_lon="$value" ;; + gender) [ -n "$value" ] && gender="$value" ;; lang) [ -n "$value" ] && lang="$value" ;; esac done <"$PROPERTIES_FILE" @@ -92,4 +94,5 @@ lang="${2:-$lang}" --birth-lon "$birth_lon" \ --date "$transit_date" \ --format json \ + --gender "$gender" \ | exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n" diff --git a/engine/scripts/user.properties.template b/engine/scripts/user.properties.template index 4384567..819f481 100644 --- a/engine/scripts/user.properties.template +++ b/engine/scripts/user.properties.template @@ -11,6 +11,12 @@ # (e.g. 2 for Central European Summer Time) # birth_lat Birth location latitude, decimal degrees, north positive # birth_lon Birth location longitude, decimal degrees, east positive +# gender Who the reading is for - male, female, or unspecified. +# Only affects the Celtic Cross position text's +# pronouns (e.g. "His House" vs. "Her House" vs. +# "Their House"), never the chart/draw itself. +# Optional, defaults to "unspecified"; unrecognized +# values fall back to "unspecified" too. # lang Reading language code - matches a file named # .lang in dist/i18n/ (see engine/i18n/ for the # source files, currently "en" and "de"). Optional, @@ -22,4 +28,5 @@ birth_time=YOUR_BIRTH_TIME_HERE birth_utc_offset=YOUR_UTC_OFFSET_HERE birth_lat=YOUR_LATITUDE_HERE birth_lon=YOUR_LONGITUDE_HERE +gender=unspecified lang=en diff --git a/engine/src/main.c b/engine/src/main.c index dd1884c..94c6db4 100644 --- a/engine/src/main.c +++ b/engine/src/main.c @@ -15,12 +15,16 @@ static void print_usage(const char *prog) { "Usage: %s --seed --birth-date YYYY-MM-DD --birth-time HH:MM\n" " --birth-utc-offset --birth-lat --birth-lon \n" " [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n" + " [--gender male|female|unspecified]\n" " [--lang ] [--i18n-dir ]\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" + " --gender Who the reading is for - only affects the Celtic Cross\n" + " position text's pronouns, never the chart/draw itself.\n" + " Defaults to unspecified (pronoun-neutral phrasing).\n" " --format Output format, defaults to text. html links card art via\n" " img/, i.e. it expects to be saved next to the img/\n" " directory that `make images` copies into dist/. json uses\n" @@ -63,6 +67,7 @@ int main(int argc, char **argv) { 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; + const char *gender_str = "unspecified"; for (int i = 1; i < argc; i++) { const char *arg = argv[i]; @@ -75,6 +80,7 @@ int main(int argc, char **argv) { 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, "--gender") == 0) rc = require_arg(argc, argv, &i, arg, &gender_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) { @@ -136,6 +142,15 @@ int main(int argc, char **argv) { return 1; } + Gender gender; + if (strcmp(gender_str, "unspecified") == 0) gender = GENDER_UNSPECIFIED; + else if (strcmp(gender_str, "male") == 0) gender = GENDER_MALE; + else if (strcmp(gender_str, "female") == 0) gender = GENDER_FEMALE; + else { + fprintf(stderr, "Invalid --gender, expected male, female, or unspecified\n"); + return 1; + } + char i18n_path[512]; if (i18n_dir_arg) { snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang); @@ -148,7 +163,7 @@ int main(int argc, char **argv) { } DailyReading reading; - reading_generate(seed, &birth, utc_moment, &reading); + reading_generate(seed, &birth, gender, utc_moment, &reading); if (format == FORMAT_HTML) { reading_print_html(&reading, stdout); diff --git a/engine/src/reading.c b/engine/src/reading.c index 0df50a1..b50a04e 100644 --- a/engine/src/reading.c +++ b/engine/src/reading.c @@ -3,9 +3,10 @@ #include -void reading_generate(const char *tarot_seed, const BirthData *birth, +void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender, time_t utc_moment, DailyReading *out) { out->utc_moment = utc_moment; + out->gender = gender; astro_compute_natal_chart(birth, &out->natal); astro_compute_daily_transits(utc_moment, &out->natal, &out->transits); tarot_draw_celtic_cross(tarot_seed, &out->spread); @@ -66,10 +67,10 @@ void reading_print_text(const DailyReading *r, FILE *out) { fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross")); for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { const TarotDraw *draw = &r->spread.positions[i]; - fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i), + fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i, r->gender), tarot_card_name(draw->card), draw->reversed ? " " : "", draw->reversed ? ui("ui.reversed", "(Reversed)") : ""); - fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i)); + fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, r->gender)); fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed)); } } @@ -138,12 +139,12 @@ void reading_print_html(const DailyReading *r, FILE *out) { "
%s
\n" "
%s
\n" "\n", - tarot_position_name((CelticCrossPosition)i), + tarot_position_name((CelticCrossPosition)i, r->gender), draw->reversed ? "reversed" : "", tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card), draw->reversed ? " " : "", draw->reversed ? ui("ui.reversed", "(Reversed)") : "", - tarot_position_description((CelticCrossPosition)i), + tarot_position_description((CelticCrossPosition)i, r->gender), tarot_card_meaning(draw->card, draw->reversed)); } fprintf(out, "\n\n"); @@ -163,6 +164,7 @@ void reading_print_json(const DailyReading *r, FILE *out) { struct tm *utc = gmtime(&r->utc_moment); fprintf(out, " \"date\": \"%04d-%02d-%02d\",\n", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday); + fprintf(out, " \"gender\": \"%s\",\n", tarot_gender_slug(r->gender)); fprintf(out, " \"natal\": {\n \"bodies\": [\n"); for (int b = 0; b < NUM_BODIES; b++) { diff --git a/engine/src/reading.h b/engine/src/reading.h index dab604d..1745884 100644 --- a/engine/src/reading.h +++ b/engine/src/reading.h @@ -21,6 +21,10 @@ typedef struct { * interpreter side) gets it for free without threading it through * separately. */ time_t utc_moment; + /* Who the reading is for - only affects the pronouns in the Celtic + * Cross position text (see tarot.h's Gender), set by reading_generate() + * the same way utc_moment is, so every caller gets it for free. */ + Gender gender; NatalChart natal; DailyTransits transits; CelticCrossSpread spread; @@ -28,7 +32,7 @@ typedef struct { /* The real API: this is the only function the watchapp needs to call. * It walks the returned struct directly to lay out its own screens. */ -void reading_generate(const char *tarot_seed, const BirthData *birth, +void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender, time_t utc_moment, DailyReading *out); /* Desktop-only output formats for inspecting a reading before any watch diff --git a/engine/src/tarot.h b/engine/src/tarot.h index 1f897a4..9b54258 100644 --- a/engine/src/tarot.h +++ b/engine/src/tarot.h @@ -52,6 +52,17 @@ typedef struct { bool reversed; } TarotDraw; +/* Who the reading is for - affects only the pronouns in the Celtic + * Cross position text below (Waite's original wording addresses the + * querent as "him"), never anything about the tarot draw or astrology + * math itself. GENDER_UNSPECIFIED is the default and renders + * pronoun-neutral phrasing (singular "they" in English). */ +typedef enum { + GENDER_UNSPECIFIED = 0, + GENDER_MALE, + GENDER_FEMALE +} Gender; + typedef struct { /* Indexed by CelticCrossPosition. */ TarotDraw positions[TAROT_SPREAD_SIZE]; @@ -67,8 +78,8 @@ const char *tarot_card_name(TarotCard card); const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */ const char *tarot_card_meaning(TarotCard card, bool reversed); -const char *tarot_position_name(CelticCrossPosition position); -const char *tarot_position_description(CelticCrossPosition position); +const char *tarot_position_name(CelticCrossPosition position, Gender gender); +const char *tarot_position_description(CelticCrossPosition position, Gender gender); /* Stable, language-independent identifiers (e.g. "high_priestess", * "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup @@ -76,5 +87,6 @@ const char *tarot_position_description(CelticCrossPosition position); * (reading_print_json) that must stay identical regardless of --lang. */ const char *tarot_card_slug(TarotCard card); const char *tarot_position_slug(CelticCrossPosition position); +const char *tarot_gender_slug(Gender gender); /* "unspecified"/"male"/"female" */ #endif diff --git a/engine/src/tarot_data.c b/engine/src/tarot_data.c index 1809be3..b667579 100644 --- a/engine/src/tarot_data.c +++ b/engine/src/tarot_data.c @@ -130,52 +130,97 @@ static const TarotCardInfo k_cards[TAROT_DECK_SIZE] = { /* Position names and one-line descriptions follow Waite's own account * of "An Ancient Celtic Method of Divination" (Part III §7), in his - * original drawing order. */ + * original drawing order. Waite addresses the querent as "him" + * throughout; that wording is kept verbatim as the GENDER_MALE variant + * below, and GENDER_FEMALE/GENDER_UNSPECIFIED are original paraphrases + * written for this project (not Waite's own text) so the reading can + * address the person it's actually for. Each array is indexed by + * Gender (tarot.h): [GENDER_UNSPECIFIED], [GENDER_MALE], [GENDER_FEMALE]. */ typedef struct { - const char *name; - const char *description; + const char *name[3]; + const char *description[3]; } PositionInfo; static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = { [POSITION_PRESENT] = { - "The Present", - "This covers him: the general influence affecting the matter." + { "The Present", "The Present", "The Present" }, + { + "This covers them: the general influence affecting the matter.", + "This covers him: the general influence affecting the matter.", + "This covers her: the general influence affecting the matter.", + } }, [POSITION_CHALLENGE] = { - "The Challenge", - "This crosses him: the nature of the obstacle in the matter." + { "The Challenge", "The Challenge", "The Challenge" }, + { + "This crosses them: the nature of the obstacle in the matter.", + "This crosses him: the nature of the obstacle in the matter.", + "This crosses her: the nature of the obstacle in the matter.", + } }, [POSITION_CROWN] = { - "The Crown", - "This crowns him: the aim or ideal, the best that can be achieved." + { "The Crown", "The Crown", "The Crown" }, + { + "This crowns them: the aim or ideal, the best that can be achieved.", + "This crowns him: the aim or ideal, the best that can be achieved.", + "This crowns her: the aim or ideal, the best that can be achieved.", + } }, [POSITION_FOUNDATION] = { - "The Foundation", - "This is beneath him: the basis of the matter, already actual." + { "The Foundation", "The Foundation", "The Foundation" }, + { + "This is beneath them: the basis of the matter, already actual.", + "This is beneath him: the basis of the matter, already actual.", + "This is beneath her: the basis of the matter, already actual.", + } }, [POSITION_RECENT_PAST] = { - "The Recent Past", - "This is behind him: the influence that is just passing away." + { "The Recent Past", "The Recent Past", "The Recent Past" }, + { + "This is behind them: the influence that is just passing away.", + "This is behind him: the influence that is just passing away.", + "This is behind her: the influence that is just passing away.", + } }, [POSITION_NEAR_FUTURE] = { - "The Near Future", - "This is before him: the influence now coming into action." + { "The Near Future", "The Near Future", "The Near Future" }, + { + "This is before them: the influence now coming into action.", + "This is before him: the influence now coming into action.", + "This is before her: the influence now coming into action.", + } }, [POSITION_ATTITUDE] = { - "Himself", - "His position or attitude in the circumstances." + { "Themself", "Himself", "Herself" }, + { + "Their position or attitude in the circumstances.", + "His position or attitude in the circumstances.", + "Her position or attitude in the circumstances.", + } }, [POSITION_ENVIRONMENT] = { - "His House", - "His environment and the tendencies at work therein." + { "Their House", "His House", "Her House" }, + { + "Their environment and the tendencies at work therein.", + "His environment and the tendencies at work therein.", + "Her environment and the tendencies at work therein.", + } }, [POSITION_HOPES_AND_FEARS] = { - "Hopes and Fears", - "His hopes or fears in the matter." + { "Hopes and Fears", "Hopes and Fears", "Hopes and Fears" }, + { + "Their hopes or fears in the matter.", + "His hopes or fears in the matter.", + "Her hopes or fears in the matter.", + } }, [POSITION_OUTCOME] = { - "The Outcome", - "What will come: the final result of the matter." + { "The Outcome", "The Outcome", "The Outcome" }, + { + "What will come: the final result of the matter.", + "What will come: the final result of the matter.", + "What will come: the final result of the matter.", + } }, }; @@ -229,13 +274,44 @@ static const char *card_field(const char *slug, const char *field, const char *f return i18n_get(key, fallback); } -static const char *position_field(const char *slug, const char *field, const char *fallback) { - char key[64]; - strcpy(key, "position."); - strcat(key, slug); - strcat(key, "."); - strcat(key, field); - return i18n_get(key, fallback); +/* Suffix appended to the i18n key for the male/female variants - the + * unspecified/neutral variant uses the plain, unsuffixed key so it + * matches the field name callers already know (e.g. "position. + * attitude.name"), same convention as card_field()'s upright/reversed + * field names above. */ +static const char *const k_gender_suffix[3] = { + [GENDER_UNSPECIFIED] = "", + [GENDER_MALE] = ".male", + [GENDER_FEMALE] = ".female", +}; + +static const char *const k_gender_slug[3] = { + [GENDER_UNSPECIFIED] = "unspecified", + [GENDER_MALE] = "male", + [GENDER_FEMALE] = "female", +}; + +static const char *position_field(const char *slug, const char *field, Gender gender, + const char *fallback) { + char neutral_key[80]; + strcpy(neutral_key, "position."); + strcat(neutral_key, slug); + strcat(neutral_key, "."); + strcat(neutral_key, field); + if (gender == GENDER_UNSPECIFIED) return i18n_get(neutral_key, fallback); + + /* Not every position's text actually varies by gender (e.g. "The + * Outcome" has no pronoun) - the .lang files only define a ".male"/ + * ".female" key where the wording differs. So look up the neutral key + * first and use *that* (translated, if a catalog is loaded) as the + * fallback for the gendered key, rather than jumping straight past a + * loaded translation to the hardcoded English `fallback` whenever the + * gendered key happens to be absent. */ + const char *neutral_text = i18n_get(neutral_key, fallback); + char key[88]; + strcpy(key, neutral_key); + strcat(key, k_gender_suffix[gender]); + return i18n_get(key, neutral_text); } const char *tarot_card_name(TarotCard card) { @@ -252,13 +328,15 @@ const char *tarot_card_meaning(TarotCard card, bool reversed) { reversed ? k_cards[card].reversed : k_cards[card].upright); } -const char *tarot_position_name(CelticCrossPosition position) { - return position_field(k_position_slug[position], "name", k_positions[position].name); +const char *tarot_position_name(CelticCrossPosition position, Gender gender) { + return position_field(k_position_slug[position], "name", gender, k_positions[position].name[gender]); } -const char *tarot_position_description(CelticCrossPosition position) { - return position_field(k_position_slug[position], "desc", k_positions[position].description); +const char *tarot_position_description(CelticCrossPosition position, Gender gender) { + return position_field(k_position_slug[position], "desc", gender, + k_positions[position].description[gender]); } const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; } const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; } +const char *tarot_gender_slug(Gender gender) { return k_gender_slug[gender]; } diff --git a/engine/tests/smoke_test.c b/engine/tests/smoke_test.c index fcaf5ee..660e14d 100644 --- a/engine/tests/smoke_test.c +++ b/engine/tests/smoke_test.c @@ -72,8 +72,8 @@ static void test_reading_generate_smoke(void) { }; DailyReading r1, r2; - reading_generate("smoke-test-seed", &birth, 1751500000, &r1); - reading_generate("smoke-test-seed", &birth, 1751500000, &r2); + reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r1); + reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r2); for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { assert(r1.spread.positions[i].card == r2.spread.positions[i].card); @@ -103,7 +103,7 @@ static void test_reading_print_json_shape(void) { }; DailyReading r; - reading_generate("smoke-test-seed", &birth, 1751500000, &r); + reading_generate("smoke-test-seed", &birth, GENDER_FEMALE, 1751500000, &r); FILE *tmp = tmpfile(); assert(tmp); @@ -121,6 +121,7 @@ static void test_reading_print_json_shape(void) { * into what must stay language-independent regardless of --lang). */ assert(buf[0] == '{'); assert(strstr(buf, "\"date\": \"2025-07-02\"") != NULL); /* UTC date of the fixed 1751500000 seed */ + assert(strstr(buf, "\"gender\": \"female\"") != NULL); assert(strstr(buf, "\"natal\"") != NULL); assert(strstr(buf, "\"transits\"") != NULL); assert(strstr(buf, "\"spread\"") != NULL); @@ -155,7 +156,7 @@ static void test_i18n_fallback_and_translation(void) { assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0); assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0); assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0); - assert(strcmp(tarot_position_name(POSITION_OUTCOME), "Das Ergebnis") == 0); + assert(strcmp(tarot_position_name(POSITION_OUTCOME, GENDER_UNSPECIFIED), "Das Ergebnis") == 0); /* Loading a file that doesn't exist fails and leaves the previously * loaded catalog in place, rather than silently clearing it. */ @@ -170,12 +171,45 @@ static void test_i18n_fallback_and_translation(void) { printf("PASS test_i18n_fallback_and_translation\n"); } +static void test_gendered_position_text(void) { + /* English catalog (loaded by the previous test): "Attitude" is the one + * position whose name itself carries a pronoun. */ + assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED), "Themself") == 0); + assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Himself") == 0); + assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Herself") == 0); + assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_UNSPECIFIED), "them") != NULL); + assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_MALE), "him") != NULL); + assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_FEMALE), "her") != NULL); + + /* The Outcome position has no pronoun in Waite's own text - identical + * across all three genders. */ + assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE), + tarot_position_description(POSITION_OUTCOME, GENDER_FEMALE)) == 0); + assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE), + tarot_position_description(POSITION_OUTCOME, GENDER_UNSPECIFIED)) == 0); + + bool loaded = i18n_load("engine/i18n/de.lang"); + assert(loaded); + assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Er selbst") == 0); + assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Sie selbst") == 0); + assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED), + "Die fragende Person selbst") == 0); + assert(strcmp(tarot_position_description(POSITION_ENVIRONMENT, GENDER_MALE), + "Sein Umfeld und die darin wirkenden Tendenzen.") == 0); + + loaded = i18n_load("engine/i18n/en.lang"); /* leave the catalog in a known state */ + assert(loaded); + + printf("PASS test_gendered_position_text\n"); +} + int main(void) { test_tarot_determinism(); test_natal_sun_sign(); test_reading_generate_smoke(); test_reading_print_json_shape(); test_i18n_fallback_and_translation(); + test_gendered_position_text(); printf("All smoke tests passed.\n"); return 0; } diff --git a/interpreter/src/main.c b/interpreter/src/main.c index d747e23..842393c 100644 --- a/interpreter/src/main.c +++ b/interpreter/src/main.c @@ -226,13 +226,13 @@ static void print_top_transits_header(FILE *out, int count) { /* ===== --format text ===== */ -static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread) { +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), + 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)); + fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender)); fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed)); } } @@ -269,7 +269,7 @@ static void interp_print_text(FILE *out, const DailyReading *reading, const Dail } fprintf(out, "\n"); - print_celtic_cross_text(out, &reading->spread); + print_celtic_cross_text(out, &reading->spread, reading->gender); } /* ===== --format html ===== */ @@ -297,7 +297,8 @@ static void print_transit_item_html(FILE *out, const DailyReading *reading, /* 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) { +static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw, + Gender gender) { fprintf(out, "
\n" "
%s
\n" @@ -306,9 +307,9 @@ static void print_card_html(FILE *out, CelticCrossPosition position, const Tarot "
%s
\n" "
%s
\n" "
\n", - tarot_position_name(position), draw->reversed ? "reversed" : "", + 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), + reversed_marker(draw->reversed), tarot_position_description(position, gender), tarot_card_meaning(draw->card, draw->reversed)); } @@ -352,8 +353,8 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail fprintf(out, "

%s

\n", ui("interp.heading_guidance", "Guidance")); fprintf(out, "
\n"); - print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE]); - print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME]); + 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"); @@ -386,7 +387,7 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail 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]); + print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i], reading->gender); } fprintf(out, "
\n"); diff --git a/interpreter/src/reading_io.c b/interpreter/src/reading_io.c index ec085d0..38c303e 100644 --- a/interpreter/src/reading_io.c +++ b/interpreter/src/reading_io.c @@ -39,6 +39,12 @@ static const char *const k_position_slug[TAROT_SPREAD_SIZE] = { "near_future", "attitude", "environment", "hopes_and_fears", "outcome", }; +/* Mirrors tarot_data.c's own k_gender_slug, same duplication policy as + * k_card_slug/k_position_slug above (no reverse lookup exposed there). */ +static const char *const k_gender_slug[3] = { + "unspecified", "male", "female", +}; + static bool body_from_slug(const char *slug, Body *out) { for (int i = 0; i < NUM_BODIES; i++) { if (strcmp(slug, k_body_slug[i]) == 0) { @@ -104,6 +110,16 @@ static bool card_from_slug(const char *slug, TarotCard *out) { return false; } +static bool gender_from_slug(const char *slug, Gender *out) { + for (int i = 0; i < 3; i++) { + if (strcmp(slug, k_gender_slug[i]) == 0) { + *out = (Gender)i; + return true; + } + } + return false; +} + static bool position_from_slug(const char *slug, CelticCrossPosition *out) { for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { if (strcmp(slug, k_position_slug[i]) == 0) { @@ -155,6 +171,18 @@ static void parse_date(const JsonValue *date, DailyReading *out) { out->utc_moment = timegm(&tm_date); } +/* Parses the top-level "gender" field (see reading_print_json in + * engine/src/reading.c) into out->gender. Lenient like parse_date(): a + * missing or unrecognized value just leaves out->gender at + * GENDER_UNSPECIFIED (0, from reading_load_json's own memset), not a + * load failure - it only affects Celtic Cross position text pronouns, + * nothing scoring depends on it. */ +static void parse_gender(const JsonValue *gender, DailyReading *out) { + const char *gender_str = gender ? json_as_string(gender) : NULL; + if (!gender_str) return; + gender_from_slug(gender_str, &out->gender); +} + bool reading_load_json(const char *text, size_t length, DailyReading *out) { memset(out, 0, sizeof(*out)); @@ -197,6 +225,7 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) { out->transits.aspect_count = filled; parse_date(json_object_get(root, "date"), out); + parse_gender(json_object_get(root, "gender"), out); const JsonValue *natal = json_object_get(root, "natal"); parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies); diff --git a/interpreter/src/reading_io.h b/interpreter/src/reading_io.h index 541d82d..1465a13 100644 --- a/interpreter/src/reading_io.h +++ b/interpreter/src/reading_io.h @@ -16,10 +16,13 @@ * significance.c), out->natal.bodies[]/out->transits.bodies[] (sign + * house per body, used only for display), out->spread.positions[] * (card + reversed per Celtic Cross position, used only by - * guidance.c), and out->utc_moment (the top-level "date" field, parsed + * guidance.c), out->utc_moment (the top-level "date" field, parsed * back to midnight UTC of that date - used only for display, e.g. main.c - * printing which day a reading is for). out->natal.ascendant_longitude/ - * houses[] are left zeroed - nothing reads them yet. + * printing which day a reading is for), and out->gender (the top-level + * "gender" field - used only for the Celtic Cross position text's + * pronouns, e.g. tarot_position_name()/tarot_position_description()). + * out->natal.ascendant_longitude/houses[] are left zeroed - nothing + * reads them yet. * * Returns false on malformed JSON, or if "transits"."aspects" isn't * present as an array (an empty array is fine - that's a real "no diff --git a/interpreter/tests/json_test.c b/interpreter/tests/json_test.c index 345b644..69f09c9 100644 --- a/interpreter/tests/json_test.c +++ b/interpreter/tests/json_test.c @@ -50,6 +50,7 @@ static void test_json_parse_rejects_malformed(void) { static const char *k_fixture = "{" " \"date\": \"2026-07-16\"," + " \"gender\": \"female\"," " \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []}," " \"transits\": {" " \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}]," @@ -106,6 +107,38 @@ static void test_reading_load_json_missing_date_defaults_to_zero(void) { printf("PASS test_reading_load_json_missing_date_defaults_to_zero\n"); } +static void test_reading_load_json_extracts_gender(void) { + DailyReading reading; + bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading); + + assert(ok); + assert(reading.gender == GENDER_FEMALE); + + printf("PASS test_reading_load_json_extracts_gender\n"); +} + +static void test_reading_load_json_missing_gender_defaults_to_unspecified(void) { + const char *text = "{\"transits\": {\"aspects\": []}}"; + DailyReading reading; + bool ok = reading_load_json(text, strlen(text), &reading); + + assert(ok); + assert(reading.gender == GENDER_UNSPECIFIED); + + printf("PASS test_reading_load_json_missing_gender_defaults_to_unspecified\n"); +} + +static void test_reading_load_json_unrecognized_gender_defaults_to_unspecified(void) { + const char *text = "{\"gender\": \"xenu\", \"transits\": {\"aspects\": []}}"; + DailyReading reading; + bool ok = reading_load_json(text, strlen(text), &reading); + + assert(ok); + assert(reading.gender == GENDER_UNSPECIFIED); + + printf("PASS test_reading_load_json_unrecognized_gender_defaults_to_unspecified\n"); +} + static void test_reading_load_json_extracts_body_sign_and_house(void) { DailyReading reading; bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading); @@ -206,6 +239,9 @@ int main(void) { test_reading_load_json_extracts_aspects(); test_reading_load_json_extracts_date(); test_reading_load_json_missing_date_defaults_to_zero(); + test_reading_load_json_extracts_gender(); + test_reading_load_json_missing_gender_defaults_to_unspecified(); + test_reading_load_json_unrecognized_gender_defaults_to_unspecified(); test_reading_load_json_extracts_body_sign_and_house(); test_reading_load_json_extracts_spread_positions(); test_reading_load_json_missing_spread_is_not_fatal(); diff --git a/watch/package.json b/watch/package.json index a9f86a9..3b3bbbb 100644 --- a/watch/package.json +++ b/watch/package.json @@ -35,6 +35,7 @@ "BIRTH_UTC_OFFSET_MINUTES", "BIRTH_LAT_MICRODEG", "BIRTH_LON_MICRODEG", + "GENDER", "LANG", "NOTIFY_ENABLED", "NOTIFY_HOUR", diff --git a/watch/src/c/app_message.c b/watch/src/c/app_message.c index c36fc90..a953316 100644 --- a/watch/src/c/app_message.c +++ b/watch/src/c/app_message.c @@ -44,6 +44,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) { cfg.birth.longitude = v / 1000000.0; got_birth = true; } + if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v; Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG); if (lang_tuple) { diff --git a/watch/src/c/config.c b/watch/src/c/config.c index 27f2995..cfd1dda 100644 --- a/watch/src/c/config.c +++ b/watch/src/c/config.c @@ -17,6 +17,7 @@ enum { PKEY_BIRTH_UTC_OFFSET_MINUTES, PKEY_BIRTH_LAT_MICRODEG, PKEY_BIRTH_LON_MICRODEG, + PKEY_GENDER, PKEY_LANG, PKEY_NOTIFY_ENABLED, PKEY_NOTIFY_HOUR, @@ -40,6 +41,7 @@ void config_load(WatchConfig *out) { out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0; out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0; out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0; + out->gender = (Gender)persist_read_int(PKEY_GENDER); /* defaults to 0 = GENDER_UNSPECIFIED */ if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) { strcpy(out->lang, "en"); @@ -61,6 +63,7 @@ void config_save(const WatchConfig *cfg) { persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0)); persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0)); persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0)); + persist_write_int(PKEY_GENDER, (int32_t)cfg->gender); persist_write_string(PKEY_LANG, cfg->lang); @@ -79,8 +82,9 @@ void config_log(const WatchConfig *cfg) { APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin", cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute, (int)(cfg->birth.utc_offset_hours * 60)); - APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 lang=%s", - (long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000), cfg->lang); + APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 gender=%d lang=%s", + (long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000), + (int)cfg->gender, cfg->lang); APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d", cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute); } diff --git a/watch/src/c/config.h b/watch/src/c/config.h index 0bfc87f..82eab7b 100644 --- a/watch/src/c/config.h +++ b/watch/src/c/config.h @@ -4,6 +4,7 @@ #include #include "../../../engine/src/astro.h" +#include "../../../engine/src/tarot.h" /* Gender */ /* Everything the watch needs that isn't computed fresh each day: birth * data (the same fields as dist/user.properties), display language, and @@ -13,6 +14,9 @@ typedef struct { bool configured; /* false until the config page has been submitted once */ BirthData birth; + Gender gender; /* who the reading is for - only affects Celtic Cross + * position text pronouns, see engine/src/tarot.h. + * Defaults to GENDER_UNSPECIFIED (0). */ char lang[8]; /* e.g. "en", "de" - matches engine/i18n/.lang's own code */ bool notify_enabled; int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e. diff --git a/watch/src/c/main.c b/watch/src/c/main.c index 51732ee..a30a9da 100644 --- a/watch/src/c/main.c +++ b/watch/src/c/main.c @@ -25,7 +25,7 @@ static void on_config_updated(const WatchConfig *cfg) { if (!s_config.configured) return; config_log(&s_config); /* the values this newly-recomputed reading is based on */ - reading_state_recompute(&s_config.birth); + reading_state_recompute(&s_config.birth, s_config.gender); if (was_configured) { report_window_reload(); } else { @@ -41,7 +41,7 @@ int main(void) { config_log(&s_config); /* the values this session's reading (if any) is based on */ if (s_config.configured) { - reading_state_recompute(&s_config.birth); + reading_state_recompute(&s_config.birth, s_config.gender); report_window_push(); } else { show_setup_required(); diff --git a/watch/src/c/reading_state.c b/watch/src/c/reading_state.c index ed8df6f..e8a2f9d 100644 --- a/watch/src/c/reading_state.c +++ b/watch/src/c/reading_state.c @@ -5,7 +5,7 @@ static DailyReading s_reading; static DailyInterpretation s_interp; -void reading_state_recompute(const BirthData *birth) { +void reading_state_recompute(const BirthData *birth, Gender gender) { time_t now = time(NULL); /* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately - * same justification as astro.c's own time_from_unix(). */ @@ -14,7 +14,7 @@ void reading_state_recompute(const BirthData *birth) { char seed[16]; snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday); - reading_generate(seed, birth, now, &s_reading); + reading_generate(seed, birth, gender, now, &s_reading); interpret_daily_reading(&s_reading, &s_interp); } diff --git a/watch/src/c/reading_state.h b/watch/src/c/reading_state.h index 019ce89..0a80410 100644 --- a/watch/src/c/reading_state.h +++ b/watch/src/c/reading_state.h @@ -5,12 +5,12 @@ #include "../../../interpreter/src/significance.h" /* Recomputes today's reading (astro + tarot + interpretation) from - * `birth`, using the watch's current clock: today's UTC date as the - * tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's + * `birth`/`gender`, using the watch's current clock: today's UTC date as + * the tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's * "Build & run") and the current moment for the transit snapshot. Safe * to call again later (e.g. the day rolled over while the app stayed * open) - overwrites the previous result in place. */ -void reading_state_recompute(const BirthData *birth); +void reading_state_recompute(const BirthData *birth, Gender gender); /* Valid only after at least one reading_state_recompute() call. */ const DailyReading *reading_state_get_reading(void); diff --git a/watch/src/c/ui_report_window.c b/watch/src/c/ui_report_window.c index 49f310f..067aa4b 100644 --- a/watch/src/c/ui_report_window.c +++ b/watch/src/c/ui_report_window.c @@ -259,7 +259,7 @@ static void build_content(int16_t width) { const TarotDraw *draw = &reading->spread.positions[i]; snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s", - tarot_position_name((CelticCrossPosition)i), tarot_card_name(draw->card), + tarot_position_name((CelticCrossPosition)i, reading->gender), tarot_card_name(draw->card), reversed_marker(draw->reversed)); y = add_text(width, y, s_position_titles[i], s_font_subhead); @@ -272,7 +272,7 @@ static void build_content(int16_t width) { scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer)); y += s_slots[i].height + MARGIN; - y = add_text(width, y, tarot_position_description((CelticCrossPosition)i), s_font_body); + y = add_text(width, y, tarot_position_description((CelticCrossPosition)i, reading->gender), s_font_body); y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body); } diff --git a/watch/src/pkjs/index.js b/watch/src/pkjs/index.js index c7a9e5c..36241bd 100644 --- a/watch/src/pkjs/index.js +++ b/watch/src/pkjs/index.js @@ -20,6 +20,14 @@ var LANGUAGES = [ { code: 'en', label: 'English' }, { code: 'de', label: 'Deutsch' } ]; +// Matches engine/src/tarot.h's Gender enum (GENDER_UNSPECIFIED = 0, +// GENDER_MALE = 1, GENDER_FEMALE = 2) - only affects the Celtic Cross +// position text's pronouns, never the chart/draw itself. +var GENDERS = [ + { code: 0, label: 'Prefer not to say' }, + { code: 1, label: 'Male' }, + { code: 2, label: 'Female' } +]; // 120x120 PNG of res/logo-small.png, inlined as base64 - this page is a // bare `data:` URI with no server, so there's nowhere else to load an @@ -65,6 +73,16 @@ function buildLanguageOptions(selected) { return html; } +function buildGenderOptions(selected) { + var html = ''; + for (var i = 0; i < GENDERS.length; i++) { + var gender = GENDERS[i]; + var isSelected = gender.code === selected ? ' selected' : ''; + html += ''; + } + return html; +} + // Runs *inside* the webview (embedded as a