2 Commits

Author SHA1 Message Date
ml 7a77c4a07d Adding gender to the config
build / build (push) Successful in 19s
2026-07-16 22:49:06 +02:00
ml 1ebbfebe31 Add watch/phone icons and logo, reorder the interpretation report, and stamp every report with the day it's for
build / build (push) Successful in 30s
2026-07-16 18:54:32 +02:00
29 changed files with 869 additions and 198 deletions
+1
View File
@@ -5,6 +5,7 @@
/watch/build /watch/build
/watch/src/c/i18n_tables.auto.c /watch/src/c/i18n_tables.auto.c
/watch/resources/img /watch/resources/img
/watch/resources/app_icon.png
# Durable backup of the same real birth data, outside dist/ so a dist/ # Durable backup of the same real birth data, outside dist/ so a dist/
# wipe doesn't lose it — see the Makefile's `scripts` target. Never # wipe doesn't lose it — see the Makefile's `scripts` target. Never
+31 -3
View File
@@ -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 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). 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`) ### Translations (`i18n.c`, `engine/i18n/*.lang`)
All display text (planet/sign/moon-phase/aspect names, tarot card names 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.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s `moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
own labels — deliberately keyed off separate slug strings rather than own labels — deliberately keyed off separate slug strings rather than
the C enum names, so renaming an enumerator can never silently break a 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.<slug>.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
`<code>.lang` in the same directory (keys must match exactly) and `<code>.lang` in the same directory (keys must match exactly) and
translate the values — no source changes needed, `make` picks up any translate the values — no source changes needed, `make` picks up any
`*.lang` file there automatically. `*.lang` file there automatically.
@@ -444,10 +466,16 @@ built once and linked into *both* binaries.
`VENDORED.md` for the pinned upstream commit. `VENDORED.md` for the pinned upstream commit.
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed - 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 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 - `engine/i18n/de.lang`'s card/spread text is an original translation of
that same public-domain English text, made for this project — not 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 - Transit narrative text in `interpreter/src/narrative.c`: condensed from
Sepharial's *Transits and Planetary Periods* (1920, public domain — Sepharial's *Transits and Planetary Periods* (1920, public domain —
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto `res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
+52 -15
View File
@@ -17,6 +17,7 @@ same `--lang`) for its own report, independently of `deck-engine`.
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg> --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
[--date YYYY-MM-DDTHH:MM] [--format text|html|json] [--date YYYY-MM-DDTHH:MM] [--format text|html|json]
[--gender male|female|unspecified]
``` ```
| Flag | Required | Format | Meaning | | Flag | Required | Format | Meaning |
@@ -29,6 +30,7 @@ deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. | | `--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. | | `--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`. | | `--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 `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. | | `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.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>.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). | | `--i18n-dir` | no | path | Directory to look for `<lang>.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_utc_offset=2
birth_lat=52.5200 birth_lat=52.5200
birth_lon=13.4050 birth_lon=13.4050
gender=unspecified
lang=en lang=en
``` ```
@@ -59,8 +62,8 @@ use:
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh` with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
checks every birth field for an empty value or a leftover `YOUR_` 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 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 filled in; `gender` and `lang` have no such check since they always
default (`en`). Since `dist/` itself can be wiped (`make clean`, or 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 deleting the directory), the build also keeps a durable backup at
`engine/scripts/user.properties.local` (gitignored) once the file `engine/scripts/user.properties.local` (gitignored) once the file
looks filled in, and restores from it if `dist/user.properties` is 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 catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
key-naming convention and how to add a new language. 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; `engine/i18n/en.lang` and `de.lang` are the shipped translation files;
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every `make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
build, like `dist/img/`). Adding a language is just dropping another 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 ===== ===== Celtic Cross =====
The Present The High Priestess (Reversed) 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. Passion, moral or physical ardour, conceit, surface knowledge.
... ...
``` ```
@@ -171,8 +182,8 @@ cards rendered as an image:
### `--format json` ### `--format json`
A single JSON object mirroring `DailyReading` exactly — `natal`, A single JSON object mirroring `DailyReading` exactly — `date`, `gender`,
`transits`, `spread` — using the **slug** accessors `natal`, `transits`, `spread` — using the **slug** accessors
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`, (`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`) `astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
rather than the display-name ones, so the output is identical regardless rather than the display-name ones, so the output is identical regardless
@@ -182,6 +193,8 @@ read.
```json ```json
{ {
"date": "2026-07-16",
"gender": "unspecified",
"natal": { "natal": {
"bodies": [ "bodies": [
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3}, {"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
@@ -210,6 +223,17 @@ read.
} }
``` ```
- `date` is the UTC calendar date of `--date` (or the current system time
if `--date` was omitted) — the day this reading is *for*, not
necessarily the day it was generated on. `reading_generate()` stores
the full moment in `DailyReading.utc_moment`; only the date part is
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 - `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
`CelticCrossPosition` enum order (Present, Challenge, Crown, `CelticCrossPosition` enum order (Present, Challenge, Crown,
@@ -279,11 +303,16 @@ dist/run-interpreter.sh html de # override format/language for this run
### `--format text` ### `--format text`
A plain-text report, printed in a fixed order — A plain-text report, printed in a fixed order —
1. the day's overall significance level (`Quiet`/`Notable`/ 1. the day this reading is for (`Reading for: 2026-07-16`), from the
input JSON's top-level `date` field (see `--format json` above) —
not necessarily the day the report is actually being read on, if
`reading.json` was generated earlier and piped into
`interpreter-cli` later;
2. the day's overall significance level (`Quiet`/`Notable`/
`Significant`/`Major`, plus its rank out of the 4 defined levels, `Significant`/`Major`, plus its rank out of the 4 defined levels,
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look" e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
line on `Major` days only; line on `Major` days only;
2. up to 5 of today's aspects ranked by score, each naming the sign 3. up to 5 of today's aspects ranked by score, each naming the sign
and natal house the transiting planet currently occupies and the and natal house the transiting planet currently occupies and the
sign and house of the natal planet it's aspecting, followed by a sign and house of the natal planet it's aspecting, followed by a
short narrative sentence about what that transiting planet short narrative sentence about what that transiting planet
@@ -291,10 +320,10 @@ A plain-text report, printed in a fixed order —
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
the source material has nothing to say for that planet/aspect the source material has nothing to say for that planet/aspect
combination; combination;
3. the full 10-position Celtic Cross spread, in Waite's own drawing 4. the full 10-position Celtic Cross spread, in Waite's own drawing
order, each with its card (and orientation), the position's own order, each with its card (and orientation), the position's own
description, and that card's actual meaning; description, and that card's actual meaning;
4. finally, a guidance paragraph tying the day's top transit to the 5. finally, a guidance paragraph tying the day's top transit to the
spread's Attitude/Outcome cards, with its opening sentence tailored spread's Attitude/Outcome cards, with its opening sentence tailored
to `day_level` (stronger wording on `Major`, softer on to `day_level` (stronger wording on `Major`, softer on
`Quiet`/`Notable`, falling back to a transit-free reading of the `Quiet`/`Notable`, falling back to a transit-free reading of the
@@ -305,6 +334,8 @@ A plain-text report, printed in a fixed order —
seen the full spread it references. seen the full spread it references.
``` ```
Reading for: 2026-07-16
Day significance: Major (4/4) Day significance: Major (4/4)
(a major transit today - worth a deeper Celtic Cross look) (a major transit today - worth a deeper Celtic Cross look)
@@ -318,14 +349,14 @@ Top 5 significant transits:
Celtic Cross: Celtic Cross:
1. The Present: The Emperor (Reversed) 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. Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
2. The Challenge: The Devil 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. Ravage, violence, vehemence, extraordinary efforts, force, fatality.
... ...
7. Himself: The Magician 7. Themself: The Magician
His position or attitude in the circumstances. Their position or attitude in the circumstances.
Skill, diplomacy, address, subtlety; self-confidence, will. Skill, diplomacy, address, subtlety; self-confidence, will.
... ...
10. The Outcome: Strength 10. The Outcome: Strength
@@ -354,6 +385,8 @@ The same report with `--lang de` (same input as the Major example
above): above):
``` ```
Lesung für: 2026-07-16
Bedeutung des Tages: Einschneidend (4/4) Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich) (ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
@@ -364,7 +397,7 @@ Top 5 wichtige Transits:
Keltisches Kreuz: Keltisches Kreuz:
1. Die Gegenwart: Der Herrscher (Umgekehrt) 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. Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
... ...
@@ -390,7 +423,7 @@ HTML output:
<div class="position">The Present</div> <div class="position">The Present</div>
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor"> <img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
<div class="name">The Emperor (Reversed)</div> <div class="name">The Emperor (Reversed)</div>
<div class="desc">This covers him: the general influence affecting the matter.</div> <div class="desc">This covers them: the general influence affecting the matter.</div>
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div> <div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
</div> </div>
``` ```
@@ -415,6 +448,7 @@ has the slugs to work with directly.
```json ```json
{ {
"date": "2026-07-16",
"day_significance": { "day_significance": {
"level": "major", "level": "major",
"rank": 4, "rank": 4,
@@ -448,6 +482,9 @@ has the slugs to work with directly.
} }
``` ```
- `date` is copied straight through from the input JSON's own top-level
`date` field (see `--format json` above) - always language-independent
regardless of `--lang`, same as every other slug in this document.
- `significant_transits` has `interp.top_item_count` entries (0-5, same - `significant_transits` has `interp.top_item_count` entries (0-5, same
ranking as `--format text`); `celtic_cross` always has exactly 10, in ranking as `--format text`); `celtic_cross` always has exactly 10, in
`CelticCrossPosition` enum order (same order as `deck-engine`'s own `CelticCrossPosition` enum order (same order as `deck-engine`'s own
+41 -12
View File
@@ -71,25 +71,53 @@ aspect.square=Quadrat
aspect.trine=Trigon aspect.trine=Trigon
aspect.opposition=Opposition 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.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.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.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.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.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.name=Die nahe Zukunft
position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird. position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird.
position.attitude.name=Er selbst position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen. position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird.
position.environment.name=Sein Haus position.attitude.name=Die fragende Person selbst
position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen. 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.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.name=Das Ergebnis
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit. position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
@@ -162,6 +190,7 @@ card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Fluc
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit. card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) --- # --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
interp.reading_date=Lesung für:
interp.day_significance=Bedeutung des Tages: interp.day_significance=Bedeutung des Tages:
interp.day_level.quiet=Ruhig interp.day_level.quiet=Ruhig
interp.day_level.notable=Bemerkenswert interp.day_level.notable=Bemerkenswert
+40 -12
View File
@@ -79,25 +79,52 @@ aspect.trine=Trine
aspect.opposition=Opposition aspect.opposition=Opposition
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's # --- 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.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.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.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.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.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.name=The Near Future
position.near_future.desc=This is before him: the influence now coming into action. position.near_future.desc=This is before them: the influence now coming into action.
position.attitude.name=Himself position.near_future.desc.male=This is before him: the influence now coming into action.
position.attitude.desc=His position or attitude in the circumstances. position.near_future.desc.female=This is before her: the influence now coming into action.
position.environment.name=His House position.attitude.name=Themself
position.environment.desc=His environment and the tendencies at work therein. 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.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.name=The Outcome
position.outcome.desc=What will come: the final result of the matter. position.outcome.desc=What will come: the final result of the matter.
@@ -171,6 +198,7 @@ card.world.upright=Assured success, recompense, voyage, route, emigration, fligh
card.world.reversed=Inertia, fixity, stagnation, permanence. card.world.reversed=Inertia, fixity, stagnation, permanence.
# --- Interpreter UI labels (interpreter/src/main.c) --- # --- Interpreter UI labels (interpreter/src/main.c) ---
interp.reading_date=Reading for:
interp.day_significance=Day significance: interp.day_significance=Day significance:
interp.day_level.quiet=Quiet interp.day_level.quiet=Quiet
interp.day_level.notable=Notable interp.day_level.notable=Notable
+3
View File
@@ -29,6 +29,7 @@ birth_time=""
birth_utc_offset="" birth_utc_offset=""
birth_lat="" birth_lat=""
birth_lon="" birth_lon=""
gender="unspecified"
lang="en" lang="en"
while IFS='=' read -r key value || [ -n "$key" ]; do 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_utc_offset) birth_utc_offset="$value" ;;
birth_lat) birth_lat="$value" ;; birth_lat) birth_lat="$value" ;;
birth_lon) birth_lon="$value" ;; birth_lon) birth_lon="$value" ;;
gender) [ -n "$value" ] && gender="$value" ;;
lang) [ -n "$value" ] && lang="$value" ;; lang) [ -n "$value" ] && lang="$value" ;;
esac esac
done <"$PROPERTIES_FILE" done <"$PROPERTIES_FILE"
@@ -77,5 +79,6 @@ exec "$BINARY" \
--birth-lon "$birth_lon" \ --birth-lon "$birth_lon" \
--date "$transit_date" \ --date "$transit_date" \
--format "$format" \ --format "$format" \
--gender "$gender" \
--lang "$lang" \ --lang "$lang" \
--i18n-dir "$SCRIPT_DIR/i18n" --i18n-dir "$SCRIPT_DIR/i18n"
+3
View File
@@ -41,6 +41,7 @@ birth_time=""
birth_utc_offset="" birth_utc_offset=""
birth_lat="" birth_lat=""
birth_lon="" birth_lon=""
gender="unspecified"
lang="en" lang="en"
while IFS='=' read -r key value || [ -n "$key" ]; do 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_utc_offset) birth_utc_offset="$value" ;;
birth_lat) birth_lat="$value" ;; birth_lat) birth_lat="$value" ;;
birth_lon) birth_lon="$value" ;; birth_lon) birth_lon="$value" ;;
gender) [ -n "$value" ] && gender="$value" ;;
lang) [ -n "$value" ] && lang="$value" ;; lang) [ -n "$value" ] && lang="$value" ;;
esac esac
done <"$PROPERTIES_FILE" done <"$PROPERTIES_FILE"
@@ -92,4 +94,5 @@ lang="${2:-$lang}"
--birth-lon "$birth_lon" \ --birth-lon "$birth_lon" \
--date "$transit_date" \ --date "$transit_date" \
--format json \ --format json \
--gender "$gender" \
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n" | exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
+7
View File
@@ -11,6 +11,12 @@
# (e.g. 2 for Central European Summer Time) # (e.g. 2 for Central European Summer Time)
# birth_lat Birth location latitude, decimal degrees, north positive # birth_lat Birth location latitude, decimal degrees, north positive
# birth_lon Birth location longitude, decimal degrees, east 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 Reading language code - matches a file named
# <code>.lang in dist/i18n/ (see engine/i18n/ for the # <code>.lang in dist/i18n/ (see engine/i18n/ for the
# source files, currently "en" and "de"). Optional, # 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_utc_offset=YOUR_UTC_OFFSET_HERE
birth_lat=YOUR_LATITUDE_HERE birth_lat=YOUR_LATITUDE_HERE
birth_lon=YOUR_LONGITUDE_HERE birth_lon=YOUR_LONGITUDE_HERE
gender=unspecified
lang=en lang=en
+16 -1
View File
@@ -15,12 +15,16 @@ static void print_usage(const char *prog) {
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n" "Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n" " --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n" " [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
" [--gender male|female|unspecified]\n"
" [--lang <code>] [--i18n-dir <path>]\n\n" " [--lang <code>] [--i18n-dir <path>]\n\n"
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n" " --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
" --birth-date/time Birth date/time in local clock time.\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-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" " --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" " --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" " --format Output format, defaults to text. html links card art via\n"
" img/<file>, i.e. it expects to be saved next to the img/\n" " img/<file>, i.e. it expects to be saved next to the img/\n"
" directory that `make images` copies into dist/. json uses\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 *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
const char *date_str = NULL, *format_str = "text"; const char *date_str = NULL, *format_str = "text";
const char *lang = "en", *i18n_dir_arg = NULL; const char *lang = "en", *i18n_dir_arg = NULL;
const char *gender_str = "unspecified";
for (int i = 1; i < argc; i++) { for (int i = 1; i < argc; i++) {
const char *arg = argv[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, "--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, "--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, "--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, "--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, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) { else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
@@ -136,6 +142,15 @@ int main(int argc, char **argv) {
return 1; 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]; char i18n_path[512];
if (i18n_dir_arg) { if (i18n_dir_arg) {
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang); 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; DailyReading reading;
reading_generate(seed, &birth, utc_moment, &reading); reading_generate(seed, &birth, gender, utc_moment, &reading);
if (format == FORMAT_HTML) { if (format == FORMAT_HTML) {
reading_print_html(&reading, stdout); reading_print_html(&reading, stdout);
+11 -5
View File
@@ -3,8 +3,10 @@
#include <math.h> #include <math.h>
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) { time_t utc_moment, DailyReading *out) {
out->utc_moment = utc_moment;
out->gender = gender;
astro_compute_natal_chart(birth, &out->natal); astro_compute_natal_chart(birth, &out->natal);
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits); astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
tarot_draw_celtic_cross(tarot_seed, &out->spread); tarot_draw_celtic_cross(tarot_seed, &out->spread);
@@ -65,10 +67,10 @@ void reading_print_text(const DailyReading *r, FILE *out) {
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross")); fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &r->spread.positions[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 ? " " : "", tarot_card_name(draw->card), draw->reversed ? " " : "",
draw->reversed ? ui("ui.reversed", "(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)); fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
} }
} }
@@ -137,12 +139,12 @@ void reading_print_html(const DailyReading *r, FILE *out) {
" <div class=\"desc\">%s</div>\n" " <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n" " <div class=\"meaning\">%s</div>\n"
"</div>\n", "</div>\n",
tarot_position_name((CelticCrossPosition)i), tarot_position_name((CelticCrossPosition)i, r->gender),
draw->reversed ? "reversed" : "", draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_image_file(draw->card), tarot_card_name(draw->card),
tarot_card_name(draw->card), draw->reversed ? " " : "", tarot_card_name(draw->card), draw->reversed ? " " : "",
draw->reversed ? ui("ui.reversed", "(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)); tarot_card_meaning(draw->card, draw->reversed));
} }
fprintf(out, "</div>\n</body></html>\n"); fprintf(out, "</div>\n</body></html>\n");
@@ -160,6 +162,10 @@ static void print_body_position_json(FILE *out, const char *indent, Body body,
void reading_print_json(const DailyReading *r, FILE *out) { void reading_print_json(const DailyReading *r, FILE *out) {
fprintf(out, "{\n"); fprintf(out, "{\n");
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"); fprintf(out, " \"natal\": {\n \"bodies\": [\n");
for (int b = 0; b < NUM_BODIES; b++) { for (int b = 0; b < NUM_BODIES; b++) {
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]); print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
+11 -1
View File
@@ -15,6 +15,16 @@
#include "tarot.h" #include "tarot.h"
typedef struct { typedef struct {
/* The moment passed to reading_generate() - the day this reading is
* for, not when it happened to be generated. Set by reading_generate()
* itself, so every caller (CLI, watch, reading_load_json() on the
* 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; NatalChart natal;
DailyTransits transits; DailyTransits transits;
CelticCrossSpread spread; CelticCrossSpread spread;
@@ -22,7 +32,7 @@ typedef struct {
/* The real API: this is the only function the watchapp needs to call. /* 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. */ * 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); time_t utc_moment, DailyReading *out);
/* Desktop-only output formats for inspecting a reading before any watch /* Desktop-only output formats for inspecting a reading before any watch
+14 -2
View File
@@ -52,6 +52,17 @@ typedef struct {
bool reversed; bool reversed;
} TarotDraw; } 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 { typedef struct {
/* Indexed by CelticCrossPosition. */ /* Indexed by CelticCrossPosition. */
TarotDraw positions[TAROT_SPREAD_SIZE]; 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_image_file(TarotCard card); /* matches res/img/ */
const char *tarot_card_meaning(TarotCard card, bool reversed); const char *tarot_card_meaning(TarotCard card, bool reversed);
const char *tarot_position_name(CelticCrossPosition position); const char *tarot_position_name(CelticCrossPosition position, Gender gender);
const char *tarot_position_description(CelticCrossPosition position); const char *tarot_position_description(CelticCrossPosition position, Gender gender);
/* Stable, language-independent identifiers (e.g. "high_priestess", /* Stable, language-independent identifiers (e.g. "high_priestess",
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup * "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. */ * (reading_print_json) that must stay identical regardless of --lang. */
const char *tarot_card_slug(TarotCard card); const char *tarot_card_slug(TarotCard card);
const char *tarot_position_slug(CelticCrossPosition position); const char *tarot_position_slug(CelticCrossPosition position);
const char *tarot_gender_slug(Gender gender); /* "unspecified"/"male"/"female" */
#endif #endif
+112 -34
View File
@@ -130,52 +130,97 @@ static const TarotCardInfo k_cards[TAROT_DECK_SIZE] = {
/* Position names and one-line descriptions follow Waite's own account /* Position names and one-line descriptions follow Waite's own account
* of "An Ancient Celtic Method of Divination" (Part III §7), in his * 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 { typedef struct {
const char *name; const char *name[3];
const char *description; const char *description[3];
} PositionInfo; } PositionInfo;
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = { static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
[POSITION_PRESENT] = { [POSITION_PRESENT] = {
"The Present", { "The Present", "The Present", "The Present" },
"This covers him: the general influence affecting the matter." {
"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] = { [POSITION_CHALLENGE] = {
"The Challenge", { "The Challenge", "The Challenge", "The Challenge" },
"This crosses him: the nature of the obstacle in the matter." {
"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] = { [POSITION_CROWN] = {
"The Crown", { "The Crown", "The Crown", "The Crown" },
"This crowns him: the aim or ideal, the best that can be achieved." {
"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] = { [POSITION_FOUNDATION] = {
"The Foundation", { "The Foundation", "The Foundation", "The Foundation" },
"This is beneath him: the basis of the matter, already actual." {
"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] = { [POSITION_RECENT_PAST] = {
"The Recent Past", { "The Recent Past", "The Recent Past", "The Recent Past" },
"This is behind him: the influence that is just passing away." {
"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] = { [POSITION_NEAR_FUTURE] = {
"The Near Future", { "The Near Future", "The Near Future", "The Near Future" },
"This is before him: the influence now coming into action." {
"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] = { [POSITION_ATTITUDE] = {
"Himself", { "Themself", "Himself", "Herself" },
"His position or attitude in the circumstances." {
"Their position or attitude in the circumstances.",
"His position or attitude in the circumstances.",
"Her position or attitude in the circumstances.",
}
}, },
[POSITION_ENVIRONMENT] = { [POSITION_ENVIRONMENT] = {
"His House", { "Their House", "His House", "Her House" },
"His environment and the tendencies at work therein." {
"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] = { [POSITION_HOPES_AND_FEARS] = {
"Hopes and Fears", { "Hopes and Fears", "Hopes and Fears", "Hopes and Fears" },
"His hopes or fears in the matter." {
"Their hopes or fears in the matter.",
"His hopes or fears in the matter.",
"Her hopes or fears in the matter.",
}
}, },
[POSITION_OUTCOME] = { [POSITION_OUTCOME] = {
"The Outcome", { "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.",
"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); return i18n_get(key, fallback);
} }
static const char *position_field(const char *slug, const char *field, const char *fallback) { /* Suffix appended to the i18n key for the male/female variants - the
char key[64]; * unspecified/neutral variant uses the plain, unsuffixed key so it
strcpy(key, "position."); * matches the field name callers already know (e.g. "position.
strcat(key, slug); * attitude.name"), same convention as card_field()'s upright/reversed
strcat(key, "."); * field names above. */
strcat(key, field); static const char *const k_gender_suffix[3] = {
return i18n_get(key, fallback); [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) { 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); reversed ? k_cards[card].reversed : k_cards[card].upright);
} }
const char *tarot_position_name(CelticCrossPosition position) { const char *tarot_position_name(CelticCrossPosition position, Gender gender) {
return position_field(k_position_slug[position], "name", k_positions[position].name); return position_field(k_position_slug[position], "name", gender, k_positions[position].name[gender]);
} }
const char *tarot_position_description(CelticCrossPosition position) { const char *tarot_position_description(CelticCrossPosition position, Gender gender) {
return position_field(k_position_slug[position], "desc", k_positions[position].description); 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_card_slug(TarotCard card) { return k_card_slug[card]; }
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; } const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
const char *tarot_gender_slug(Gender gender) { return k_gender_slug[gender]; }
+39 -4
View File
@@ -72,8 +72,8 @@ static void test_reading_generate_smoke(void) {
}; };
DailyReading r1, r2; DailyReading r1, r2;
reading_generate("smoke-test-seed", &birth, 1751500000, &r1); reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r1);
reading_generate("smoke-test-seed", &birth, 1751500000, &r2); reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r2);
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
assert(r1.spread.positions[i].card == r2.spread.positions[i].card); 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; DailyReading r;
reading_generate("smoke-test-seed", &birth, 1751500000, &r); reading_generate("smoke-test-seed", &birth, GENDER_FEMALE, 1751500000, &r);
FILE *tmp = tmpfile(); FILE *tmp = tmpfile();
assert(tmp); assert(tmp);
@@ -120,6 +120,8 @@ static void test_reading_print_json_shape(void) {
* a stray astro_*_name()/tarot_*_name() call leaking translated text * a stray astro_*_name()/tarot_*_name() call leaking translated text
* into what must stay language-independent regardless of --lang). */ * into what must stay language-independent regardless of --lang). */
assert(buf[0] == '{'); 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, "\"natal\"") != NULL);
assert(strstr(buf, "\"transits\"") != NULL); assert(strstr(buf, "\"transits\"") != NULL);
assert(strstr(buf, "\"spread\"") != NULL); assert(strstr(buf, "\"spread\"") != NULL);
@@ -154,7 +156,7 @@ static void test_i18n_fallback_and_translation(void) {
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0); assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0); assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 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 /* Loading a file that doesn't exist fails and leaves the previously
* loaded catalog in place, rather than silently clearing it. */ * loaded catalog in place, rather than silently clearing it. */
@@ -169,12 +171,45 @@ static void test_i18n_fallback_and_translation(void) {
printf("PASS test_i18n_fallback_and_translation\n"); 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) { int main(void) {
test_tarot_determinism(); test_tarot_determinism();
test_natal_sun_sign(); test_natal_sun_sign();
test_reading_generate_smoke(); test_reading_generate_smoke();
test_reading_print_json_shape(); test_reading_print_json_shape();
test_i18n_fallback_and_translation(); test_i18n_fallback_and_translation();
test_gendered_position_text();
printf("All smoke tests passed.\n"); printf("All smoke tests passed.\n");
return 0; return 0;
} }
+148 -83
View File
@@ -126,6 +126,15 @@ static const char *sign_display_name(ZodiacSign sign) {
return ui(key, fallback[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) { static const char *day_level_name(DaySignificance level) {
switch (level) { switch (level) {
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet"); case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
@@ -179,48 +188,19 @@ static char *capture_guidance(const DailyInterpretation *interp, const CelticCro
return buf; return buf;
} }
static void print_top_transits_header(FILE *out, int count) { /* Prints one significant-transit item's full detail (title line with
if (count == 1) { * sign/house/orb/score, plus its narrative sentence) - shared between
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:")); * the "Significant Transits" list and the day's top item, which is
} else { * repeated in full just above the guidance paragraph (see
char buf[64]; * interp_print_text) so the reader has that same detail in view right
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count); * next to the guidance text it informs. `index` is 1-based and prefixes
fprintf(out, "%s\n", buf); * 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) {
/* ===== --format text ===== */
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread) {
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),
tarot_card_name(draw->card), reversed_marker(draw->reversed));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
}
}
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
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");
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++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect; const Aspect *a = &item->aspect;
if (index > 0) fprintf(out, " %d. ", index);
fprintf(out, " %d. %s %s", i + 1, ui("ui.transiting", "Transiting"), fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[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"), fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet)); body_display_name(a->natal_planet));
@@ -233,19 +213,106 @@ static void interp_print_text(FILE *out, const DailyReading *reading, const Dail
reading->transits.bodies[a->transiting_planet].house); reading->transits.bodies[a->transiting_planet].house);
fputs(narrative_buf, out); 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, "\n");
print_celtic_cross_text(out, &reading->spread); fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "\n"); if (interp->top_item_count > 0) {
print_transit_item_text(out, reading, &interp->top_items[0], 0);
}
char guidance_buf[GUIDANCE_TEXT_MAX]; char guidance_buf[GUIDANCE_TEXT_MAX];
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread); guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
fputs(guidance_buf, out); 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 ===== */ /* ===== --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) { static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, fprintf(out,
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n" "<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
@@ -267,6 +334,11 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily 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)", fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
ui("interp.heading_day_significance", "Day Significance"), ui("interp.heading_day_significance", "Day Significance"),
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level), ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
@@ -277,48 +349,20 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
} }
fprintf(out, "</p>\n"); fprintf(out, "</p>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits")); char *guidance = capture_guidance(interp, &reading->spread);
if (interp->top_item_count == 0) { fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
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++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
fprintf(out, "<tr><td>%d.</td><td>%s %s %s %s %s</td>" fprintf(out, "<div class=\"spread attitude-outcome\">\n");
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n", print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE], reading->gender);
i + 1, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet), print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME], reading->gender);
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);
}
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++) {
const TarotDraw *draw = &reading->spread.positions[i];
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((CelticCrossPosition)i), 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((CelticCrossPosition)i),
tarot_card_meaning(draw->card, draw->reversed));
}
fprintf(out, "</div>\n"); fprintf(out, "</div>\n");
char *guidance = capture_guidance(interp, &reading->spread); fprintf(out, "<div class=\"guidance\">\n");
fprintf(out, "<h2>%s</h2>\n<div class=\"guidance\">\n", ui("interp.heading_guidance", "Guidance")); 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; const char *start = guidance;
for (const char *p = guidance; ; p++) { for (const char *p = guidance; ; p++) {
if (*p == '\n' || *p == '\0') { if (*p == '\n' || *p == '\0') {
@@ -330,6 +374,23 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
free(guidance); free(guidance);
fprintf(out, "</div>\n"); 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"); fprintf(out, "</body></html>\n");
} }
@@ -356,6 +417,10 @@ static void json_string(FILE *out, const char *s) {
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) { static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "{\n"); 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, " \"day_significance\": {\n");
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]); fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1); fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
+53
View File
@@ -1,6 +1,9 @@
#define _DEFAULT_SOURCE /* for timegm, parsing "date" back into utc_moment */
#include "reading_io.h" #include "reading_io.h"
#include "json.h" #include "json.h"
#include <stdio.h>
#include <string.h> #include <string.h>
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key /* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
@@ -36,6 +39,12 @@ static const char *const k_position_slug[TAROT_SPREAD_SIZE] = {
"near_future", "attitude", "environment", "hopes_and_fears", "outcome", "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) { static bool body_from_slug(const char *slug, Body *out) {
for (int i = 0; i < NUM_BODIES; i++) { for (int i = 0; i < NUM_BODIES; i++) {
if (strcmp(slug, k_body_slug[i]) == 0) { if (strcmp(slug, k_body_slug[i]) == 0) {
@@ -101,6 +110,16 @@ static bool card_from_slug(const char *slug, TarotCard *out) {
return false; 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) { static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) { for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
if (strcmp(slug, k_position_slug[i]) == 0) { if (strcmp(slug, k_position_slug[i]) == 0) {
@@ -133,6 +152,37 @@ static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
} }
} }
/* Parses the top-level "date" field ("YYYY-MM-DD", see reading_print_json
* in engine/src/reading.c) back into out->utc_moment, as midnight UTC of
* that date - reading_print_json only ever wrote the calendar date, not a
* time of day, so that's the only value that round-trips through
* gmtime() to the same date. Lenient like parse_bodies()/parse_spread():
* a missing or malformed "date" just leaves out->utc_moment at 0 (from
* reading_load_json's own memset), not a load failure - nothing here
* depends on it for scoring, only for display. */
static void parse_date(const JsonValue *date, DailyReading *out) {
const char *date_str = date ? json_as_string(date) : NULL;
if (!date_str) return;
struct tm tm_date = {0};
if (sscanf(date_str, "%d-%d-%d", &tm_date.tm_year, &tm_date.tm_mon, &tm_date.tm_mday) != 3) return;
tm_date.tm_year -= 1900;
tm_date.tm_mon -= 1;
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) { bool reading_load_json(const char *text, size_t length, DailyReading *out) {
memset(out, 0, sizeof(*out)); memset(out, 0, sizeof(*out));
@@ -174,6 +224,9 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
} }
out->transits.aspect_count = filled; 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"); const JsonValue *natal = json_object_get(root, "natal");
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies); parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies); parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies);
+8 -3
View File
@@ -14,10 +14,15 @@
* around each significant event, and for guidance_print()'s tarot * around each significant event, and for guidance_print()'s tarot
* framing: out->transits.aspects[]/aspect_count (used for scoring - see * framing: out->transits.aspects[]/aspect_count (used for scoring - see
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign + * significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
* house per body, used only for display), and out->spread.positions[] * house per body, used only for display), out->spread.positions[]
* (card + reversed per Celtic Cross position, used only by * (card + reversed per Celtic Cross position, used only by
* guidance.c). out->natal.ascendant_longitude/houses[] are left zeroed - * guidance.c), out->utc_moment (the top-level "date" field, parsed
* nothing reads them yet. * back to midnight UTC of that date - used only for display, e.g. main.c
* 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 * 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 * present as an array (an empty array is fine - that's a real "no
+64
View File
@@ -1,6 +1,7 @@
#include <assert.h> #include <assert.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <time.h>
#include "../src/json.h" #include "../src/json.h"
#include "../src/reading_io.h" #include "../src/reading_io.h"
@@ -48,6 +49,8 @@ static void test_json_parse_rejects_malformed(void) {
* absent entirely, to prove partial spread data doesn't fail the load. */ * absent entirely, to prove partial spread data doesn't fail the load. */
static const char *k_fixture = static const char *k_fixture =
"{" "{"
" \"date\": \"2026-07-16\","
" \"gender\": \"female\","
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []}," " \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
" \"transits\": {" " \"transits\": {"
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}]," " \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
@@ -80,6 +83,62 @@ static void test_reading_load_json_extracts_aspects(void) {
printf("PASS test_reading_load_json_extracts_aspects\n"); printf("PASS test_reading_load_json_extracts_aspects\n");
} }
static void test_reading_load_json_extracts_date(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
struct tm *utc = gmtime(&reading.utc_moment);
assert(utc->tm_year + 1900 == 2026);
assert(utc->tm_mon + 1 == 7);
assert(utc->tm_mday == 16);
printf("PASS test_reading_load_json_extracts_date\n");
}
static void test_reading_load_json_missing_date_defaults_to_zero(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.utc_moment == 0);
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) { static void test_reading_load_json_extracts_body_sign_and_house(void) {
DailyReading reading; DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading); bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
@@ -178,6 +237,11 @@ int main(void) {
test_json_parse_basic_shapes(); test_json_parse_basic_shapes();
test_json_parse_rejects_malformed(); test_json_parse_rejects_malformed();
test_reading_load_json_extracts_aspects(); 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_body_sign_and_house();
test_reading_load_json_extracts_spread_positions(); test_reading_load_json_extracts_spread_positions();
test_reading_load_json_missing_spread_is_not_fatal(); test_reading_load_json_missing_spread_is_not_fatal();
+7
View File
@@ -35,6 +35,7 @@
"BIRTH_UTC_OFFSET_MINUTES", "BIRTH_UTC_OFFSET_MINUTES",
"BIRTH_LAT_MICRODEG", "BIRTH_LAT_MICRODEG",
"BIRTH_LON_MICRODEG", "BIRTH_LON_MICRODEG",
"GENDER",
"LANG", "LANG",
"NOTIFY_ENABLED", "NOTIFY_ENABLED",
"NOTIFY_HOUR", "NOTIFY_HOUR",
@@ -42,6 +43,12 @@
], ],
"resources": { "resources": {
"media": [ "media": [
{
"type": "bitmap",
"name": "IMAGE_APP_ICON",
"file": "app_icon.png",
"menuIcon": true
},
{ {
"type": "bitmap", "type": "bitmap",
"name": "IMG_CARD_FOOL", "name": "IMG_CARD_FOOL",
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Downsizes res/app_icon_50x50_64_color.png into watch/resources/app_icon.png.
Pebble's own build tooling (process_sdk_resources.py) hard-fails any
"menuIcon": true resource larger than 25x25px - the fixed size Pebble OS
renders app icons at in the watch's own app launcher list (and, synced
from the watch, in the mobile app's installed-apps list too). The source
art in res/ is 50x50 (square, so it downsamples to exactly 25x25 with no
letterboxing) - the color variant is used, per the same "prefer color"
choice as the rest of this app's card art, over the companion
*_gray.png fallback also present in res/.
Generated output, like i18n_tables.auto.c and resources/img/*.png - not
committed (see .gitignore), regenerate by running this script directly
or via `pebble build` (wired into wscript alongside gen_card_images.py).
Requires Pillow (`pip install Pillow`), not part of this repo's own
desktop build/test toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_PATH = os.path.normpath(
os.path.join(SCRIPT_DIR, "..", "..", "res", "app_icon_50x50_64_color.png")
)
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "app_icon.png"))
MAX_ICON_SIZE = 25 # Pebble's own hard cap for "menuIcon": true resources.
def main():
if os.path.exists(OUT_PATH) and os.path.getmtime(OUT_PATH) >= os.path.getmtime(SRC_PATH):
print(f"{OUT_PATH} already up to date", file=sys.stderr)
return
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with Image.open(SRC_PATH) as img:
ratio = MAX_ICON_SIZE / max(img.width, img.height)
size = (round(img.width * ratio), round(img.height * ratio))
resized = img.convert("RGBA").resize(size, Image.LANCZOS)
resized.save(OUT_PATH, "PNG", optimize=True)
print(f"wrote {OUT_PATH} ({size[0]}x{size[1]})", file=sys.stderr)
if __name__ == "__main__":
main()
+1
View File
@@ -44,6 +44,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) {
cfg.birth.longitude = v / 1000000.0; cfg.birth.longitude = v / 1000000.0;
got_birth = true; got_birth = true;
} }
if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v;
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG); Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
if (lang_tuple) { if (lang_tuple) {
+6 -2
View File
@@ -17,6 +17,7 @@ enum {
PKEY_BIRTH_UTC_OFFSET_MINUTES, PKEY_BIRTH_UTC_OFFSET_MINUTES,
PKEY_BIRTH_LAT_MICRODEG, PKEY_BIRTH_LAT_MICRODEG,
PKEY_BIRTH_LON_MICRODEG, PKEY_BIRTH_LON_MICRODEG,
PKEY_GENDER,
PKEY_LANG, PKEY_LANG,
PKEY_NOTIFY_ENABLED, PKEY_NOTIFY_ENABLED,
PKEY_NOTIFY_HOUR, 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.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.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->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) { if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
strcpy(out->lang, "en"); 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_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_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_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); 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", 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, cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
(int)(cfg->birth.utc_offset_hours * 60)); (int)(cfg->birth.utc_offset_hours * 60));
APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 lang=%s", 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), cfg->lang); (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", APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute); cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
} }
+4
View File
@@ -4,6 +4,7 @@
#include <pebble.h> #include <pebble.h>
#include "../../../engine/src/astro.h" #include "../../../engine/src/astro.h"
#include "../../../engine/src/tarot.h" /* Gender */
/* Everything the watch needs that isn't computed fresh each day: birth /* Everything the watch needs that isn't computed fresh each day: birth
* data (the same fields as dist/user.properties), display language, and * data (the same fields as dist/user.properties), display language, and
@@ -13,6 +14,9 @@
typedef struct { typedef struct {
bool configured; /* false until the config page has been submitted once */ bool configured; /* false until the config page has been submitted once */
BirthData birth; 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>.lang's own code */ char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
bool notify_enabled; bool notify_enabled;
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e. int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
+2 -2
View File
@@ -25,7 +25,7 @@ static void on_config_updated(const WatchConfig *cfg) {
if (!s_config.configured) return; if (!s_config.configured) return;
config_log(&s_config); /* the values this newly-recomputed reading is based on */ 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) { if (was_configured) {
report_window_reload(); report_window_reload();
} else { } else {
@@ -41,7 +41,7 @@ int main(void) {
config_log(&s_config); /* the values this session's reading (if any) is based on */ config_log(&s_config); /* the values this session's reading (if any) is based on */
if (s_config.configured) { if (s_config.configured) {
reading_state_recompute(&s_config.birth); reading_state_recompute(&s_config.birth, s_config.gender);
report_window_push(); report_window_push();
} else { } else {
show_setup_required(); show_setup_required();
+2 -2
View File
@@ -5,7 +5,7 @@
static DailyReading s_reading; static DailyReading s_reading;
static DailyInterpretation s_interp; 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); time_t now = time(NULL);
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately - /* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
* same justification as astro.c's own time_from_unix(). */ * same justification as astro.c's own time_from_unix(). */
@@ -14,7 +14,7 @@ void reading_state_recompute(const BirthData *birth) {
char seed[16]; char seed[16];
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday); 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); interpret_daily_reading(&s_reading, &s_interp);
} }
+3 -3
View File
@@ -5,12 +5,12 @@
#include "../../../interpreter/src/significance.h" #include "../../../interpreter/src/significance.h"
/* Recomputes today's reading (astro + tarot + interpretation) from /* Recomputes today's reading (astro + tarot + interpretation) from
* `birth`, using the watch's current clock: today's UTC date as the * `birth`/`gender`, using the watch's current clock: today's UTC date as
* tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's * 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 * "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 * to call again later (e.g. the day rolled over while the app stayed
* open) - overwrites the previous result in place. */ * 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. */ /* Valid only after at least one reading_state_recompute() call. */
const DailyReading *reading_state_get_reading(void); const DailyReading *reading_state_get_reading(void);
+109 -21
View File
@@ -31,6 +31,7 @@ static GFont s_font_body;
* Static rather than malloc'd - fixed, known-in-advance sizes, and it * Static rather than malloc'd - fixed, known-in-advance sizes, and it
* keeps report_window_reload() from having to reason about freeing the * keeps report_window_reload() from having to reason about freeing the
* previous run's buffers before reusing them. */ * previous run's buffers before reusing them. */
static char s_reading_date[48];
static char s_day_title[64]; static char s_day_title[64];
static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96]; static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96];
static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX]; static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX];
@@ -53,6 +54,14 @@ typedef struct {
static ImageSlot s_slots[TAROT_SPREAD_SIZE]; static ImageSlot s_slots[TAROT_SPREAD_SIZE];
/* Attitude (0) and Outcome (1) preview slots, drawn at half screen
* width each so the two sit side by side - the BitmapLayer's own bounds
* (narrower than IMAGE_WIDTH on 144px-wide platforms) clip the centered
* image automatically, same as GAlignCenter already does for the
* full-width slots above, no separate scaling logic needed. */
#define REPEAT_SLOT_COUNT 2
static ImageSlot s_repeat_slots[REPEAT_SLOT_COUNT];
#define MAX_TEXT_LAYERS 50 #define MAX_TEXT_LAYERS 50
static TextLayer *s_text_layers[MAX_TEXT_LAYERS]; static TextLayer *s_text_layers[MAX_TEXT_LAYERS];
static int s_text_layer_count; static int s_text_layer_count;
@@ -75,8 +84,8 @@ static const char *reversed_marker(bool reversed) {
return reversed ? i18n_get("ui.reversed", "(Reversed)") : ""; return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
} }
static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) { static int16_t add_text_at(int16_t x, int16_t width, int16_t y, const char *text, GFont font) {
TextLayer *tl = text_layer_create(GRect(0, y, width, 4000)); TextLayer *tl = text_layer_create(GRect(x, y, width, 4000));
text_layer_set_font(tl, font); text_layer_set_font(tl, font);
text_layer_set_text(tl, text); text_layer_set_text(tl, text);
text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap); text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap);
@@ -91,6 +100,10 @@ static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font)
return y + size.h + MARGIN; return y + size.h + MARGIN;
} }
static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
return add_text_at(0, width, y, text, font);
}
static void teardown_content(void) { static void teardown_content(void) {
for (int i = 0; i < s_text_layer_count; i++) { for (int i = 0; i < s_text_layer_count; i++) {
text_layer_destroy(s_text_layers[i]); text_layer_destroy(s_text_layers[i]);
@@ -107,15 +120,20 @@ static void teardown_content(void) {
s_slots[i].bitmap_layer = NULL; s_slots[i].bitmap_layer = NULL;
} }
} }
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
if (s_repeat_slots[i].bitmap) {
gbitmap_destroy(s_repeat_slots[i].bitmap);
s_repeat_slots[i].bitmap = NULL;
}
if (s_repeat_slots[i].bitmap_layer) {
bitmap_layer_destroy(s_repeat_slots[i].bitmap_layer);
s_repeat_slots[i].bitmap_layer = NULL;
}
}
} }
static void update_visible_images(void) { static void update_slot_visibility(ImageSlot *slot, int16_t visible_top, int16_t visible_bottom) {
GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
int16_t visible_top = -offset.y;
int16_t visible_bottom = visible_top + s_screen_height;
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
ImageSlot *slot = &s_slots[i];
bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top; bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top;
if (visible && !slot->bitmap) { if (visible && !slot->bitmap) {
@@ -127,6 +145,18 @@ static void update_visible_images(void) {
slot->bitmap = NULL; slot->bitmap = NULL;
} }
} }
static void update_visible_images(void) {
GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
int16_t visible_top = -offset.y;
int16_t visible_bottom = visible_top + s_screen_height;
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
update_slot_visibility(&s_slots[i], visible_top, visible_bottom);
}
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
update_slot_visibility(&s_repeat_slots[i], visible_top, visible_bottom);
}
} }
static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) { static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) {
@@ -139,25 +169,87 @@ static void build_content(int16_t width) {
int16_t y = MARGIN; int16_t y = MARGIN;
/* The day this reading is for (reading->utc_moment, set by
* reading_generate() - see reading_state_recompute()), not necessarily
* "today" if the report is viewed right at a UTC-midnight boundary
* before the next recompute. Plain gmtime(), same justification as
* reading_state.c's own use of it. */
struct tm *utc = gmtime(&reading->utc_moment);
snprintf(s_reading_date, sizeof s_reading_date, "%s %04d-%02d-%02d",
i18n_get("interp.reading_date", "Reading for:"), utc->tm_year + 1900,
utc->tm_mon + 1, utc->tm_mday);
y = add_text(width, y, s_reading_date, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section); y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section);
snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level), snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT); interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
y = add_text(width, y, s_day_title, s_font_body); y = add_text(width, y, s_day_title, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section); /* Build every transit's title + narrative up front (used both for the
if (interp->top_item_count == 0) { * repeated top-item line below and the full list further down), so
y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body); * neither buffer is computed twice for item 0. */
} else { if (interp->top_item_count > 0) {
for (int i = 0; i < interp->top_item_count; i++) { for (int i = 0; i < interp->top_item_count; i++) {
const Aspect *a = &interp->top_items[i].aspect; const Aspect *a = &interp->top_items[i].aspect;
snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s", snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s",
i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet), i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet),
astro_aspect_name(a->type), i18n_get("ui.natal", "natal"), astro_aspect_name(a->type), i18n_get("ui.natal", "natal"),
astro_body_name(a->natal_planet)); astro_body_name(a->natal_planet));
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
narrative_write(s_narratives[i], sizeof s_narratives[i], "", a, narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
reading->transits.bodies[a->transiting_planet].house); reading->transits.bodies[a->transiting_planet].house);
}
}
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
/* Attitude/Outcome preview, side by side, image only - no per-card
* caption text. A "Position: Card Name (Reversed)" label doesn't fit
* a half-width column without wrapping into 3+ short lines, and with
* both columns wrapping independently the reader ends up reading
* fragments across columns rather than down one column at a time
* (confirmed on-device: it reads as one garbled interleaved sentence,
* not two). Captions are unnecessary anyway - the guidance text
* printed immediately below already names and quotes both cards in
* full ("Deine Haltungskarte ist ..." / "Das wahrscheinliche Ergebnis
* ... ist ..."), and both reappear with their full position/name/
* meaning again in the Celtic Cross walkthrough further down. */
{
int16_t half_w = width / 2;
const TarotDraw *attitude = &reading->spread.positions[POSITION_ATTITUDE];
const TarotDraw *outcome = &reading->spread.positions[POSITION_OUTCOME];
int16_t img_height = card_image_height_for_width(IMAGE_WIDTH);
s_repeat_slots[0].card = attitude->card;
s_repeat_slots[0].y = y;
s_repeat_slots[0].height = img_height;
s_repeat_slots[0].bitmap_layer = bitmap_layer_create(GRect(0, y, half_w, img_height));
bitmap_layer_set_alignment(s_repeat_slots[0].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[0].bitmap_layer));
s_repeat_slots[1].card = outcome->card;
s_repeat_slots[1].y = y;
s_repeat_slots[1].height = img_height;
s_repeat_slots[1].bitmap_layer =
bitmap_layer_create(GRect(half_w, y, width - half_w, img_height));
bitmap_layer_set_alignment(s_repeat_slots[1].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[1].bitmap_layer));
y += img_height + MARGIN;
}
if (interp->top_item_count > 0) {
y = add_text(width, y, s_transit_titles[0], s_font_subhead);
y = add_text(width, y, s_narratives[0], s_font_body);
}
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
y = add_text(width, y, s_guidance, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section);
if (interp->top_item_count == 0) {
y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body);
} else {
for (int i = 0; i < interp->top_item_count; i++) {
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
y = add_text(width, y, s_narratives[i], s_font_body); y = add_text(width, y, s_narratives[i], s_font_body);
} }
} }
@@ -167,7 +259,7 @@ static void build_content(int16_t width) {
const TarotDraw *draw = &reading->spread.positions[i]; const TarotDraw *draw = &reading->spread.positions[i];
snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s", 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)); reversed_marker(draw->reversed));
y = add_text(width, y, s_position_titles[i], s_font_subhead); y = add_text(width, y, s_position_titles[i], s_font_subhead);
@@ -180,14 +272,10 @@ static void build_content(int16_t width) {
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer)); scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer));
y += s_slots[i].height + MARGIN; 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); y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body);
} }
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
y = add_text(width, y, s_guidance, s_font_body);
scroll_layer_set_content_size(s_scroll_layer, GSize(width, y)); scroll_layer_set_content_size(s_scroll_layer, GSize(width, y));
} }
File diff suppressed because one or more lines are too long
+10 -7
View File
@@ -63,15 +63,18 @@ def configure(ctx):
def build(ctx): def build(ctx):
ctx.load('pebble_sdk') ctx.load('pebble_sdk')
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang, and # Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang,
# resources/img/*.png from res/img/*.jpeg, before compiling/bundling # resources/img/*.png from res/img/*.jpeg, and resources/app_icon.png
# anything - see each script's own doc comment. Both run eagerly (not # from res/app_icon_50x50_64_color.png, before compiling/bundling
# as waf Tasks) since the bundle step below depends on their output # anything - see each script's own doc comment. All three run eagerly
# existing; gen_card_images.py skips any card whose PNG is already # (not as waf Tasks) since the bundle step below depends on their
# up to date, so this stays cheap on repeat builds. Requires Pillow # output existing; gen_card_images.py/gen_app_icon.py both skip
# (`pip install Pillow`) - see watch/README.md. # regenerating anything already up to date, so this stays cheap on
# repeat builds. Requires Pillow (`pip install Pillow`) - see
# watch/README.md.
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py']) subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py']) subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
subprocess.check_call([sys.executable, 'scripts/gen_app_icon.py'])
engine_dir = ctx.path.parent.find_dir('engine/src') engine_dir = ctx.path.parent.find_dir('engine/src')
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS] engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]