12 Commits

Author SHA1 Message Date
ml 3bc1263294 Add a standalone Android app with the same daily reading and config page
build / build (push) Successful in 4m8s
android/ links engine/src/ and interpreter/src/ via JNI/CMake (real
astronomy.c, not the watch's size-constrained substitute), rendering
through Jetpack Compose with a burger-menu config page mirroring
WatchConfig and a WorkManager-driven daily notification. Independent
of the watch app - neither requires the other.

build-image now bundles the Android SDK/NDK alongside the Pebble SDK
(VERSION bumped to 2), and .gitea/workflows/build.yml/Makefile/
run-image.sh build and publish the APK the same way as the CLI
tarball and .pbw.
2026-07-18 22:41:51 +02:00
ml a3e437277f Fix watch build to generate resources before the Pebble SDK packs them
build / build (push) Successful in 51s
2026-07-18 08:01:29 +02:00
ml ff2b43f1e0 Add a Docker-based build image and wire it into CI for the watch app
build / build (push) Failing after 0s
2026-07-18 07:52:40 +02:00
ml 4e83ebbf50 Add daily notification wakeups and a make watchapp packaging target
build / build (push) Successful in 20s
Schedules a one-shot Pebble wakeup for the configured notification
time, re-arming it on every launch and config change, and vibrates/
refreshes the report when it fires (foreground or fresh launch).
Also adds a `make watchapp` target that builds the watchapp via the
Pebble SDK and copies a versioned .pbw to dist/, matching `make
package`'s version resolution — deliberately not wired into CI, which
has no Pebble SDK installed.
2026-07-17 20:48:31 +02:00
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
ml b94e76931a Replace the watch app's menu UI with a single scrollable report page, add the config page
build / build (push) Successful in 18s
Report screen (ui_report_window.c, new): replaces the MenuLayer +
per-item drill-down windows (ui_menu_window.c/h, ui_card_window.c/h,
removed) with one continuously scrollable page mirroring the
interpreter's own text/HTML report - day significance, each transit's
narrative, the full Celtic Cross spread, and guidance last. Bigger/
bolder fonts throughout. Card images are a fixed 72px wide: a full-
display-width version was tried first but reliably crashes the real
PNG decoder ("PNG memory allocation failed") on actual hardware,
independent of the app's own heap or lazy-loading - 72px is the
hardware-verified ceiling.

Config page (pkjs/index.js): a self-contained `data:` URI settings form
for birth data/language/notification, submitting via AppMessage to the
existing app_message.c receiver. package.json needed
"capabilities": ["configurable"] for the Pebble mobile app to expose a
Settings entry for the sideloaded app at all - app_message.c's own
receiving logic was already correct. Added config_log() (config.c) to
confirm on-device, via APP_LOG, exactly which values a shown reading is
based on - both right after a config submission and at every app
launch.
2026-07-14 19:03:25 +02:00
ml 1a654da3e9 Introduces watch/, a real Pebble watchapp (built via pebble build) that
build / build (push) Successful in 30s
shows the daily tarot/astrology reading on-device: persistence, reading
computation, and UI screens, driven by the existing engine/interpreter
code linked in unchanged where possible.

Required engine-side changes to make that linking work:
- A compact low-precision ephemeris (lowprec_ephemeris.c) replacing the
  vendored ~127KB Astronomy Engine, which doesn't fit the watch's ~64KB
  app budget.
- Hand-rolled sqrt/atan2/sin/cos replacements for that ephemeris and
  astro.c's Ascendant calculation - Pebble's statically-linked libm
  hard-faults on real hardware under this app's -fPIE link for all four.
- narrative.c/guidance.c refactored from FILE*/fprintf onto snprintf-
  based buffers, since Pebble's SDK blocks fprintf at compile time.
2026-07-14 17:10:22 +02:00
ml d9b6e2889b Statically linking the binaries
build / build (push) Successful in 17s
2026-07-11 21:10:05 +02:00
ml 83248561cc Adding gitea action
build / build (push) Successful in 33s
2026-07-11 20:23:39 +02:00
ml e522b4d6f5 Add a make package target for versioned Linux CLI release tarballs
Bundles deck-engine, interpreter-cli, run-engine.sh/run-interpreter.sh,
img/, i18n/, LICENSE, README.md, and docs/reading.md into
dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz. Version defaults to
the current git tag (vX.Y.Z), falling back to a 0.0.0-dev+<sha>
placeholder when untagged
2026-07-11 20:12:50 +02:00
ml 1662d550c7 Give the interpreter real Celtic Cross meanings and --format/--lang support
- guidance.c now prints last, after the full spread; both it and main.c's
  new Celtic Cross readout pull real Waite card meanings via tarot_data.c
  instead of just naming cards.
- interpreter-cli gains --format text|html|json and --lang en|de, matching
  deck-engine - required linking engine/src/tarot_data.c and i18n.c into
  the interpreter binary (a narrow, documented exception to its earlier
  "no engine .c files" policy) and writing German translations for
  narrative.c's/guidance.c's own text.
2026-07-11 19:39:30 +02:00
115 changed files with 7879 additions and 474 deletions
+99
View File
@@ -0,0 +1,99 @@
name: build
# Build a versioned Linux CLI release tarball (see `make package`), the
# Pebble watchapp (see `make watchapp`), and the Android app (see `make
# android`) on every push/PR, plus on-demand via the Gitea "Run workflow"
# button. Runs inside build-image (see ../../build-image/Dockerfile,
# built/pushed via ../../build-image.sh and ../../upload-image.sh), which
# bundles the Pebble SDK and the Android SDK/NDK alongside the C
# toolchain. Unlike the CLI/Pebble build path, the Android build still
# needs network access at job runtime (Gradle/AGP/androidx dependency
# resolution) - see the root CLAUDE.md's Android section.
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
# Runner defaults `run:` steps to `sh`, which doesn't understand
# `set -o pipefail` used below — force bash explicitly.
defaults:
run:
shell: bash
# Must match a label your act_runner is registered with.
runs-on: ubuntu-latest
container:
image: cr.ladkau.de/deck-in-a-dash/builder:latest
credentials:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
steps:
- uses: actions/checkout@v4
with:
# `make package`/`make watchapp`'s version comes from `git
# describe --tags` - needs full history/tags, not
# actions/checkout's default shallow single-commit clone.
fetch-depth: 0
- name: Preflight
run: |
set -euo pipefail
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
command -v pebble >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: pebble not in PATH" >&2; exit 1; }
command -v java >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: java not in PATH" >&2; exit 1; }
[ -n "${ANDROID_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_HOME not set" >&2; exit 1; }
[ -n "${ANDROID_NDK_HOME:-}" ] || { echo "PREFLIGHT FAIL: ANDROID_NDK_HOME not set" >&2; exit 1; }
- name: Build and test
run: |
set -euo pipefail
make test
make package
make watchapp
make android
- name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works.
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash
path: dist/deck-in-a-dash-*-linux-*.tar.gz
- name: Upload watchapp artifact
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash-watchapp
path: dist/deck-in-a-dash-*.pbw
- name: Upload android artifact
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash-android
path: dist/deck-in-a-dash-*.apk
- name: Publish to dl.ladkau.de
# Uploads the tarball, .pbw, and .apk over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
PBW="$(ls dist/deck-in-a-dash-*.pbw)"
APK="$(ls dist/deck-in-a-dash-*.apk)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF
-mkdir files/deck-in-a-dash
put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL")
put $PBW files/deck-in-a-dash/$(basename "$PBW")
put $APK files/deck-in-a-dash/$(basename "$APK")
EOF
+31
View File
@@ -2,6 +2,33 @@
# data once filled in — never commit it. # data once filled in — never commit it.
/dist /dist
/build /build
/watch/build
/watch/src/c/i18n_tables.auto.c
/watch/resources/img
/watch/resources/app_icon.png
# waf's own build lock file - regenerated every `pebble build`, holds
# absolute machine paths and a full dump of the invoking shell's
# environment variables. Was tracked accidentally; see the commit that
# added this .gitignore line for the git rm --cached that untracked it.
/watch/.lock-waf_linux_build
# Android app build output/local config and generated resources (card
# art, launcher icon, synced i18n files - see android/app/build.gradle.kts's
# generateCardArt/generateLauncherIcon/syncI18n tasks and their own
# source scripts' doc comments).
/android/local.properties
/android/.gradle
/android/build
/android/app/build
/android/app/.cxx
/android/app/src/main/assets/i18n
/android/app/src/main/res/drawable-nodpi
/android/app/src/main/res/mipmap-mdpi
/android/app/src/main/res/mipmap-hdpi
/android/app/src/main/res/mipmap-xhdpi
/android/app/src/main/res/mipmap-xxhdpi
/android/app/src/main/res/mipmap-xxxhdpi
# 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
@@ -13,3 +40,7 @@ core
core.* core.*
.DS_Store .DS_Store
# Container registry credentials for build-image.sh/run-image.sh/
# upload-image.sh — never commit; copy registry.env.example instead.
/registry.env
+346 -69
View File
@@ -5,13 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project goal ## Project goal
A Pebble smartwatch app that produces a daily personalized tarot (Celtic A Pebble smartwatch app that produces a daily personalized tarot (Celtic
Cross) and astrology (natal chart + transits) reading. Currently only the Cross) and astrology (natal chart + transits) reading. This repository
**core engine** exists: a plain-C, dependency-free library plus a holds the **core engine**: a plain-C, dependency-free library plus a
standalone CLI binary (`dist/deck-engine`) that can be built and run standalone CLI binary (`dist/deck-engine`) that can be built and run
without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI, without any Pebble SDK or emulator, deliberately built so it links
resource packs) has not been started yet — the engine is deliberately straight into Pebble C/UI watch code without rework (see "Portability to
built so it can be linked straight into it later without rework (see the watch" below).
"Portability to the watch" below).
## Build & run ## Build & run
@@ -20,6 +19,10 @@ make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties # dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties) make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
make package # builds a versioned Linux CLI tarball at dist/ - see "Packaging" below
make watchapp # builds the Pebble watchapp and copies a versioned .pbw to
# dist/ - see "Packaging" below. Requires the Pebble SDK
# (not needed for anything else above); not run by CI.
``` ```
A single `Makefile` at the repo root builds both `engine/` and A single `Makefile` at the repo root builds both `engine/` and
@@ -64,6 +67,151 @@ Run a single smoke test by editing `engine/tests/smoke_test.c`'s `main()`
temporarily, or just read its assertions — there's no test filter flag, temporarily, or just read its assertions — there's no test filter flag,
the whole suite runs in milliseconds. the whole suite runs in milliseconds.
## Packaging
`make package` (a `Makefile` target, not a separate script) builds a
fresh `all` and bundles it into a versioned, self-contained Linux CLI
tarball at `dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz` -
`deck-engine`, `interpreter-cli`, `run-engine.sh`/`run-interpreter.sh`,
`img/`, `i18n/`, `LICENSE`, `README.md`, `docs/reading.md` (renamed
`READING.md`), and a placeholder `user.properties` (from
`engine/scripts/user.properties.template`, **never**
`dist/user.properties` itself - see below). Extracting the tarball
anywhere and running the scripts/binaries from inside it works exactly
like running them from `dist/` in place, since `run-engine.sh`/
`run-interpreter.sh` already resolve every path relative to their own
location (`SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`), not the
caller's working directory.
**Version**: defaults to the current commit's git tag, matching
`vX.Y.Z` (the `v` is stripped); if `HEAD` isn't exactly on such a tag,
it falls back to a `0.0.0-dev+<short-sha>` placeholder with a warning on
stderr. Override either with `make package VERSION=1.2.3`. Push a
`vX.Y.Z` tag to drive a real release version.
**Never packages `dist/user.properties`**: the staging step copies
files into the package by explicit name only - never a wildcard or
recursive copy of `dist/` itself - specifically so a filled-in
`dist/user.properties` (real birth data, gitignored, private) can never
end up in a distributable package. The package always gets the
placeholder template instead, identical to what a first-time `make`
seeds `dist/user.properties` with.
**CI** (`.gitea/workflows/build.yml`): runs `make test && make package`
on every push/PR (plus manual dispatch). The resulting tarball is both
kept as a Gitea Actions run artifact and, on `push` only (not PRs),
published over SFTP to `dl.ladkau.de` under `files/deck-in-a-dash/` -
requires a `DL_SFTP_KEY` secret configured on this repo (or inherited
from the org/instance level).
### Watch app packaging (`make watchapp`)
A separate target, deliberately **not** a prerequisite of `all` - it
shells out to `pebble build` inside `watch/` (via `watch/wscript`'s own
Pebble SDK/waf tooling, not this Makefile's plain-`cc` rules) and copies
the resulting `watch/build/watch.pbw` to
`dist/deck-in-a-dash-<version>.pbw`, using the exact same version
resolution as `make package` above (current git tag, or `make watchapp
VERSION=1.2.3` to override - see that section, not duplicated here).
Requires the Pebble SDK (`pebble` on `PATH`, plus the Python 3 + Pillow
`watch/wscript` needs for its resource-generation scripts) - a machine
without it can still build/test/package the CLI via every other target
in this file.
`.gitea/workflows/build.yml` **does** run `make watchapp` on every
push/PR, alongside `make test`/`make package` - the whole job runs
inside a container image (`build-image/`, built and pushed with
`build-image.sh`/`upload-image.sh`, see "Build image" below) that has
the Pebble SDK baked in, so the CI runner itself needs no toolchain
beyond Docker access to pull that image. The CLI tarball, the `.pbw`,
and the Android `.apk` (see "Android app packaging" below) are all
uploaded as build artifacts and published to
`dl.ladkau.de/files/deck-in-a-dash/` on push.
### Android app packaging (`make android`)
A separate target, deliberately **not** a prerequisite of `all` - it
shells out to `./gradlew assembleDebug` inside `android/` (via that
directory's own Gradle/AGP/NDK toolchain, not this Makefile's plain-`cc`
rules) and copies the resulting
`android/app/build/outputs/apk/debug/app-debug.apk` to
`dist/deck-in-a-dash-<version>.apk`, using the exact same version
resolution as `make package`/`make watchapp` above (current git tag, or
`make android VERSION=1.2.3` to override). Requires the Android SDK/NDK
(`ANDROID_HOME`/`ANDROID_NDK_HOME` set, plus the Python 3 + Pillow
`android/app/build.gradle.kts`'s `preBuild` task needs for its own
resource-generation scripts - see `android/README.md`) - a machine
without it can still build/test/package the CLI and the watch app via
every other target in this file.
See `android/README.md` for the app's own architecture summary; in
short, it's a Jetpack Compose Material3 app whose native layer links the
same `engine/src/*.c`/`interpreter/src/*.c` sources the watch links (via
JNI/CMake, `android/app/src/main/jni/CMakeLists.txt`) - **the real,
full-precision `engine/third_party/astronomy/astronomy.c`, not the
watch's `lowprec_ephemeris.c` substitute**, since a phone has no
equivalent of Pebble's 64KB code+data+bss budget forcing that
substitution. Like the watch, the interpreter's real `.c` implementations
are linked in directly (`significance.c`/`narrative.c`/`guidance.c`),
not `json.c`/`reading_io.c` - there's no JSON pipe between separate
binaries here either, just one process computing and rendering its own
reading. Unlike the watch (which walks the `DailyReading`/
`DailyInterpretation` structs directly across the same C/UI boundary),
the JNI bridge (`android/app/src/main/jni/reading_json.c`, a structural
port of `interpreter/src/main.c`'s `interp_print_json()`) serializes one
complete reading to a JSON string per call - a single call, not
fine-grained per-field accessors, since the alternative would mean
either re-running real ephemeris trig per field access or pinning a live
native object across calls; Kotlin then decodes that JSON with
`kotlinx.serialization` rather than maintaining its own JNI accessor
surface. Every string in that JSON (titles, narrative, guidance,
position/card names and meanings) is already localized via `i18n_get()`,
so Kotlin never needs its own planet/aspect/sign display-name table -
only the `*_slug` fields exist for the UI's card-art drawable lookup.
Card art is regenerated by `android/scripts/gen_card_images.py`
(structurally a sibling of `watch/scripts/gen_card_images.py`, same slug
table) at 400px wide, lossy WebP - not the watch's 72px PNG, since a
phone has no hardware-decoder budget forcing that size, but 21MB of
source JPEGs would otherwise bloat the APK; config (birth data, gender,
language, notification settings - the same fields as `WatchConfig`) is
persisted via Jetpack Preferences DataStore instead of Pebble's
key-value persist API. The daily notification uses WorkManager (a
self-rescheduling one-shot chain, not `AlarmManager`, since a "roughly
this time daily" reminder doesn't need `AlarmManager`'s exact-alarm
permission burden) - the Android analog of `watch/src/c/notify.c`'s own
daily-wakeup contract.
**CI needs network access for this target specifically** - unlike the
CLI/Pebble build path above (deliberately zero network access needed
once `build-image` is pulled), Gradle/AGP/androidx dependency resolution
against Google's Maven + Maven Central happens at build time, every
build. This is a deliberate, accepted deviation from this image's
otherwise-offline build philosophy, not an oversight.
### Build image
`build-image/Dockerfile` pins the exact toolchain versions (Pebble
Tool, Pebble SDK core, Node.js, and the Android SDK/NDK) needed for
`make test`, `make package`, `make watchapp`, and `make android`, so
builds are reproducible independent of whatever happens to be installed
on a given machine. Three root-level scripts drive it, each reading
registry settings from a gitignored `registry.env` (copy
`registry.env.example` to create it - never commit the real file, since
it holds registry credentials) and the image tag from
`build-image/VERSION`:
- `build-image.sh` - builds and locally tags the image (`:VERSION` and
`:latest`), no push.
- `run-image.sh [VERSION]` - runs `make test package watchapp android`
inside the locally-built image against a live bind-mount of the repo,
wiping generated build artifacts first for CI parity. Use this to
validate a `build-image/Dockerfile` change before pushing the image
anywhere.
- `upload-image.sh` - logs in and pushes the image (`:VERSION` and
`:latest`) to the registry; `.gitea/workflows/build.yml` pulls
`:latest` to run its build job (see "Watch app packaging"/"Android app
packaging" above).
## Architecture ## Architecture
``` ```
@@ -92,19 +240,20 @@ deck_in_a_dash/
tests/smoke_test.c tests/smoke_test.c
scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
interpreter/ Separate module + binary; see "Interpretation" below. interpreter/ Separate module + binary; see "Interpretation" below.
dist/ Build output (gitignored-style; see Build & run). dist/ Build output (gitignored-style; see Build & run) and,
Makefile Single root Makefile, builds both engine/ and interpreter/. after `make package`, versioned release tarballs.
Makefile Single root Makefile, builds both engine/ and
interpreter/, and packages a release (see Packaging).
``` ```
### Data flow / the real API ### Data flow / the real API
`reading_generate(seed, birth, utc_moment, &DailyReading)` in `reading_generate(seed, birth, utc_moment, &DailyReading)` in
`reading.h` is the single entry point that matters — it's what the `reading.h` is the single entry point that matters — the real API
watchapp will call once it exists. It populates a plain struct surface. It populates a plain struct (`NatalChart` + `DailyTransits` +
(`NatalChart` + `DailyTransits` + `CelticCrossSpread`) that the caller `CelticCrossSpread`) that a caller walks directly to build a UI.
walks directly to build a UI. `reading_print_text`/`reading_print_html` `reading_print_text`/`reading_print_html` are desktop-only text/HTML
are desktop-only conveniences for inspecting a reading (used by the CLI); renderers of that same struct, used by the CLI.
the watchapp will never call them.
### Determinism (`rng.c`) ### Determinism (`rng.c`)
@@ -162,6 +311,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
@@ -179,51 +345,67 @@ 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.
`i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and `i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and
`reading_print_*` — it's a desktop-only entry point; `i18n_get` itself `reading_print_*` — it's a desktop-only entry point; `i18n_get` itself
is a pure fixed-size-array lookup with no heap allocation, so it's fine is a pure fixed-size-array lookup with no heap allocation, so it links
to port to the watch once it has its own (non-file-based) way to into the watch as-is given a non-file-based way to populate the catalog,
populate the catalog, e.g. from a compiled-in resource. e.g. from a compiled-in resource.
### Portability to the watch ### Portability to the watch
`rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate` `rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate`
are kept free of `stdio`/CLI assumptions specifically so they can link are kept free of `stdio`/CLI assumptions specifically so they link into
into the Pebble watchapp unchanged later. Only `reading_print_text`/ the Pebble watchapp unchanged. Only `reading_print_text`/`_html` (text
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load` formatting), `main.c` (CLI arg parsing), and `i18n_load` (reads a file
(reads a file from disk) are desktop-only and won't port as-is. from disk) are desktop-only and don't port as-is.
## Interpretation (`interpreter/`) ## Interpretation (`interpreter/`)
A second, separate top-level module and **its own binary** A second, separate top-level module and **its own binary**
(`dist/interpreter-cli`, sibling to `dist/deck-engine`) that scores *how (`dist/interpreter-cli`, sibling to `dist/deck-engine`) that scores *how
significant a day's transits are* and, for each of the top items, prints significant a day's transits are*, prints a short narrative sentence for
a short narrative sentence about what that transit classically means each of the top items about what that transit classically means, reads
the first piece of a larger "turn the raw reading into an actual out the full Celtic Cross spread with every card's meaning, and finally
narrative" effort described in project chat history; the Celtic Cross prints guidance tying the day's top transit to the spread's
spread itself isn't woven into that narrative yet (see Attitude/Outcome cards; see `docs/reading.md`. Like `deck-engine`, it
`docs/reading.md`). `deck-engine` itself is deliberately unchanged by supports `--format text|html|json` and `--lang <code>`/`--i18n-dir
this beyond gaining `--format json`; the two binaries compose over that <path>` for its own output - see `main.c`'s bullets below for both.
JSON, never by linking together: `deck-engine` itself is deliberately unchanged beyond gaining `--format
json`; the two binaries compose over that JSON, never by linking
`reading_generate()` or any I/O function together:
```bash ```bash
make # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli make # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli
make test # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c make test # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c
dist/deck-engine ... --format json | dist/interpreter-cli dist/deck-engine ... --format json | dist/interpreter-cli [--format text|html|json] [--lang <code>]
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json # or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
# or, for daily use (OS clock + user.properties, same as run-engine.sh): # or, for daily use (OS clock + user.properties, same as run-engine.sh):
dist/run-interpreter.sh dist/run-interpreter.sh [text|html|json] [lang]
``` ```
The root `Makefile` builds both from one invocation, but keeps them as Note `--lang` means something different on each side of the pipe:
separate compilation units throughout — no object file is ever shared `deck-engine`'s own `--lang` (if passed at all) is irrelevant here, since
between the two binaries (see the next bullet). `--format json` is deliberately language-independent (see
`docs/input-output-format.md`); `dist/interpreter-cli --lang` controls
*its own* text/html/json output language, independently.
The root `Makefile` builds both from one invocation, and keeps them
independent compilation units for everything ephemeris/tarot-draw/
random-related — the one exception is `engine/src/tarot_data.c` and its
own `i18n.c` dependency (pure, deterministic card/position lookup text,
see `INTERP_TAROT_TEXT_OBJS` in the `Makefile`), whose object files are
built once and linked into *both* binaries.
- `interpret_daily_reading(reading, &out)` (`significance.h/.c`) scores - `interpret_daily_reading(reading, &out)` (`significance.h/.c`) scores
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
@@ -234,17 +416,15 @@ between the two binaries (see the next bullet).
purely so callers can print the level as a rank, e.g. `main.c`'s purely so callers can print the level as a rank, e.g. `main.c`'s
`"%s (%d/%d)"``"Notable (2/4)"``interp.day_level + 1` out of `"%s (%d/%d)"``"Notable (2/4)"``interp.day_level + 1` out of
`DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true `DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true
only at `DAY_MAJOR`the intended hook for giving the Celtic Cross only at `DAY_MAJOR``main.c` uses it solely to decide whether to
reading a "this matters" intro sentence naming `top_items[0]` on days print an extra "worth a deeper Celtic Cross look" line; it's not a
that earn it; that rendering doesn't exist yet, `main.c`'s report is gate on `guidance.c` (below), which runs on every day level.
plain text only.
- Score = `transiting-planet weight (slow/outer planets score higher) × - Score = `transiting-planet weight (slow/outer planets score higher) ×
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
(transits to natal Sun/Moon score higher than to other planets)`. The (transits to natal Sun/Moon score higher than to other planets)`. The
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR` weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
thresholds in `significance.c` are hand-tuned, not derived from thresholds in `significance.c` are hand-tuned, not derived from
anything physical — expect to retune them once real readings are anything physical.
compared against how "major" a day actually feels.
- **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence - **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence
description printed under each top item, condensed from Sepharial's description printed under each top item, condensed from Sepharial's
*Transits and Planetary Periods* (1920, public domain — *Transits and Planetary Periods* (1920, public domain —
@@ -267,7 +447,81 @@ between the two binaries (see the next bullet).
source (same status as the sign/aspect names already in `astro.c`). source (same status as the sign/aspect names already in `astro.c`).
Passing a house outside 1-12 (the same "no data" sentinel `reading_io.c` Passing a house outside 1-12 (the same "no data" sentinel `reading_io.c`
uses elsewhere) omits that framing and prints the planet's narrative on uses elsewhere) omits that framing and prints the planet's narrative on
its own. its own. Every string here (`k_narratives[]`'s `base`/`harmonious`/
`discordant`, `k_house_area[]`, and the "In matters of ..." framing
template itself) is looked up via `i18n_get()` under `narrative.*` keys
before falling back to this English text - `engine/i18n/de.lang` has a
full German translation under the same keys.
- `main.c` calls `i18n_load()` once at startup (same pattern as
`engine/src/main.c`, including the same `default_i18n_path()` helper,
duplicated rather than shared since the two binaries are never linked
- see the compilation-units bullet above) before producing any output,
so every `i18n_get()` call anywhere in the interpreter - not just
`tarot_data.c`'s, but `narrative.c`'s/`guidance.c`'s own (below) -
respects `--lang`. `--format text`'s report is printed in a fixed
order: significance level → top significant transits (each with its
`narrative.c` sentence) → the full Celtic Cross spread
(`print_celtic_cross_text()`, every position in Waite's own drawing
order, each with its card, orientation, position description, and
card meaning — via the real `tarot_position_name()`/`tarot_card_name()`/
`tarot_position_description()`/`tarot_card_meaning()` accessors, not a
duplicated table, since `tarot_data.c` is linked into this binary) →
`guidance.c`'s paragraph, deliberately printed **last**, so it reads
as the closing takeaway after the reader has seen the full spread it
references. `--format html` mirrors `deck-engine`'s own HTML styling
and reuses the same `img/<file>` card-art convention (`tarot_card_
image_file()`); `--format json` embeds the same localized narrative/
guidance prose as `--format text` (in whatever `--lang` was loaded)
*alongside* stable, language-independent slugs (`transiting_planet`,
`aspect`, `card`, `position`, etc., reusing the same small local slug
tables `reading_io.c` needs for parsing) — a deliberate departure from
`reading_print_json`'s "slugs only, never prose" policy (next bullet),
justified because rendering that prose *is* this module's job, unlike
`deck-engine`'s JSON which is purely a machine interchange format.
`narrative_print()`/`guidance_print()` only know how to write to a
`FILE *`, so `--format json` captures their output into a heap string
via POSIX `open_memstream()` (`capture_narrative()`/`capture_guidance()`)
rather than changing either module's public API just for this one
caller.
- **`guidance.c`/`guidance.h`** is the combined-storytelling piece: it
ties `interp->top_items[0]` (the day's single most significant
transit) to the Celtic Cross spread and prints a short "how to meet
the day" paragraph on *every* day — `main.c` calls it unconditionally
after every `interpret_daily_reading()`, with no `day_level` gate. The
core guidance keys off two things: the top transit's aspect character
(harmonious/discordant/neutral, same classification `narrative.c`
uses) crossed with whether the spread's *Attitude* position — Waite's
"Himself: his position or attitude in the circumstances", the
position most directly about how the reader is meeting the day — fell
upright or reversed (3 × 2 = 6 combinations); this text stays valid
regardless of the day's intensity. What *does* vary with
`interp->day_level` is only the sentence introducing the top transit
(`k_intro[DAY_SIGNIFICANCE_COUNT]`, one `%s` each) — e.g. "With Saturn
as today's dominant influence" on `DAY_MAJOR` vs. "Saturn is only
faintly active today, but for what it's worth" on `DAY_QUIET`. A day
with no aspects in orb at all (`top_item_count == 0`, always
`DAY_QUIET`) has no transiting planet to introduce, so it falls back
to `k_no_transit_stance`, keyed on the Attitude card's orientation
alone. All of this guidance text is original, written for this
project, since neither Waite nor Sepharial discuss combining astrology
and tarot. The paragraph also names the Attitude and Outcome cards and
states their actual meaning via `tarot_card_meaning()` (e.g. what
Wheel of Fortune reversed means) — the real Waite text, not a
duplicated table, for the same reason as `print_celtic_cross_text()`
above. Every string here (stance/intro/no-transit/label text, plus a
`guidance.planet.*` table used only for the intro sentence's planet
name - separate from `body.*` because "the Sun"/"the Moon" take a
definite article the other eight planets don't, in both English and
German) is looked up via `i18n_get()` under `guidance.*` keys before
falling back to this English text - `engine/i18n/de.lang` has a full
German translation. The German `guidance.intro.*` templates are
deliberately phrased so the planet name placeholder is always the
sentence's grammatical subject (nominative case) across all four day
levels, sidestepping the case-agreement problems a naive word-for-word
translation of the English templates would hit (e.g. "with Saturn"
needs dative in German, but "Saturn is only faintly active" needs
nominative - the four German templates are worded so the placeholder
never needs to change case).
- **`reading_print_json`** (`engine/src/reading.c`) is the wire format: - **`reading_print_json`** (`engine/src/reading.c`) is the wire format:
the full `DailyReading` (natal, transits, spread), using the the full `DailyReading` (natal, transits, spread), using the
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
@@ -283,31 +537,37 @@ between the two binaries (see the next bullet).
and fills a `DailyReading` with those (used for scoring), plus and fills a `DailyReading` with those (used for scoring), plus
`"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign `"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign
house, via `parse_bodies()` — used only for the sign/house context house, via `parse_bodies()` — used only for the sign/house context
`main.c` prints alongside each significant event, never for scoring). `main.c` prints alongside each significant event, never for scoring)
`"spread"` is left zeroed entirely - nothing reads it. An aspect naming and `"spread"."positions"` (each position's card + reversed flag, via
a planet/aspect-type slug it doesn't recognize, or a document missing `parse_spread()` — used by `main.c`'s full Celtic Cross readout and by
`"transits"."aspects"` entirely (as opposed to a present-but-empty `guidance.c`'s Attitude/Outcome framing, never for scoring). An
array, which is a valid "no aspects today"), makes the whole load fail; aspect naming a planet/aspect-type slug it doesn't recognize, or a
by contrast an unrecognized body slug or missing `house` inside document missing `"transits"."aspects"` entirely (as opposed to a
`"bodies"` is silently skipped (that body's `house` stays `0`, an present-but-empty array, which is a valid "no aspects today"), makes
invalid whole-sign house number used as "no data" - `main.c` checks for the whole load fail; by contrast an unrecognized body/card/position
it before printing sign/house), since that's supplementary display slug, a missing `house` inside `"bodies"`, or a missing `"spread"` key
context rather than something scoring depends on. entirely is silently skipped/zeroed (`main.c` checks for the body
- **Deliberately header-only dependency on the engine, throughout**: "no data" sentinel before printing sign/house), since all of that is
`significance.h`/`reading_io.h`/`narrative.h` `#include` engine headers supplementary display context rather than something scoring depends
(`reading.h`/`astro.h`) for the struct/enum *definitions*, but the root on.
`Makefile` never compiles or links any engine `.c` file (or the - **Mostly header-only dependency on the engine**: `significance.h`/
vendored Astronomy Engine) into the interpreter's binary or tests — `reading_io.h`/`narrative.h`/`guidance.h` `#include` engine headers
every (`reading.h`/`astro.h`/`tarot.h`) for the struct/enum *definitions*,
test in `interpreter/tests/` runs against hand-built JSON text or and the root `Makefile` never compiles or links `astro.c`, `tarot.c`,
hand-built `DailyReading` values, with no real ephemeris/tarot-draw `rng.c`, `reading.c`, or the vendored Astronomy Engine into the
call involved anywhere. Consequently `reading_io.c` (planet/aspect/sign interpreter's binary or tests — every test in `interpreter/tests/`
slugs) and `main.c` (planet/aspect/sign display names) each duplicate a runs against hand-built JSON text or hand-built `DailyReading`/
small local table rather than linking `astro.c` to reuse its own - `DailyInterpretation` values, with no real ephemeris/tarot-draw call
keep those in sync if the engine's slugs/names ever change. involved anywhere. Consequently `reading_io.c` (planet/aspect/sign/
- Only `Aspect`s compete for the top 5 so far (`SignificantItemKind` is card/position slugs) and `main.c` (planet/aspect/sign display names)
currently just `ITEM_ASPECT`); moon phase and house ingresses are each duplicate a small local table rather than linking `astro.c` to
candidate future item kinds but aren't scored yet. reuse its own - keep those in sync if the engine's slugs ever change.
`tarot_data.c`/`i18n.c` are the one exception, actually linked in (see
above) - so card/position *names and meanings* are never duplicated;
only the slugs `reading_io.c` needs for JSON parsing (which
`tarot_data.c` doesn't expose a reverse lookup for) still are.
- Only `Aspect`s compete for the top 5 (`SignificantItemKind` is
currently just `ITEM_ASPECT`).
## Licensing ## Licensing
@@ -317,11 +577,28 @@ between the two binaries (see the next bullet).
`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
entries, which are original (see narrative.c's own comment for why). entries, which are original (see narrative.c's own comment for why).
- Guidance text in `interpreter/src/guidance.c`: original, written for
this project — no source to condense from, since neither Waite nor
Sepharial discuss combining astrology and tarot. (The Attitude/Outcome
card meanings that same paragraph quotes are Waite's own text via
`tarot_card_meaning()`, covered by the `tarot_data.c` bullet above,
not original text.)
- `engine/i18n/de.lang`'s `narrative.*`/`guidance.*` entries are original
German translations of the above two files' text, made for this
project — same status as `de.lang`'s card/spread text (not taken from
a specific published German edition of Sepharial, since none exists
for this condensed, project-specific wording anyway).
+159 -16
View File
@@ -1,5 +1,12 @@
CC ?= cc CC ?= cc
CFLAGS ?= -std=c99 -Wall -Wextra -O2 CFLAGS ?= -std=c99 -Wall -Wextra -O2
# Static by default so dist/deck-engine and dist/interpreter-cli (and the
# `package` tarball built from them) don't depend on the build machine's
# glibc version at runtime - a binary linked against a newer glibc than a
# user's system has refuses to run at all ("GLIBC_2.3x not found"), which
# is exactly the kind of thing a downloaded release package must not hit.
# Override with `LDFLAGS= make` if static libc/libm aren't available.
LDFLAGS ?= -static
LDLIBS := -lm LDLIBS := -lm
BUILD_DIR := build BUILD_DIR := build
@@ -36,41 +43,71 @@ ENGINE_TEST_BINARY := $(BUILD_DIR)/smoke_test
# ---- interpreter ---- # ---- interpreter ----
# Only ever compiles this module's own sources - significance.h/ # Mostly compiles only this module's own sources - significance.h/
# reading_io.h reach into engine/src/ for struct/enum *definitions* # reading_io.h/narrative.h reach into engine/src/ for struct/enum
# (plain #includes), but no engine/src/*.c (or the vendored Astronomy # *definitions* (plain #includes) without linking any engine/src/*.c (or
# Engine) is compiled or linked here. That's what keeps this module (and # the vendored Astronomy Engine). That's what keeps those modules (and
# its tests) independent of the engine's actual ephemeris/tarot-draw # their tests) independent of the engine's actual ephemeris/tarot-draw
# implementation - see significance.c's and reading_io.c's own comments # implementation - see significance.c's and reading_io.c's own comments
# for why each duplicates a small table instead of linking astro.c. # for why each duplicates a small table instead of linking astro.c.
#
# The one deliberate exception is engine/src/tarot_data.c (card/position
# names and Waite meaning text) and its own i18n.c dependency: both are
# pure, deterministic lookup tables with no ephemeris, no randomness, and
# (since the interpreter never calls i18n_load) no file I/O either, so
# linking them doesn't compromise interpreter/tests/'s "no real
# ephemeris/tarot-draw call" independence - it just avoids duplicating
# ~40 short strings of Waite's own card text a second time for
# main.c's/guidance.c's full-spread readout. See CLAUDE.md.
INTERP_SRC_DIR := interpreter/src INTERP_SRC_DIR := interpreter/src
INTERP_TEST_DIR := interpreter/tests INTERP_TEST_DIR := interpreter/tests
INTERP_LIB_SRCS := $(INTERP_SRC_DIR)/significance.c $(INTERP_SRC_DIR)/json.c \ INTERP_LIB_SRCS := $(INTERP_SRC_DIR)/significance.c $(INTERP_SRC_DIR)/json.c \
$(INTERP_SRC_DIR)/reading_io.c $(INTERP_SRC_DIR)/narrative.c $(INTERP_SRC_DIR)/reading_io.c $(INTERP_SRC_DIR)/narrative.c \
$(INTERP_SRC_DIR)/guidance.c
INTERP_LIB_OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(INTERP_LIB_SRCS)) INTERP_LIB_OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(INTERP_LIB_SRCS))
INTERP_MAIN_OBJ := $(OBJ_DIR)/$(INTERP_SRC_DIR)/main.o INTERP_MAIN_OBJ := $(OBJ_DIR)/$(INTERP_SRC_DIR)/main.o
# Shared with the engine build (same source, same flags - see the
# comment above); reused as-is rather than compiled twice.
INTERP_TAROT_TEXT_OBJS := $(OBJ_DIR)/$(ENGINE_SRC_DIR)/tarot_data.o $(OBJ_DIR)/$(ENGINE_SRC_DIR)/i18n.o
INTERP_BINARY := $(DIST_DIR)/interpreter-cli INTERP_BINARY := $(DIST_DIR)/interpreter-cli
SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/significance_test.o SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/significance_test.o
JSON_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/json_test.o JSON_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/json_test.o
NARRATIVE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/narrative_test.o NARRATIVE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/narrative_test.o
GUIDANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/guidance_test.o
SIGNIFICANCE_TEST_BIN := $(BUILD_DIR)/significance_test SIGNIFICANCE_TEST_BIN := $(BUILD_DIR)/significance_test
JSON_TEST_BIN := $(BUILD_DIR)/json_test JSON_TEST_BIN := $(BUILD_DIR)/json_test
NARRATIVE_TEST_BIN := $(BUILD_DIR)/narrative_test NARRATIVE_TEST_BIN := $(BUILD_DIR)/narrative_test
GUIDANCE_TEST_BIN := $(BUILD_DIR)/guidance_test
.PHONY: all test clean images i18n scripts # Linux CLI release tarball - see the `package` target below.
ARCH := $(shell uname -m)
PACKAGE_DIR := $(BUILD_DIR)/package
# ---- watch app (Pebble) ----
WATCH_DIR := watch
WATCH_PBW := $(WATCH_DIR)/build/watch.pbw
# ---- Android app ----
ANDROID_DIR := android
ANDROID_APK := $(ANDROID_DIR)/app/build/outputs/apk/debug/app-debug.apk
.PHONY: all test clean images i18n scripts package watchapp android
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
$(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ) $(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ)
@mkdir -p $(DIST_DIR) @mkdir -p $(DIST_DIR)
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDLIBS)
$(INTERP_BINARY): $(INTERP_LIB_OBJS) $(INTERP_MAIN_OBJ) $(INTERP_BINARY): $(INTERP_LIB_OBJS) $(INTERP_MAIN_OBJ) $(INTERP_TAROT_TEXT_OBJS)
@mkdir -p $(DIST_DIR) @mkdir -p $(DIST_DIR)
$(CC) $(CFLAGS) -o $@ $^ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
# The HTML report expects card art at img/<file> next to deck-engine, so # The HTML report expects card art at img/<file> next to deck-engine, so
# every build refreshes a copy alongside it (cheap: 22 small JPEGs). # every build refreshes a copy alongside it (cheap: 22 small JPEGs).
@@ -109,29 +146,135 @@ scripts:
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \ cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \
fi fi
test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) $(NARRATIVE_TEST_BIN) test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) $(NARRATIVE_TEST_BIN) $(GUIDANCE_TEST_BIN)
./$(ENGINE_TEST_BINARY) ./$(ENGINE_TEST_BINARY)
./$(SIGNIFICANCE_TEST_BIN) ./$(SIGNIFICANCE_TEST_BIN)
./$(JSON_TEST_BIN) ./$(JSON_TEST_BIN)
./$(NARRATIVE_TEST_BIN) ./$(NARRATIVE_TEST_BIN)
./$(GUIDANCE_TEST_BIN)
$(ENGINE_TEST_BINARY): $(ENGINE_OBJS) $(ENGINE_TEST_OBJ) $(ENGINE_TEST_BINARY): $(ENGINE_OBJS) $(ENGINE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDLIBS)
$(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ) $(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(JSON_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/json.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/reading_io.o $(JSON_TEST_OBJ) $(JSON_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/json.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/reading_io.o $(JSON_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(NARRATIVE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/narrative.o $(NARRATIVE_TEST_OBJ) $(NARRATIVE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/narrative.o $(INTERP_TAROT_TEXT_OBJS) $(NARRATIVE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(GUIDANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/guidance.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o \
$(INTERP_TAROT_TEXT_OBJS) $(GUIDANCE_TEST_OBJ)
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
$(OBJ_DIR)/%.o: %.c $(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c -o $@ $< $(CC) $(CFLAGS) -c -o $@ $<
# Builds a versioned Linux CLI release tarball at
# dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz. Version defaults to
# the current git tag (vX.Y.Z, tag prefix stripped) - push a tag to drive
# a release. Override with `make package VERSION=1.2.3`, or just run it
# untagged for a local dev build (gets a 0.0.0-dev+<sha> placeholder
# version, with a warning).
#
# Copies files into the staging dir by explicit name only - never a
# wildcard/recursive copy of dist/ itself - so a real, filled-in
# dist/user.properties (birth data, gitignored, private) can never
# accidentally end up in a package. The package gets the placeholder
# template instead, same as a first-time `make` produces.
package: all
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
PKG_NAME="deck-in-a-dash-$$V-linux-$(ARCH)"; \
PKG_STAGE="$(PACKAGE_DIR)/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
mkdir -p "$$PKG_STAGE"; \
cp $(ENGINE_BINARY) $(INTERP_BINARY) "$$PKG_STAGE/"; \
cp $(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh "$$PKG_STAGE/"; \
cp -r $(DIST_IMG_DIR) $(DIST_I18N_DIR) "$$PKG_STAGE/"; \
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template "$$PKG_STAGE/user.properties"; \
cp LICENSE README.md "$$PKG_STAGE/"; \
cp docs/reading.md "$$PKG_STAGE/READING.md"; \
tar -czf "$(DIST_DIR)/$$PKG_NAME.tar.gz" -C "$(PACKAGE_DIR)" "$$PKG_NAME"; \
rm -rf "$(PACKAGE_DIR)"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
clean: clean:
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \ rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh $(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
# user.properties holds the user's own data - never removed by clean. # user.properties holds the user's own data - never removed by clean.
# Builds the Pebble watchapp (`pebble build` in watch/, via its own
# wscript/pebble_sdk waf tooling - not a plain C toolchain, so this is a
# separate target from `all` rather than one of its prerequisites) and
# copies the resulting .pbw into dist/, versioned the same way `package`
# above versions the CLI tarball (current git tag, or
# `make watchapp VERSION=1.2.3` to override). Requires the Pebble SDK
# (`pebble` on PATH, plus Python 3 + Pillow for watch/scripts/*.py - see
# watch/wscript) - this is NOT checked by the root `make`/`make test`,
# so a machine without the Pebble SDK can still build/test the CLI.
watchapp:
cd $(WATCH_DIR) && pebble build
@mkdir -p $(DIST_DIR)
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
PBW_NAME="deck-in-a-dash-$$V.pbw"; \
cp $(WATCH_PBW) "$(DIST_DIR)/$$PBW_NAME"; \
echo "Wrote $(DIST_DIR)/$$PBW_NAME"
# Builds the Android app (`./gradlew assembleDebug` in android/, via its
# own Gradle/AGP/NDK toolchain - not a plain C toolchain, so this is a
# separate target from `all` rather than one of its prerequisites) and
# copies the resulting debug APK into dist/, versioned the same way
# `package`/`watchapp` above version their own artifacts (current git
# tag, or `make android VERSION=1.2.3` to override). Ships the
# auto-generated debug keystore, not a release signing config - see the
# root CLAUDE.md's Android section for why. Requires the Android SDK/NDK
# (ANDROID_HOME/ANDROID_NDK_HOME set, see android/README.md) - this is
# NOT checked by the root `make`/`make test`, so a machine without them
# can still build/test the CLI (and, separately, the watch app).
android:
cd $(ANDROID_DIR) && ./gradlew assembleDebug
@mkdir -p $(DIST_DIR)
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
APK_NAME="deck-in-a-dash-$$V.apk"; \
cp $(ANDROID_APK) "$(DIST_DIR)/$$APK_NAME"; \
echo "Wrote $(DIST_DIR)/$$APK_NAME"
+90 -16
View File
@@ -7,11 +7,10 @@
A Pebble smartwatch app that produces a daily personalized tarot (Celtic A Pebble smartwatch app that produces a daily personalized tarot (Celtic
Cross) and astrology (natal chart + transits) reading. Cross) and astrology (natal chart + transits) reading.
**Status:** only the core engine exists so far — a plain-C, dependency-free This repository holds the core engine: a plain-C, dependency-free
library and standalone CLI (`dist/deck-engine`) that computes a full library and standalone CLI (`dist/deck-engine`) that computes a full
reading without needing a Pebble device, emulator, or SDK. The watchapp reading without needing a Pebble device, emulator, or SDK, designed so
itself hasn't been built yet; the engine is designed so it can be linked it links straight into Pebble watch code without rework.
into it later without rework.
## What it computes ## What it computes
@@ -45,12 +44,36 @@ engine/
the user.properties template. the user.properties template.
dist/ Build output (gitignored) — see below. dist/ Build output (gitignored) — see below.
interpreter/ Separate module + binary (dist/interpreter-cli): interpreter/ Separate module + binary (dist/interpreter-cli):
scores how significant a day's transits are scores how significant a day's transits are,
and prints a short narrative note for each, reads out the full Celtic Cross spread with
reading deck-engine's --format json output every card's meaning, and closes with guidance
(see CLAUDE.md). (every day, tailored to how significant it is)
tying the top transit to the spread, reading
deck-engine's --format json output. Supports
--format text|html|json and --lang en|de for
its own output too (see CLAUDE.md).
watch/ The actual Pebble watchapp - links engine/src/
and interpreter/src/ in directly (see CLAUDE.md's
"Portability to the watch"), plus its own
persistence, reading recompute, UI, daily
notification, and config page. Built separately
from the rest of this repo (`make watchapp`,
below) - requires the Pebble SDK.
android/ A standalone Android app (independent of the
watch app - neither requires the other) with the
same daily reading + config page, Jetpack Compose
UI, links engine/src/ and interpreter/src/ via
JNI/CMake (see CLAUDE.md's "Android app
packaging"). Built separately (`make android`,
below) - requires the Android SDK/NDK.
docs/ Architecture/dataflow diagrams, input/output format reference. docs/ Architecture/dataflow diagrams, input/output format reference.
Makefile Single root Makefile, builds both engine/ and interpreter/. build-image/ Dockerfile for the reproducible build environment
(C toolchain + Pebble SDK + Android SDK/NDK) used
by CI and by the build-image.sh/run-image.sh/
upload-image.sh scripts at the repo root (see
"Building" below).
Makefile Single root Makefile, builds both engine/ and
interpreter/, and packages a release (`make package`).
``` ```
See [`CLAUDE.md`](CLAUDE.md) for the detailed architecture and the sharp See [`CLAUDE.md`](CLAUDE.md) for the detailed architecture and the sharp
@@ -66,10 +89,47 @@ struct format reference
make # -> dist/deck-engine, dist/interpreter-cli, dist/img/, dist/i18n/, make # -> dist/deck-engine, dist/interpreter-cli, dist/img/, dist/i18n/,
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties # dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make package # -> dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz, a
# self-contained release bundle (binaries, scripts, img/, i18n/,
# LICENSE, docs) - version from the current git tag, or
# VERSION=1.2.3 make package to override
``` ```
Requires only a C99 compiler and `make` — no other dependencies. Requires only a C99 compiler and `make` — no other dependencies.
**Building the watch app itself** (`watch/`, the actual Pebble app)
needs the separate Pebble SDK (`pebble` on `PATH`), so it's a separate
target, not part of `make`/`make test`/`make package` above:
```bash
make watchapp # -> dist/deck-in-a-dash-<version>.pbw, same version
# resolution as `make package`
```
**Building the Android app** (`android/`, a standalone app independent
of the watch app) needs the separate Android SDK/NDK
(`ANDROID_HOME`/`ANDROID_NDK_HOME` set - see `android/README.md`), so
it's likewise a separate target:
```bash
make android # -> dist/deck-in-a-dash-<version>.apk, same version
# resolution as `make package`
```
**CI runs `make watchapp` too**`.gitea/workflows/build.yml` builds
and tests everything inside a container built from `build-image/`,
which bundles the Pebble SDK alongside the C toolchain so the runner
itself doesn't need either installed. To build/test that image
yourself, copy `registry.env.example` to `registry.env` and fill in
your registry credentials, then:
```bash
./build-image.sh # build and locally tag the image
./run-image.sh # run `make test package watchapp` inside it, against
# this checkout, for a CI-equivalent local build
./upload-image.sh # push the image so CI can pull it
```
## Running ## Running
**Daily use**, via `dist/run-engine.sh`: pulls today's date and the **Daily use**, via `dist/run-engine.sh`: pulls today's date and the
@@ -94,16 +154,23 @@ dist/deck-engine --seed "2026-07-03" \
``` ```
**Interpreter** (`interpreter/`, built by the same root `make`): **Interpreter** (`interpreter/`, built by the same root `make`):
scores how significant a day's transits are, reading `deck-engine`'s scores how significant a day's transits are, reads out the full Celtic
`--format json` output — the two binaries compose over that JSON, they're Cross spread with every card's meaning, and closes with guidance tying
never linked together. `dist/run-interpreter.sh` is the daily-use the day's biggest transit to the Attitude/Outcome cards - reading
equivalent of `run-engine.sh` (same OS clock + `user.properties` inputs), `deck-engine`'s `--format json` output. Its own output supports the same
piping straight into `interpreter-cli`: `--format text|html|json` and `--lang en|de` as `deck-engine` (note this
`--lang` is independent of whatever language `deck-engine` was run
with - `--format json` is always language-independent, see
`docs/input-output-format.md`). `dist/run-interpreter.sh` is the
daily-use equivalent of `run-engine.sh` (same OS clock + `user.properties`
inputs, including `user.properties`' `lang=`), piping straight into
`interpreter-cli`:
```bash ```bash
dist/deck-engine ... --format json | dist/interpreter-cli dist/deck-engine ... --format json | dist/interpreter-cli [--format text|html|json] [--lang en|de]
# or, for daily use: # or, for daily use:
dist/run-interpreter.sh dist/run-interpreter.sh # text output, language from user.properties
dist/run-interpreter.sh html de # HTML report with card art, override the language
``` ```
## License ## License
@@ -124,3 +191,10 @@ Transit narrative text in `interpreter/src/narrative.c` is condensed
from Sepharial's *Transits and Planetary Periods* (1920), also in the from Sepharial's *Transits and Planetary Periods* (1920), also in the
public domain, except the Moon and Pluto entries, which are original public domain, except the Moon and Pluto entries, which are original
(see the file's own comment). (see the file's own comment).
Guidance text in `interpreter/src/guidance.c` is original, written for
this project.
`engine/i18n/de.lang`'s translations of the above two files' text
(`narrative.*`/`guidance.*` keys) are original German translations made
for this project, same status as its card/spread text.
+43
View File
@@ -0,0 +1,43 @@
# Deck in a Dash — Android app
A standalone Android app showing the same daily tarot (Celtic Cross) +
astrology reading as the Pebble watch app (`../watch/`), with a burger
menu leading to a config page. Independent of the watch app - neither
requires the other to be installed. See the root
[`CLAUDE.md`](../CLAUDE.md)'s "Android app" section for the full
architecture (JNI-linked engine/interpreter, JSON bridge schema,
WorkManager notification, card art pipeline).
## Building
Requires:
- A JDK (17+; the project's own `compileOptions`/toolchain target Java
17). `JAVA_HOME` should point at it, or a `java` matching that version
must be first on `PATH`.
- The Android SDK, with `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) set -
needs at minimum `platforms;android-36`, `build-tools;36.0.0`,
`ndk;30.0.14904198`, and `cmake;3.22.1` installed (see
`app/build.gradle.kts`'s `compileSdk`/`ndkVersion` and
`app/src/main/jni/CMakeLists.txt`'s `cmake_minimum_required` for the
exact versions this project pins - `../build-image/Dockerfile`
installs the same ones for CI).
- `ANDROID_NDK_HOME` set to that installed NDK's directory.
- Python 3 + Pillow (`pip install Pillow`) - needed by
`scripts/gen_card_images.py`/`gen_launcher_icon.py`, which Gradle's
`preBuild` task runs automatically (see `app/build.gradle.kts`).
```bash
./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
```
Or, from the repo root: `make android`, which wraps the above and
copies the APK into `dist/deck-in-a-dash-<version>.pbw`'s sibling,
`dist/deck-in-a-dash-<version>.apk` (same version resolution as `make
package`/`make watchapp` - see the root `Makefile`).
The Gradle wrapper (`./gradlew`) resolves its own pinned Gradle version
on first run - no separate Gradle install is needed.
Ships the Android auto-generated debug keystore, not a release-signed
build - see the root `CLAUDE.md`'s Android section for why.
+92
View File
@@ -0,0 +1,92 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "de.ladkau.deckinadash"
compileSdk = 36
ndkVersion = "30.0.14904198"
defaultConfig {
applicationId = "de.ladkau.deckinadash"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "0.0.0-dev"
ndk {
abiFilters += listOf("arm64-v8a", "x86_64")
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
externalNativeBuild {
cmake {
path = file("src/main/jni/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
// Regenerates app/src/main/res/drawable-nodpi/<slug>.webp from
// ../../res/img/*.jpeg (see ../../scripts/gen_card_images.py) and syncs
// engine/i18n/*.lang into app/src/main/assets/i18n/ - both are gitignored
// generated output, so a fresh checkout needs them produced before any
// resource-merge/asset-packaging task runs. Mirrors watch/wscript's own
// "generate before the SDK tooling packs resources" fix - see that
// file's own comment for the ordering bug this exact pattern avoids.
val generateCardArt = tasks.register<Exec>("generateCardArt") {
workingDir = rootDir
commandLine("python3", "scripts/gen_card_images.py")
}
val generateLauncherIcon = tasks.register<Exec>("generateLauncherIcon") {
workingDir = rootDir
commandLine("python3", "scripts/gen_launcher_icon.py")
}
val syncI18n = tasks.register<Copy>("syncI18n") {
from("${rootDir}/../engine/i18n")
include("*.lang")
into("src/main/assets/i18n")
}
tasks.named("preBuild") {
dependsOn(generateCardArt, generateLauncherIcon, syncI18n)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
debugImplementation(libs.androidx.ui.tooling)
}
+9
View File
@@ -0,0 +1,9 @@
# kotlinx.serialization needs its generated serializer classes kept.
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclasseswithmembers class de.ladkau.deckinadash.data.** {
*** Companion;
}
-keepclasseswithmembers class de.ladkau.deckinadash.data.** {
kotlinx.serialization.KSerializer serializer(...);
}
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".DeckInADashApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.DeckInADash">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.DeckInADash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,43 @@
package de.ladkau.deckinadash
import android.app.Application
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.notify.NotificationScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import java.io.File
/**
* Copies assets/i18n's .lang files (synced at build time from engine/i18n/ -
* see app/build.gradle.kts's syncI18n task) into filesDir/i18n/ once
* per process start, since i18n_load() (linked into the native library
* unchanged, see reading_json.c) needs real filesystem paths, not APK
* assets. Also reschedules the daily notification on launch - one of
* the two required call sites for NotificationScheduler.reschedule(),
* see its own doc comment for the other (every config save).
*/
class DeckInADashApplication : Application() {
override fun onCreate() {
super.onCreate()
syncI18nAssets()
CoroutineScope(Dispatchers.Default).launch {
val config = ConfigRepository(this@DeckInADashApplication).config.first()
if (config.configured) {
NotificationScheduler.reschedule(this@DeckInADashApplication, config)
}
}
}
private fun syncI18nAssets() {
val outDir = File(filesDir, "i18n").apply { mkdirs() }
val files = assets.list("i18n") ?: return
for (name in files) {
assets.open("i18n/$name").use { input ->
File(outDir, name).outputStream().use { output -> input.copyTo(output) }
}
}
}
}
@@ -0,0 +1,20 @@
package de.ladkau.deckinadash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import de.ladkau.deckinadash.ui.DeckInADashApp
import de.ladkau.deckinadash.ui.theme.DeckInADashTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DeckInADashTheme {
DeckInADashApp()
}
}
}
}
@@ -0,0 +1,75 @@
package de.ladkau.deckinadash.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(name = "deck_config")
/** Preferences DataStore-backed persistence for [DeckConfig] - see that
* class's own doc comment for why DataStore over SharedPreferences/Proto
* DataStore. */
class ConfigRepository(private val context: Context) {
private object Keys {
val CONFIGURED = booleanPreferencesKey("configured")
val BIRTH_YEAR = intPreferencesKey("birth_year")
val BIRTH_MONTH = intPreferencesKey("birth_month")
val BIRTH_DAY = intPreferencesKey("birth_day")
val BIRTH_HOUR = intPreferencesKey("birth_hour")
val BIRTH_MINUTE = intPreferencesKey("birth_minute")
val UTC_OFFSET_HOURS = doublePreferencesKey("utc_offset_hours")
val LATITUDE = doublePreferencesKey("latitude")
val LONGITUDE = doublePreferencesKey("longitude")
val GENDER = stringPreferencesKey("gender")
val LANG = stringPreferencesKey("lang")
val NOTIFY_ENABLED = booleanPreferencesKey("notify_enabled")
val NOTIFY_HOUR = intPreferencesKey("notify_hour")
val NOTIFY_MINUTE = intPreferencesKey("notify_minute")
}
val config: Flow<DeckConfig> = context.configDataStore.data.map { prefs ->
DeckConfig(
configured = prefs[Keys.CONFIGURED] ?: false,
birthYear = prefs[Keys.BIRTH_YEAR] ?: 0,
birthMonth = prefs[Keys.BIRTH_MONTH] ?: 0,
birthDay = prefs[Keys.BIRTH_DAY] ?: 0,
birthHour = prefs[Keys.BIRTH_HOUR] ?: 0,
birthMinute = prefs[Keys.BIRTH_MINUTE] ?: 0,
utcOffsetHours = prefs[Keys.UTC_OFFSET_HOURS] ?: 0.0,
latitude = prefs[Keys.LATITUDE] ?: 0.0,
longitude = prefs[Keys.LONGITUDE] ?: 0.0,
gender = prefs[Keys.GENDER]?.let { Gender.valueOf(it) } ?: Gender.UNSPECIFIED,
lang = prefs[Keys.LANG] ?: "en",
notifyEnabled = prefs[Keys.NOTIFY_ENABLED] ?: false,
notifyHour = prefs[Keys.NOTIFY_HOUR] ?: 8,
notifyMinute = prefs[Keys.NOTIFY_MINUTE] ?: 0,
)
}
suspend fun save(config: DeckConfig) {
context.configDataStore.edit { prefs ->
prefs[Keys.CONFIGURED] = true
prefs[Keys.BIRTH_YEAR] = config.birthYear
prefs[Keys.BIRTH_MONTH] = config.birthMonth
prefs[Keys.BIRTH_DAY] = config.birthDay
prefs[Keys.BIRTH_HOUR] = config.birthHour
prefs[Keys.BIRTH_MINUTE] = config.birthMinute
prefs[Keys.UTC_OFFSET_HOURS] = config.utcOffsetHours
prefs[Keys.LATITUDE] = config.latitude
prefs[Keys.LONGITUDE] = config.longitude
prefs[Keys.GENDER] = config.gender.name
prefs[Keys.LANG] = config.lang
prefs[Keys.NOTIFY_ENABLED] = config.notifyEnabled
prefs[Keys.NOTIFY_HOUR] = config.notifyHour
prefs[Keys.NOTIFY_MINUTE] = config.notifyMinute
}
}
}
@@ -0,0 +1,32 @@
package de.ladkau.deckinadash.data
/** Mirrors engine/src/tarot.h's Gender enum - ordinal must match. */
enum class Gender {
UNSPECIFIED,
MALE,
FEMALE,
}
/**
* Everything the app needs that isn't computed fresh each day: birth
* data, display language, and daily notification settings. Mirrors
* watch/src/c/config.h's WatchConfig field-for-field - same data, same
* defaults, persisted here via Preferences DataStore (see
* ConfigRepository) instead of Pebble's key-value persist API.
*/
data class DeckConfig(
val configured: Boolean = false,
val birthYear: Int = 0,
val birthMonth: Int = 0,
val birthDay: Int = 0,
val birthHour: Int = 0,
val birthMinute: Int = 0,
val utcOffsetHours: Double = 0.0,
val latitude: Double = 0.0,
val longitude: Double = 0.0,
val gender: Gender = Gender.UNSPECIFIED,
val lang: String = "en",
val notifyEnabled: Boolean = false,
val notifyHour: Int = 8,
val notifyMinute: Int = 0,
)
@@ -0,0 +1,51 @@
package de.ladkau.deckinadash.data
import kotlinx.serialization.Serializable
/**
* Mirrors the JSON schema app/src/main/jni/reading_json.c emits - see
* that file's own header comment. Deserialized with a snake_case naming
* strategy (see ReadingRepository's Json instance), so these property
* names stay idiomatic Kotlin camelCase without needing @SerialName on
* every field.
*/
@Serializable
data class ReadingDto(
val date: String,
val gender: String,
val daySignificance: DaySignificanceDto,
val transits: List<TransitDto>,
val guidance: String,
val spread: List<SpreadPositionDto>,
)
@Serializable
data class DaySignificanceDto(
val levelSlug: String,
val levelLabel: String,
val rank: Int,
val count: Int,
val deservesFraming: Boolean,
)
@Serializable
data class TransitDto(
val title: String,
val transitingPlanetSlug: String,
val natalPlanetSlug: String,
val aspectSlug: String,
val orb: Double,
val score: Double,
val narrative: String,
)
@Serializable
data class SpreadPositionDto(
val positionSlug: String,
val positionName: String,
val positionDescription: String,
val cardSlug: String,
val cardName: String,
val reversed: Boolean,
val cardMeaning: String,
)
@@ -0,0 +1,44 @@
package de.ladkau.deckinadash.data
import android.content.Context
import de.ladkau.deckinadash.nativebridge.DeckNative
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
/**
* Computes a [ReadingDto] via [DeckNative], the single entry point both
* the reading screen (ReadingViewModel) and the daily notification
* (ReadingNotificationWorker) call - see NotificationScheduler's own
* comment on why the notification must reuse this rather than
* duplicating reading logic.
*/
class ReadingRepository(private val context: Context) {
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
namingStrategy = JsonNamingStrategy.SnakeCase
}
/** Directory DeckInADashApplication.onCreate() synced engine/i18n's .lang files into. */
private val i18nDir: String
get() = "${context.filesDir}/i18n"
fun compute(config: DeckConfig, utcMomentEpochSeconds: Long): ReadingDto {
val jsonText = DeckNative.generateReadingJson(
birthYear = config.birthYear,
birthMonth = config.birthMonth,
birthDay = config.birthDay,
birthHour = config.birthHour,
birthMinute = config.birthMinute,
utcOffsetHours = config.utcOffsetHours,
latitude = config.latitude,
longitude = config.longitude,
gender = config.gender.ordinal,
utcMomentEpochSeconds = utcMomentEpochSeconds,
i18nDir = i18nDir,
lang = config.lang,
)
return json.decodeFromString(ReadingDto.serializer(), jsonText)
}
}
@@ -0,0 +1,37 @@
package de.ladkau.deckinadash.nativebridge
/**
* JNI bridge to the native `deckengine` shared library (built from
* app/src/main/jni/CMakeLists.txt, which links the same engine +
* interpreter C sources the watch app links - see that CMakeLists.txt's
* own comment). One call returns one complete JSON snapshot of a day's
* reading; see app/src/main/jni/reading_json.h for the exact schema and
* why this is a single call rather than many fine-grained accessors.
*/
object DeckNative {
init {
System.loadLibrary("deckengine")
}
/**
* @param gender 0 = unspecified, 1 = male, 2 = female - matches
* engine/src/tarot.h's Gender enum order.
* @param i18nDir directory containing `<lang>.lang` files (see
* engine/i18n/), synced from assets by DeckInADashApp.onCreate().
* @throws IllegalStateException if native JSON generation fails.
*/
external fun generateReadingJson(
birthYear: Int,
birthMonth: Int,
birthDay: Int,
birthHour: Int,
birthMinute: Int,
utcOffsetHours: Double,
latitude: Double,
longitude: Double,
gender: Int,
utcMomentEpochSeconds: Long,
i18nDir: String,
lang: String,
): String
}
@@ -0,0 +1,53 @@
package de.ladkau.deckinadash.notify
import android.content.Context
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import de.ladkau.deckinadash.data.DeckConfig
import java.util.Calendar
import java.util.concurrent.TimeUnit
/**
* WorkManager analog of watch/src/c/notify.c's notify_reschedule(): (re)schedules
* a one-shot wakeup for the next occurrence of `config.notifyHour:notifyMinute`
* in the device's local time (today if not yet passed, tomorrow otherwise),
* or only cancels if notifications are disabled. See ReadingNotificationWorker
* for the self-rescheduling chain this kicks off.
*
* Call once per app launch (DeckInADashApplication.onCreate()) and on every
* config save (ConfigViewModel.save()) - same two call sites notify_reschedule()
* itself requires, for the same reason (settings may have changed).
*/
object NotificationScheduler {
private const val WORK_NAME = "daily_reading_notification"
fun reschedule(context: Context, config: DeckConfig) {
val workManager = WorkManager.getInstance(context)
if (!config.notifyEnabled) {
workManager.cancelUniqueWork(WORK_NAME)
return
}
val delayMillis = millisUntilNext(config.notifyHour, config.notifyMinute)
val request = OneTimeWorkRequestBuilder<ReadingNotificationWorker>()
.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
.build()
// REPLACE is the WorkManager analog of notify_reschedule()'s own
// cancel-then-reschedule - guarantees no duplicate chains.
workManager.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
private fun millisUntilNext(hour: Int, minute: Int): Long {
val now = Calendar.getInstance()
val target = (now.clone() as Calendar).apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (!target.after(now)) {
target.add(Calendar.DAY_OF_YEAR, 1)
}
return target.timeInMillis - now.timeInMillis
}
}
@@ -0,0 +1,116 @@
package de.ladkau.deckinadash.notify
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import de.ladkau.deckinadash.MainActivity
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.ReadingRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.time.Instant
private const val CHANNEL_ID = "daily_reading"
private const val NOTIFICATION_ID = 1
/**
* Fires once (scheduled by NotificationScheduler.reschedule()), computes
* today's reading via the same ReadingRepository the UI uses (no
* duplicated reading logic - see that class's own comment), posts a
* "quick digest" notification from the day's significance + top
* transit (the same lead-in watch/src/c/ui_report_window.c's own
* comment describes the watch's wakeup-vibrate leading into), then
* reschedules itself for tomorrow - WorkManager has no "repeat forever
* at an exact wall-clock time" primitive, so this self-chaining step is
* how notify_reschedule()'s own daily-repeat contract is reproduced.
*/
class ReadingNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val configRepository = ConfigRepository(applicationContext)
val config = configRepository.config.first()
// Mirrors notify_reschedule()'s own "if disabled, only cancel" branch -
// a defensive no-op, since reschedule() should already have cancelled
// this work item when notifications were turned off.
if (!config.notifyEnabled || !config.configured) {
return Result.success()
}
val reading = try {
withContext(Dispatchers.Default) {
ReadingRepository(applicationContext).compute(config, Instant.now().epochSecond)
}
} catch (t: Throwable) {
NotificationScheduler.reschedule(applicationContext, config)
return Result.failure()
}
postNotification(reading)
NotificationScheduler.reschedule(applicationContext, config)
return Result.success()
}
private fun postNotification(reading: ReadingDto) {
ensureChannel()
val body = reading.transits.firstOrNull()?.let { "${it.title}. ${it.narrative}" }
?: reading.guidance
val openAppIntent = PendingIntent.getActivity(
applicationContext,
0,
Intent(applicationContext, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
applicationContext.getString(R.string.notification_title, reading.daySignificance.levelLabel),
)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.build()
if (ActivityCompat.checkSelfPermission(
applicationContext,
Manifest.permission.POST_NOTIFICATIONS,
) != PackageManager.PERMISSION_GRANTED
) {
return
}
NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification)
}
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = applicationContext.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CHANNEL_ID,
applicationContext.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = applicationContext.getString(R.string.notification_channel_description)
}
manager.createNotificationChannel(channel)
}
}
@@ -0,0 +1,20 @@
package de.ladkau.deckinadash.ui
import android.content.Context
import androidx.annotation.DrawableRes
/**
* Resolves a card slug (e.g. "fool", "wheel_of_fortune" - the same
* strings engine/src/tarot_data.c's tarot_card_slug() returns) to its
* generated drawable resource, produced by ../../scripts/gen_card_images.py
* into res/drawable-nodpi/<slug>.webp at build time - see that script's
* own comment. Slugs are already valid Android resource-name identifiers,
* so no separate mapping table is needed, only a runtime resource lookup
* (the resource ID isn't known at compile time from this module, since
* the drawables are generated, not committed).
*/
@DrawableRes
fun cardDrawableRes(context: Context, cardSlug: String): Int {
val id = context.resources.getIdentifier(cardSlug, "drawable", context.packageName)
return if (id != 0) id else android.R.drawable.ic_menu_report_image
}
@@ -0,0 +1,107 @@
package de.ladkau.deckinadash.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.ui.about.AboutScreen
import de.ladkau.deckinadash.ui.config.ConfigScreen
import de.ladkau.deckinadash.ui.reading.ReadingScreen
import kotlinx.coroutines.launch
private object Destinations {
const val READING = "reading"
const val SETTINGS = "settings"
const val ABOUT = "about"
}
/** Burger menu = ModalNavigationDrawer, leading to Reading/Settings/About -
* the app's single navigation host, per the plan's UI decision. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeckInADashApp() {
val navController = rememberNavController()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_reading)) },
selected = currentRoute == Destinations.READING,
onClick = { navigateTo(navController, Destinations.READING); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_settings)) },
selected = currentRoute == Destinations.SETTINGS,
onClick = { navigateTo(navController, Destinations.SETTINGS); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text(stringResource(R.string.nav_about)) },
selected = currentRoute == Destinations.ABOUT,
onClick = { navigateTo(navController, Destinations.ABOUT); scope.launch { drawerState.close() } },
modifier = Modifier.padding(horizontal = 12.dp),
)
}
},
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.nav_settings))
}
},
)
},
) { innerPadding ->
NavHost(
navController = navController,
startDestination = Destinations.READING,
modifier = Modifier.padding(innerPadding),
) {
composable(Destinations.READING) {
ReadingScreen(onNavigateToSettings = { navigateTo(navController, Destinations.SETTINGS) })
}
composable(Destinations.SETTINGS) { ConfigScreen() }
composable(Destinations.ABOUT) { AboutScreen() }
}
}
}
}
private fun navigateTo(navController: NavHostController, route: String) {
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
@@ -0,0 +1,26 @@
package de.ladkau.deckinadash.ui.about
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.ladkau.deckinadash.R
/** Static Waite/Sepharial public-domain attribution, carried forward
* from the root CLAUDE.md's own "Licensing" section, since this app's
* reading screen displays that same card/narrative text verbatim. */
@Composable
fun AboutScreen(modifier: Modifier = Modifier) {
Text(
text = stringResource(R.string.about_body),
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
)
}
@@ -0,0 +1,258 @@
package de.ladkau.deckinadash.ui.config
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.material3.AlertDialog
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.Gender
import java.time.Instant
import java.time.ZoneOffset
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfigScreen(
modifier: Modifier = Modifier,
viewModel: ConfigViewModel = viewModel(),
) {
val config by viewModel.config.collectAsState()
val context = LocalContext.current
var showDatePicker by remember { mutableStateOf(false) }
var showBirthTimePicker by remember { mutableStateOf(false) }
var showNotifyTimePicker by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
viewModel.update { it.copy(notifyEnabled = granted) }
}
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(R.string.config_birth_date), style = MaterialTheme.typography.titleSmall)
Button(onClick = { showDatePicker = true }) {
val label = if (config.birthYear > 0) {
"%04d-%02d-%02d".format(config.birthYear, config.birthMonth, config.birthDay)
} else {
stringResource(R.string.config_birth_date)
}
Text(label)
}
Text(stringResource(R.string.config_birth_time), style = MaterialTheme.typography.titleSmall)
Button(onClick = { showBirthTimePicker = true }) {
Text("%02d:%02d".format(config.birthHour, config.birthMinute))
}
OutlinedTextField(
value = config.utcOffsetHours.toString(),
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(utcOffsetHours = value) } }
},
label = { Text(stringResource(R.string.config_utc_offset)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = config.latitude.toString(),
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(latitude = value) } }
},
label = { Text(stringResource(R.string.config_latitude)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = config.longitude.toString(),
onValueChange = { text ->
text.toDoubleOrNull()?.let { value -> viewModel.update { it.copy(longitude = value) } }
},
label = { Text(stringResource(R.string.config_longitude)) },
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
)
Text(stringResource(R.string.config_gender), style = MaterialTheme.typography.titleSmall)
val genderOptions = listOf(
Gender.UNSPECIFIED to stringResource(R.string.config_gender_unspecified),
Gender.MALE to stringResource(R.string.config_gender_male),
Gender.FEMALE to stringResource(R.string.config_gender_female),
)
SingleChoiceSegmentedButtonRow {
genderOptions.forEachIndexed { index, (gender, label) ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = genderOptions.size),
selected = config.gender == gender,
onClick = { viewModel.update { it.copy(gender = gender) } },
) { Text(label) }
}
}
LanguageDropdown(
selected = config.lang,
onSelected = { lang -> viewModel.update { it.copy(lang = lang) } },
)
Row {
Text(stringResource(R.string.config_notify_enabled), style = MaterialTheme.typography.titleSmall)
Switch(
checked = config.notifyEnabled,
onCheckedChange = { enabled ->
if (enabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
viewModel.update { it.copy(notifyEnabled = enabled) }
}
},
)
}
if (config.notifyEnabled) {
Button(onClick = { showNotifyTimePicker = true }) {
Text("%02d:%02d".format(config.notifyHour, config.notifyMinute))
}
}
Button(onClick = { viewModel.save {} }) {
Text(stringResource(R.string.config_save))
}
}
if (showDatePicker) {
val initialMillis = runCatching {
java.time.LocalDate.of(
config.birthYear.takeIf { it > 0 } ?: 2000,
config.birthMonth.takeIf { it in 1..12 } ?: 1,
config.birthDay.takeIf { it in 1..31 } ?: 1,
).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
}.getOrDefault(Instant.now().toEpochMilli())
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(onClick = {
datePickerState.selectedDateMillis?.let { millis ->
val date = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
viewModel.update {
it.copy(birthYear = date.year, birthMonth = date.monthValue, birthDay = date.dayOfMonth)
}
}
showDatePicker = false
}) { Text(stringResource(R.string.config_save)) }
},
) { DatePicker(state = datePickerState) }
}
if (showBirthTimePicker) {
TimePickerDialogContent(
initialHour = config.birthHour,
initialMinute = config.birthMinute,
onDismiss = { showBirthTimePicker = false },
onConfirm = { hour, minute ->
viewModel.update { it.copy(birthHour = hour, birthMinute = minute) }
showBirthTimePicker = false
},
)
}
if (showNotifyTimePicker) {
TimePickerDialogContent(
initialHour = config.notifyHour,
initialMinute = config.notifyMinute,
onDismiss = { showNotifyTimePicker = false },
onConfirm = { hour, minute ->
viewModel.update { it.copy(notifyHour = hour, notifyMinute = minute) }
showNotifyTimePicker = false
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TimePickerDialogContent(
initialHour: Int,
initialMinute: Int,
onDismiss: () -> Unit,
onConfirm: (hour: Int, minute: Int) -> Unit,
) {
val state = rememberTimePickerState(initialHour = initialHour, initialMinute = initialMinute, is24Hour = true)
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onConfirm(state.hour, state.minute) }) {
Text(stringResource(R.string.config_save))
}
},
text = { TimePicker(state = state) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LanguageDropdown(selected: String, onSelected: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val languages = listOf("en", "de")
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selected,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.config_language)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
languages.forEach { lang ->
DropdownMenuItem(
text = { Text(lang) },
onClick = {
onSelected(lang)
expanded = false
},
)
}
}
}
}
@@ -0,0 +1,46 @@
package de.ladkau.deckinadash.ui.config
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.DeckConfig
import de.ladkau.deckinadash.notify.NotificationScheduler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class ConfigViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
// A local editable draft, seeded once from the persisted config below -
// deliberately not a live collection of configRepository.config, so
// in-progress edits aren't clobbered by DataStore's own emission of the
// write this same screen is about to make on save().
private val _config = MutableStateFlow(DeckConfig())
val config: StateFlow<DeckConfig> = _config.asStateFlow()
init {
viewModelScope.launch {
_config.value = configRepository.config.first()
}
}
fun update(transform: (DeckConfig) -> DeckConfig) {
_config.value = transform(_config.value)
}
/** Persists the current draft and reschedules the daily notification -
* the other of NotificationScheduler.reschedule()'s two required call
* sites (see its own doc comment; the first is app launch). */
fun save(onSaved: () -> Unit) {
viewModelScope.launch {
val toSave = _config.value.copy(configured = true)
configRepository.save(toSave)
NotificationScheduler.reschedule(getApplication(), toSave)
onSaved()
}
}
}
@@ -0,0 +1,156 @@
package de.ladkau.deckinadash.ui.reading
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import de.ladkau.deckinadash.R
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.SpreadPositionDto
import de.ladkau.deckinadash.data.TransitDto
import de.ladkau.deckinadash.ui.cardDrawableRes
@Composable
fun ReadingScreen(
onNavigateToSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ReadingViewModel = viewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
Box(modifier = modifier.fillMaxSize()) {
when (val state = uiState) {
is ReadingUiState.Loading -> CenteredMessage(stringResource(R.string.reading_loading))
is ReadingUiState.NotConfigured -> Column(
modifier = Modifier.wrapContentSize(Alignment.Center).padding(24.dp),
) {
Text(stringResource(R.string.reading_not_configured))
Button(onClick = onNavigateToSettings, modifier = Modifier.padding(top = 16.dp)) {
Text(stringResource(R.string.nav_settings))
}
}
is ReadingUiState.Error -> CenteredMessage(stringResource(R.string.reading_error))
is ReadingUiState.Content -> ReadingContent(state.reading)
}
}
}
@Composable
private fun CenteredMessage(text: String) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (text == stringResource(R.string.reading_loading)) {
CircularProgressIndicator(modifier = Modifier.padding(bottom = 16.dp))
}
Text(text)
}
}
/** Single scrollable page, same content order as
* watch/src/c/ui_report_window.c's build_content(): date, day
* significance, top transits+narratives, full Celtic Cross spread,
* guidance last. */
@Composable
private fun ReadingContent(reading: ReadingDto) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(reading.date, style = MaterialTheme.typography.labelLarge)
Text(
reading.daySignificance.levelLabel,
style = MaterialTheme.typography.headlineSmall,
)
}
if (reading.transits.isNotEmpty()) {
item {
Text(
stringResource(R.string.reading_transits_heading),
style = MaterialTheme.typography.titleMedium,
)
}
items(reading.transits) { transit -> TransitItem(transit) }
}
item { HorizontalDivider() }
item {
Text(
stringResource(R.string.reading_spread_heading),
style = MaterialTheme.typography.titleMedium,
)
}
items(reading.spread) { position -> SpreadItem(position) }
item { HorizontalDivider() }
item {
Text(
stringResource(R.string.reading_guidance_heading),
style = MaterialTheme.typography.titleMedium,
)
Text(reading.guidance, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun TransitItem(transit: TransitDto) {
Column {
Text(transit.title, style = MaterialTheme.typography.bodyLarge)
if (transit.narrative.isNotBlank()) {
Text(transit.narrative, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun SpreadItem(position: SpreadPositionDto) {
val context = LocalContext.current
Column {
Text(position.positionName, style = MaterialTheme.typography.titleSmall)
Image(
painter = painterResource(cardDrawableRes(context, position.cardSlug)),
contentDescription = position.cardName,
modifier = Modifier
.width(160.dp)
.padding(vertical = 4.dp)
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
)
val cardTitle = if (position.reversed) {
"${position.cardName} ${stringResource(R.string.reading_reversed)}"
} else {
position.cardName
}
Text(cardTitle, style = MaterialTheme.typography.bodyLarge)
Text(position.positionDescription, style = MaterialTheme.typography.bodySmall)
Text(position.cardMeaning, style = MaterialTheme.typography.bodyMedium)
}
}
@@ -0,0 +1,58 @@
package de.ladkau.deckinadash.ui.reading
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.ladkau.deckinadash.data.ConfigRepository
import de.ladkau.deckinadash.data.ReadingDto
import de.ladkau.deckinadash.data.ReadingRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
sealed interface ReadingUiState {
data object Loading : ReadingUiState
data object NotConfigured : ReadingUiState
data class Content(val reading: ReadingDto) : ReadingUiState
data object Error : ReadingUiState
}
/**
* Recomputes today's reading whenever the persisted config changes
* (including the very first emission) - the Compose analog of
* watch/src/c/reading_state.c's reading_state_recompute() own doc
* comment ("safe to call again later... overwrites the previous result
* in place"), just reactive instead of imperatively re-invoked.
*/
class ReadingViewModel(application: Application) : AndroidViewModel(application) {
private val configRepository = ConfigRepository(application)
private val readingRepository = ReadingRepository(application)
private val _uiState = MutableStateFlow<ReadingUiState>(ReadingUiState.Loading)
val uiState: StateFlow<ReadingUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
configRepository.config.collectLatest { config ->
if (!config.configured) {
_uiState.value = ReadingUiState.NotConfigured
return@collectLatest
}
_uiState.value = ReadingUiState.Loading
_uiState.value = try {
val reading = withContext(Dispatchers.Default) {
readingRepository.compute(config, Instant.now().epochSecond)
}
ReadingUiState.Content(reading)
} catch (t: Throwable) {
ReadingUiState.Error
}
}
}
}
}
@@ -0,0 +1,30 @@
package de.ladkau.deckinadash.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun DeckInADashTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
+34
View File
@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.22.1)
project(deckengine C)
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
# Repo root, three levels up from android/app/src/main/jni/.
get_filename_component(REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../.." ABSOLUTE)
# Same engine + interpreter source files the watch app links (see
# watch/wscript's ENGINE_SRCS/INTERP_SRCS and its own comment on why -
# except astro.c is paired with the real, full-precision
# engine/third_party/astronomy/astronomy.c here, not the watch's
# lowprec_ephemeris.c substitute: that substitute exists solely for
# Pebble's 64KB code+data+bss budget, which Android has no equivalent
# of. json.c/reading_io.c are not linked - like the watch, this is one
# process computing and rendering its own reading, no JSON pipe between
# separate binaries.
add_library(deckengine SHARED
deck_jni.c
reading_json.c
${REPO_ROOT}/engine/src/rng.c
${REPO_ROOT}/engine/src/tarot.c
${REPO_ROOT}/engine/src/tarot_data.c
${REPO_ROOT}/engine/src/astro.c
${REPO_ROOT}/engine/src/i18n.c
${REPO_ROOT}/engine/src/reading.c
${REPO_ROOT}/engine/third_party/astronomy/astronomy.c
${REPO_ROOT}/interpreter/src/significance.c
${REPO_ROOT}/interpreter/src/narrative.c
${REPO_ROOT}/interpreter/src/guidance.c
)
target_link_libraries(deckengine m)
+45
View File
@@ -0,0 +1,45 @@
/* Thin JNIEXPORT shim over reading_json.c - keeps all real logic (and
* every engine/interpreter header include) in plain, JNI-free C, same
* separation watch/src/c/notify.c keeps from reading_state.c. */
#include <jni.h>
#include <stdlib.h>
#include "reading_json.h"
JNIEXPORT jstring JNICALL
Java_de_ladkau_deckinadash_nativebridge_DeckNative_generateReadingJson(
JNIEnv *env, jobject thiz,
jint birth_year, jint birth_month, jint birth_day,
jint birth_hour, jint birth_minute, jdouble utc_offset_hours,
jdouble latitude, jdouble longitude,
jint gender, jlong utc_moment_epoch_seconds,
jstring i18n_dir, jstring lang) {
(void)thiz;
BirthData birth = {
.year = birth_year, .month = birth_month, .day = birth_day,
.hour = birth_hour, .minute = birth_minute,
.utc_offset_hours = utc_offset_hours,
.latitude = latitude, .longitude = longitude,
};
const char *i18n_dir_chars = (*env)->GetStringUTFChars(env, i18n_dir, NULL);
const char *lang_chars = (*env)->GetStringUTFChars(env, lang, NULL);
char *json = NULL;
int rc = reading_json_generate(&birth, (Gender)gender, (time_t)utc_moment_epoch_seconds,
i18n_dir_chars, lang_chars, &json);
(*env)->ReleaseStringUTFChars(env, i18n_dir, i18n_dir_chars);
(*env)->ReleaseStringUTFChars(env, lang, lang_chars);
if (rc != 0) {
jclass exc = (*env)->FindClass(env, "java/lang/IllegalStateException");
(*env)->ThrowNew(env, exc, "reading_json_generate failed");
return NULL;
}
jstring result = (*env)->NewStringUTF(env, json);
free(json);
return result;
}
+198
View File
@@ -0,0 +1,198 @@
/* open_memstream() is POSIX (2008), not C99 - needs this feature-test
* macro under -std=c99 (glibc and Android's bionic both support it from
* a plain #define, no libc version gate below Android API 23, well
* under this app's minSdk 24). */
#define _POSIX_C_SOURCE 200809L
#include "reading_json.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../../../../../engine/src/i18n.h"
#include "../../../../../engine/src/reading.h"
#include "../../../../../interpreter/src/guidance.h"
#include "../../../../../interpreter/src/narrative.h"
#include "../../../../../interpreter/src/significance.h"
/* Same convention as engine/src/main.c's own default_i18n_path() -
* duplicated rather than shared, same reasoning CLAUDE.md gives for why
* engine/src/main.c and interpreter/src/main.c each keep their own copy
* (the binaries - here, shared libraries - are never linked together). */
static void i18n_path(const char *dir, const char *lang, char *out, size_t out_size) {
snprintf(out, out_size, "%s/%s.lang", dir, lang);
}
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
"quiet", "notable", "significant", "major",
};
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
default: return "Unknown";
}
}
/* "Transiting Saturn Square natal Sun" - the same title text
* interpreter/src/main.c's --format text prints per transit item, built
* here (rather than left to Kotlin) since it's fully localized via
* astro_body_name()/astro_aspect_name() - Kotlin never needs its own
* planet/aspect display-name table. */
static char *build_transit_title(const Aspect *a) {
char buf[160];
snprintf(buf, sizeof buf, "%s %s %s %s %s", i18n_get("ui.transiting", "Transiting"),
astro_body_name(a->transiting_planet), astro_aspect_name(a->type),
i18n_get("ui.natal", "natal"), astro_body_name(a->natal_planet));
size_t len = strlen(buf);
char *out = malloc(len + 1);
memcpy(out, buf, len + 1);
return out;
}
static char *capture_narrative(const Aspect *aspect, int house) {
char stack_buf[NARRATIVE_TEXT_MAX];
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
char stack_buf[GUIDANCE_TEXT_MAX];
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static void json_string(FILE *out, const char *s) {
fputc('"', out);
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
switch (c) {
case '"': fputs("\\\"", out); break;
case '\\': fputs("\\\\", out); break;
case '\n': fputs("\\n", out); break;
case '\r': fputs("\\r", out); break;
case '\t': fputs("\\t", out); break;
default:
if (c < 0x20) fprintf(out, "\\u%04x", c);
else fputc((char)c, out);
}
}
fputc('"', out);
}
static void 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);
}
int reading_json_generate(const BirthData *birth, Gender gender, time_t utc_moment,
const char *i18n_dir, const char *lang, char **out_json) {
char path[512];
i18n_path(i18n_dir, lang, path, sizeof path);
i18n_load(path); /* non-fatal on failure - falls back to built-in English, same as engine/src/main.c */
/* The tarot seed is today's UTC date, same convention
* dist/run-engine.sh and watch/src/c/reading_state.c use. */
char seed[16];
format_date(utc_moment, seed, sizeof seed);
DailyReading reading;
reading_generate(seed, birth, gender, utc_moment, &reading);
DailyInterpretation interp;
interpret_daily_reading(&reading, &interp);
size_t json_len = 0;
char *json_buf = NULL;
FILE *out = open_memstream(&json_buf, &json_len);
if (!out) return 1;
fprintf(out, "{\n");
char date_buf[32];
format_date(reading.utc_moment, date_buf, sizeof date_buf);
fprintf(out, " \"date\": ");
json_string(out, date_buf);
fprintf(out, ",\n \"gender\": ");
json_string(out, tarot_gender_slug(reading.gender));
fprintf(out, ",\n");
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level_slug\": \"%s\",\n", k_day_level_slug[interp.day_level]);
fprintf(out, " \"level_label\": ");
json_string(out, day_level_name(interp.day_level));
fprintf(out, ",\n \"rank\": %d,\n \"count\": %d,\n \"deserves_framing\": %s\n",
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT,
interpretation_deserves_framing(&interp) ? "true" : "false");
fprintf(out, " },\n");
fprintf(out, " \"transits\": [\n");
for (int i = 0; i < interp.top_item_count; i++) {
const SignificantItem *item = &interp.top_items[i];
const Aspect *a = &item->aspect;
const PlanetPosition *tp = &reading.transits.bodies[a->transiting_planet];
char *title = build_transit_title(a);
char *narrative = capture_narrative(a, tp->house);
fprintf(out, " {\n");
fprintf(out, " \"title\": ");
json_string(out, title);
fprintf(out, ",\n \"transiting_planet_slug\": \"%s\",\n", astro_body_slug(a->transiting_planet));
fprintf(out, " \"natal_planet_slug\": \"%s\",\n", astro_body_slug(a->natal_planet));
fprintf(out, " \"aspect_slug\": \"%s\",\n", astro_aspect_slug(a->type));
fprintf(out, " \"orb\": %.4f,\n", a->orb);
fprintf(out, " \"score\": %.4f,\n", item->score);
fprintf(out, " \"narrative\": ");
json_string(out, narrative);
fprintf(out, "\n }%s\n", i + 1 < interp.top_item_count ? "," : "");
free(title);
free(narrative);
}
fprintf(out, " ],\n");
char *guidance = capture_guidance(&interp, &reading.spread);
fprintf(out, " \"guidance\": ");
json_string(out, guidance);
fprintf(out, ",\n");
free(guidance);
fprintf(out, " \"spread\": [\n");
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
CelticCrossPosition position = (CelticCrossPosition)i;
const TarotDraw *draw = &reading.spread.positions[i];
fprintf(out, " {\n");
fprintf(out, " \"position_slug\": \"%s\",\n", tarot_position_slug(position));
fprintf(out, " \"position_name\": ");
json_string(out, tarot_position_name(position, reading.gender));
fprintf(out, ",\n \"position_description\": ");
json_string(out, tarot_position_description(position, reading.gender));
fprintf(out, ",\n \"card_slug\": \"%s\",\n", tarot_card_slug(draw->card));
fprintf(out, " \"card_name\": ");
json_string(out, tarot_card_name(draw->card));
fprintf(out, ",\n \"reversed\": %s,\n", draw->reversed ? "true" : "false");
fprintf(out, " \"card_meaning\": ");
json_string(out, tarot_card_meaning(draw->card, draw->reversed));
fprintf(out, "\n }%s\n", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
}
fprintf(out, " ]\n");
fprintf(out, "}\n");
fclose(out);
*out_json = json_buf;
return 0;
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef DECKINADASH_READING_JSON_H
#define DECKINADASH_READING_JSON_H
#include <time.h>
#include "../../../../../engine/src/astro.h"
#include "../../../../../engine/src/tarot.h"
/* Recomputes a full day's reading (astro + tarot + interpretation) from
* `birth`/`gender`/`utc_moment` - same inputs and same
* reading_generate()/interpret_daily_reading() calls
* watch/src/c/reading_state.c makes - and serializes everything a UI
* needs to render it (in watch/src/c/ui_report_window.c's own section
* order: date, day significance, top transits+narratives, full Celtic
* Cross spread, guidance last) as one JSON string, structurally a port
* of interpreter/src/main.c's interp_print_json() targeting a heap
* buffer instead of stdout.
*
* `i18n_dir`/`lang` locate `<i18n_dir>/<lang>.lang` (same convention as
* engine/src/main.c's default_i18n_path()); a missing/unreadable file is
* a non-fatal fallback to built-in English (i18n_load()'s own contract),
* not an error returned here.
*
* On success, returns a malloc'd, null-terminated JSON string in
* `*out_json` (caller must free()) and returns 0. Returns non-zero and
* leaves `*out_json` untouched only if the underlying open_memstream()
* allocation itself fails - not a condition this codebase's other
* callers (deck-engine, interpreter-cli) treat as reachable in practice. */
int reading_json_generate(const BirthData *birth, Gender gender, time_t utc_moment,
const char *i18n_dir, const char *lang, char **out_json);
#endif
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Status-bar notification icon: must be a flat white silhouette on
transparent (the system applies its own tint) - a simple crescent
moon + star, evoking the astrology half of this app's reading. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FF000000"
android:pathData="M12.5,3 C8.4,3.6 5.3,7.1 5.3,11.4 C5.3,16.1 9.1,19.9 13.8,19.9 C16.9,19.9 19.6,18.2 21,15.7 C19.9,16.4 18.6,16.8 17.2,16.8 C12.9,16.8 9.4,13.3 9.4,9 C9.4,6.7 10.5,4.6 12.5,3 Z" />
<path
android:fillColor="#FF000000"
android:pathData="M18.5,3 L19.2,4.8 L21,5.5 L19.2,6.2 L18.5,8 L17.8,6.2 L16,5.5 L17.8,4.8 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B1B2F</color>
<color name="purple_primary">#6750A4</color>
</resources>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Deck in a Dash</string>
<string name="nav_reading">Today\'s Reading</string>
<string name="nav_settings">Settings</string>
<string name="nav_about">About</string>
<string name="notification_channel_name">Daily reading</string>
<string name="notification_channel_description">A daily notification with your tarot and astrology reading</string>
<string name="notification_title">Today\'s reading: %1$s</string>
<string name="config_title">Settings</string>
<string name="config_birth_date">Birth date</string>
<string name="config_birth_time">Birth time</string>
<string name="config_utc_offset">UTC offset (hours)</string>
<string name="config_latitude">Latitude</string>
<string name="config_longitude">Longitude</string>
<string name="config_gender">Reading for</string>
<string name="config_gender_unspecified">Unspecified</string>
<string name="config_gender_male">Male</string>
<string name="config_gender_female">Female</string>
<string name="config_language">Language</string>
<string name="config_notify_enabled">Daily notification</string>
<string name="config_notify_time">Notification time</string>
<string name="config_save">Save</string>
<string name="reading_not_configured">Set your birth details in Settings to see today\'s reading.</string>
<string name="reading_loading">Computing today\'s reading…</string>
<string name="reading_error">Could not compute today\'s reading.</string>
<string name="reading_guidance_heading">Guidance</string>
<string name="reading_transits_heading">Significant transits</string>
<string name="reading_spread_heading">Celtic Cross</string>
<string name="reading_reversed">(Reversed)</string>
<string name="about_body">Deck in a Dash shows a daily personalized tarot (Celtic Cross) and astrology (natal chart + transits) reading.\n\nCard and spread text is condensed from A. E. Waite\'s The Pictorial Key to the Tarot (1911, public domain). Transit narrative text is condensed from Sepharial\'s Transits and Planetary Periods (1920, public domain).</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DeckInADash" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+35
View File
@@ -0,0 +1,35 @@
[versions]
agp = "9.2.1"
kotlin = "2.2.20"
coreKtx = "1.17.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.13.0"
composeBom = "2026.06.01"
navigationCompose = "2.9.8"
datastorePreferences = "1.2.1"
workRuntimeKtx = "2.11.2"
kotlinxSerializationJson = "1.11.0"
kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Downsizes ../../res/img/*.jpeg into app/src/main/res/drawable-nodpi/*.webp
for the Android app.
Structurally a sibling of ../../watch/scripts/gen_card_images.py (same
CARDS slug/filename table - matching engine/src/tarot_data.c's own
k_card_slug, so app/src/main/res/drawable-nodpi/<slug>.webp resolves via
plain resource-name lookup, see app/src/main/java/.../ui/CardArt.kt -
and the same per-file mtime skip-if-up-to-date logic), but sized and
encoded for a phone screen instead of Pebble's hardware-forced 72px PNG:
400px wide, lossy WebP (quality 85) - these are painted illustrations,
not line art, so WebP meaningfully beats PNG here, and 21MB of source
JPEGs would otherwise bloat the APK.
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/drawable-nodpi entry) - regenerate by running
this script directly or via Gradle's generateCardArt task (wired into
app's preBuild, see ../app/build.gradle.kts). Requires Pillow (`pip
install Pillow`), already part of build-image's toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "img"))
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res", "drawable-nodpi"))
CARD_WIDTH = 400
WEBP_QUALITY = 85
# (source file in res/img/, slug matching engine/src/tarot_data.c's
# k_card_slug table) - in TarotCard enum order (CARD_FOOL = 0 ... CARD_WORLD).
CARDS = [
("RWS_Tarot_00_Fool.jpeg", "fool"),
("RWS_Tarot_01_Magician.jpeg", "magician"),
("RWS_Tarot_02_High_Priestess.jpeg", "high_priestess"),
("RWS_Tarot_03_Empress.jpeg", "empress"),
("RWS_Tarot_04_Emperor.jpeg", "emperor"),
("RWS_Tarot_05_Hierophant.jpeg", "hierophant"),
("RWS_Tarot_06_Lovers.jpeg", "lovers"),
("RWS_Tarot_07_Chariot.jpeg", "chariot"),
("RWS_Tarot_08_Strength.jpeg", "strength"),
("RWS_Tarot_09_Hermit.jpeg", "hermit"),
("RWS_Tarot_10_Wheel_of_Fortune.jpeg", "wheel_of_fortune"),
("RWS_Tarot_11_Justice.jpeg", "justice"),
("RWS_Tarot_12_Hanged_Man.jpeg", "hanged_man"),
("RWS_Tarot_13_Death.jpeg", "death"),
("RWS_Tarot_14_Temperance.jpeg", "temperance"),
("RWS_Tarot_15_Devil.jpeg", "devil"),
("RWS_Tarot_16_Tower.jpeg", "tower"),
("RWS_Tarot_17_Star.jpeg", "star"),
("RWS_Tarot_18_Moon.jpeg", "moon"),
("RWS_Tarot_19_Sun.jpeg", "sun"),
("RWS_Tarot_20_Judgement.jpeg", "judgement"),
("RWS_Tarot_21_World.jpeg", "world"),
]
def main():
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for src_name, slug in CARDS:
src_path = os.path.join(SRC_DIR, src_name)
out_path = os.path.join(OUT_DIR, f"{slug}.webp")
if os.path.exists(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(src_path):
continue
with Image.open(src_path) as img:
ratio = CARD_WIDTH / img.width
height = round(img.height * ratio)
resized = img.convert("RGB").resize((CARD_WIDTH, height), Image.LANCZOS)
resized.save(out_path, "WEBP", quality=WEBP_QUALITY)
written += 1
print(f"wrote {written}/{len(CARDS)} card images to {OUT_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Generates the Android launcher icon from ../../res/logo.png.
Produces:
- mipmap-<density>/ic_launcher_foreground.png - the logo centered,
scaled to fit the adaptive-icon safe zone (66% of a 108dp canvas),
transparent background - paired at runtime with
@color/ic_launcher_background (see mipmap-anydpi-v26/ic_launcher.xml,
committed - a static reference, not generated) for API 26+.
- mipmap-<density>/ic_launcher.png / ic_launcher_round.png - the same
logo flattened onto ic_launcher_background, for API <26 devices that
can't use the adaptive-icon XML at all.
Generated output, not committed (see the repo's root .gitignore's
/android/app/src/main/res/mipmap-* entries) - regenerate by running this
script directly or via Gradle's generateLauncherIcon task (wired into
app's preBuild, see ../app/build.gradle.kts). Requires Pillow, already
part of build-image's toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
LOGO_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "logo.png"))
RES_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "app", "src", "main", "res"))
BACKGROUND_RGBA = (27, 27, 47, 255) # matches @color/ic_launcher_background
# (density qualifier, adaptive-icon canvas px, legacy launcher px)
DENSITIES = [
("mdpi", 108, 48),
("hdpi", 162, 72),
("xhdpi", 216, 96),
("xxhdpi", 324, 144),
("xxxhdpi", 432, 192),
]
# Adaptive icons render only the inner ~66% "safe zone" reliably across
# launchers that apply their own mask shape.
SAFE_ZONE_RATIO = 0.66
def resized_logo(logo, target_px):
ratio = target_px / max(logo.width, logo.height)
size = (round(logo.width * ratio), round(logo.height * ratio))
return logo.resize(size, Image.LANCZOS)
def main():
logo = Image.open(LOGO_PATH).convert("RGBA")
for density, canvas_px, legacy_px in DENSITIES:
mipmap_dir = os.path.join(RES_DIR, f"mipmap-{density}")
os.makedirs(mipmap_dir, exist_ok=True)
# Adaptive-icon foreground: transparent canvas, logo scaled to the
# safe zone and centered.
foreground = Image.new("RGBA", (canvas_px, canvas_px), (0, 0, 0, 0))
fg_logo = resized_logo(logo, round(canvas_px * SAFE_ZONE_RATIO))
offset = ((canvas_px - fg_logo.width) // 2, (canvas_px - fg_logo.height) // 2)
foreground.paste(fg_logo, offset, fg_logo)
foreground.save(os.path.join(mipmap_dir, "ic_launcher_foreground.png"))
# Legacy (<API26) flattened fallback, same for round and square -
# launchers on those versions apply their own circular crop.
legacy = Image.new("RGBA", (legacy_px, legacy_px), BACKGROUND_RGBA)
legacy_logo = resized_logo(logo, round(legacy_px * SAFE_ZONE_RATIO))
legacy_offset = ((legacy_px - legacy_logo.width) // 2, (legacy_px - legacy_logo.height) // 2)
legacy.paste(legacy_logo, legacy_offset, legacy_logo)
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher.png"))
legacy.convert("RGB").save(os.path.join(mipmap_dir, "ic_launcher_round.png"))
print(f"wrote launcher icons for {len(DENSITIES)} densities to {RES_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "deck-in-a-dash-android"
include(":app")
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Builds the release build environment image (build-image/Dockerfile)
# locally, tagged with build-image/VERSION and :latest. Does not push -
# test it with ./run-image.sh first, then publish with ./upload-image.sh.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
echo "== Building $REGISTRY_IMAGE:$VERSION =="
docker build \
-t "$REGISTRY_IMAGE:$VERSION" \
-t "$REGISTRY_IMAGE:latest" \
"$ROOT/build-image"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION (and :latest)"
echo "Test it with ./run-image.sh, then publish with ./upload-image.sh"
+94
View File
@@ -0,0 +1,94 @@
# Build environment for deck_in_a_dash release artifacts: a plain C
# toolchain for the desktop deck-engine/interpreter-cli binaries (`make
# test`, `make package`), the Pebble SDK for the watch app (`make
# watchapp`), and the Android SDK/NDK for the Android app (`make
# android`), pinned to the versions validated on the maintainer's dev
# machine. Rebuild with ../build-image.sh whenever a version below
# changes.
#
# Does NOT contain any secrets - registry/SFTP credentials are injected
# at job runtime from Gitea Actions secrets, never baked into this image.
FROM ubuntu:22.04
ARG PEBBLE_TOOL_VERSION=5.0.39
ARG PEBBLE_SDK_CORE_VERSION=4.17
ARG NODE_VERSION=24.16.0
ARG ANDROID_CMDLINE_TOOLS_VERSION=9862592
ARG ANDROID_PLATFORM_VERSION=36
ARG ANDROID_BUILD_TOOLS_VERSION=36.0.0
ARG ANDROID_NDK_VERSION=30.0.14904198
ARG ANDROID_CMAKE_VERSION=3.22.1
ENV DEBIAN_FRONTEND=noninteractive \
PATH=/root/.local/bin:${PATH}
# build-essential: gcc/make for the desktop deck-engine/interpreter-cli
# binaries (`make test`, `make package`) and for the Android app's JNI
# native library (app/src/main/jni/CMakeLists.txt - the NDK's own
# toolchain does the actual cross-compiling, but AGP's build needs a
# host C toolchain available too).
# python3/python3-venv/python3-pip: `pebble sdk install` below creates a
# venv per SDK version; Pillow (installed below) is needed at `pebble
# build` time by watch/scripts/gen_card_images.py and gen_app_icon.py,
# and by the Android app's android/scripts/gen_card_images.py and
# gen_launcher_icon.py.
# git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads,
# and `make package`/`make watchapp`/`make android`'s `git describe
# --tags` versioning.
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential python3 python3-venv python3-pip \
git curl ca-certificates unzip tar xz-utils openssh-client \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir Pillow
# Node.js: `pebble sdk install` below runs `npm install` for the SDK
# core's bundled webpack tooling - it does not bring its own node/npm.
RUN curl -sSL -o /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
&& rm /tmp/node.tar.xz
# Pebble SDK: pebble-tool (via uv, pipx-style isolated install) + the
# sdk-core toolchain/webpack deps for PEBBLE_SDK_CORE_VERSION, both
# fully resolved here so no `docker run` of this image needs network
# access to install anything before `pebble build` works.
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
&& pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
# Android SDK/NDK: openjdk-17 (Gradle/AGP's own minimum JDK) + the
# cmdline-tools' own sdkmanager to install exactly the platform/build-
# tools/NDK/CMake versions android/app/build.gradle.kts pins - unlike
# the Pebble/CLI build path above, `make android`'s own Gradle build
# still needs network access at build time (Gradle/AGP/androidx
# dependency resolution against Google's Maven + Maven Central - see the
# root CLAUDE.md's Android section for why this is an accepted,
# deliberate deviation from this image's otherwise-offline build
# philosophy). android/gradlew resolves its own pinned Gradle version -
# no Gradle binary is baked into this image.
RUN apt-get update && apt-get install -y --no-install-recommends openjdk-17-jdk-headless \
&& rm -rf /var/lib/apt/lists/*
ENV ANDROID_HOME=/opt/android-sdk \
ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& curl -sSL -o /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --licenses >/dev/null \
&& sdkmanager --install \
"platform-tools" \
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
"ndk;${ANDROID_NDK_VERSION}" \
"cmake;${ANDROID_CMAKE_VERSION}"
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}
WORKDIR /workspace
+1
View File
@@ -0,0 +1 @@
2
+6 -9
View File
@@ -16,9 +16,8 @@ know before changing the engine. This document is the visual overview.
(MIT), pinned to a specific commit — see its `VENDORED.md`. (MIT), pinned to a specific commit — see its `VENDORED.md`.
- **`engine/src/`** is the core engine. Everything except `main.c`, - **`engine/src/`** is the core engine. Everything except `main.c`,
`reading.c`'s `reading_print_text`/`reading_print_html` functions, and `reading.c`'s `reading_print_text`/`reading_print_html` functions, and
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions, `i18n.c`'s file-loading half is free of `stdio`/CLI assumptions, so it
specifically so it can be linked into the Pebble watchapp later without links straight into a Pebble watchapp without rework.
rework.
- `rng.c` — deterministic string-seeded PRNG (FNV-1a + splitmix64). - `rng.c` — deterministic string-seeded PRNG (FNV-1a + splitmix64).
- `tarot.c` / `tarot_data.c` — the Celtic Cross draw and its content. - `tarot.c` / `tarot_data.c` — the Celtic Cross draw and its content.
- `astro.c` — natal chart + daily transits, built on the vendored - `astro.c` — natal chart + daily transits, built on the vendored
@@ -41,9 +40,6 @@ know before changing the engine. This document is the visual overview.
`engine/scripts/user.properties.local` (gitignored) once it's filled `engine/scripts/user.properties.local` (gitignored) once it's filled
in, and restored from there if `dist/` is ever wiped — see in, and restored from there if `dist/` is ever wiped — see
`CLAUDE.md`'s "Build & run". `CLAUDE.md`'s "Build & run".
- **The Pebble watchapp does not exist yet.** When it's built, it will
call `reading_generate()` directly and walk the returned struct to lay
out its own screens — it has no reason to touch `reading_print_*`.
## Dataflow ## Dataflow
@@ -64,9 +60,10 @@ computations and combines their results:
with position and upright/reversed orientation, fully determined by with position and upright/reversed orientation, fully determined by
the seed string alone. the seed string alone.
These three results are combined into one `DailyReading` struct, which is These three results are combined into one `DailyReading` struct — the
either rendered by `reading_print_text`/`reading_print_html` (used by the real API surface. `reading_print_text`/`reading_print_html` (used by the
CLI) or, in the future, walked directly by watchapp UI code. See CLI) are just one way of consuming it; any caller, including watch UI
code, can walk the struct's fields directly. See
[`input-output-format.md`](input-output-format.md) for the exact fields [`input-output-format.md`](input-output-format.md) for the exact fields
and output formats. and output formats.
-8
View File
@@ -55,12 +55,6 @@ digraph architecture {
{ rank=same; binary; distimg; disti18n; runsh; props; } { rank=same; binary; distimg; disti18n; runsh; props; }
} }
subgraph cluster_watch {
label="Pebble watchapp — not built yet";
style="dashed"; color=firebrick; fontsize=11; fontcolor=firebrick;
watch [label="watchapp C / UI\n(future)", fillcolor=white, color=firebrick, fontcolor=firebrick, style="rounded,dashed"];
}
pdf -> tarot_data [label="sourced from", style=dotted, color=gray40]; pdf -> tarot_data [label="sourced from", style=dotted, color=gray40];
tarot -> rng; tarot -> rng;
@@ -78,6 +72,4 @@ digraph architecture {
images -> distimg [label="`make images`\ncopies", style=dotted, color=gray40]; images -> distimg [label="`make images`\ncopies", style=dotted, color=gray40];
langfiles -> disti18n [label="`make i18n`\ncopies", style=dotted, color=gray40]; langfiles -> disti18n [label="`make i18n`\ncopies", style=dotted, color=gray40];
runsh -> props [label="reads at\nrun time", style=dotted, color=gray40]; runsh -> props [label="reads at\nrun time", style=dotted, color=gray40];
watch -> reading [label="will call\nreading_generate()\ndirectly", style=dashed, color=firebrick, fontcolor=firebrick];
} }
-4
View File
@@ -33,11 +33,9 @@ digraph dataflow {
print_text [label="reading_print_text()", fillcolor="#cfe8fb"]; print_text [label="reading_print_text()", fillcolor="#cfe8fb"];
print_html [label="reading_print_html()", fillcolor="#cfe8fb"]; print_html [label="reading_print_html()", fillcolor="#cfe8fb"];
watch_fn [label="watchapp UI code\n(future — walks the struct\ndirectly, no print_* call)", fillcolor=white, color=firebrick, fontcolor=firebrick, style="rounded,dashed"];
out_text [label="stdout: plain-text report", shape=parallelogram, fillcolor="#c8f0c8"]; out_text [label="stdout: plain-text report", shape=parallelogram, fillcolor="#c8f0c8"];
out_html [label="stdout: reading.html\n(<img src=\"img/...\"> per card)", shape=parallelogram, fillcolor="#c8f0c8"]; out_html [label="stdout: reading.html\n(<img src=\"img/...\"> per card)", shape=parallelogram, fillcolor="#c8f0c8"];
out_watch [label="watch screens\n(future)", shape=parallelogram, fillcolor=white, color=firebrick, fontcolor=firebrick, style="dashed"];
cli -> main; cli -> main;
runsh -> main; runsh -> main;
@@ -59,9 +57,7 @@ digraph dataflow {
daily_reading -> print_text; daily_reading -> print_text;
daily_reading -> print_html; daily_reading -> print_html;
daily_reading -> watch_fn [style=dashed, color=firebrick];
print_text -> out_text; print_text -> out_text;
print_html -> out_html; print_html -> out_html;
watch_fn -> out_watch [style=dashed, color=firebrick];
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 236 KiB

+267 -41
View File
@@ -4,9 +4,10 @@ See [`architecture.md`](architecture.md) for how these fit together.
There are two ways to provide input (direct CLI flags, or `run-engine.sh` There are two ways to provide input (direct CLI flags, or `run-engine.sh`
+ `user.properties`) and three output formats (`text`, `html`, `json`); the + `user.properties`) and three output formats (`text`, `html`, `json`); the
`DailyReading` struct is the programmatic form all three are rendered `DailyReading` struct is the programmatic form all three are rendered
from, and the one the future watchapp will consume directly. `json` is from, and the one watch UI code consumes directly. `json` is also
also `interpreter-cli`'s input format — see "The interpreter" at the `interpreter-cli`'s input format — see "The interpreter" at the bottom
bottom of this file. of this file, which supports the same three output formats (and the
same `--lang`) for its own report, independently of `deck-engine`.
## Input ## Input
@@ -16,6 +17,7 @@ bottom of this file.
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 |
@@ -28,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). |
@@ -51,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
``` ```
@@ -58,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
@@ -80,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
@@ -90,8 +102,8 @@ build, like `dist/img/`). Adding a language is just dropping another
### `DailyReading` (the real output — `reading.h`) ### `DailyReading` (the real output — `reading.h`)
`reading_generate()` is the actual API; `text`/`html` below are just two `reading_generate()` is the actual API; `text`/`html` below are just two
ways of rendering the struct it fills in. This is what the watchapp will ways of rendering the struct it fills in. This is what watch UI code
read directly once it exists. reads directly.
```c ```c
typedef struct { typedef struct {
@@ -145,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.
... ...
``` ```
@@ -170,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
@@ -181,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},
@@ -209,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,
@@ -227,16 +252,39 @@ read.
`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s `dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s
"Interpretation" section for the full architecture — that reads a "Interpretation" section for the full architecture — that reads a
`--format json` document and scores how significant today's transits `--format json` document and scores how significant today's transits
are. It never links the engine; it only depends on the JSON shape are. It never links `astro.c`/`tarot.c`/`reading.c` (or the vendored
above. Astronomy Engine) — only `engine/src/tarot_data.c`/`i18n.c` are linked
in, for real card/position names and Waite meaning text (see
`CLAUDE.md`'s `INTERP_TAROT_TEXT_OBJS` note). Otherwise it depends only
on the JSON shape above.
Like `deck-engine`, its own output supports `--format text|html|json`
and `--lang <code>`/`--i18n-dir <path>`:
| Flag | Required | Format | Meaning |
|---|---|---|---|
| `[reading.json]` | no | file path or `-` | Input file; stdin (or `-`) if omitted. |
| `--format` | no | `text` \| `html` \| `json` | This binary's **own output** format, defaults to `text`. Entirely independent of the `--format json` that produced its *input* - see below. |
| `--lang` | no | language code, e.g. `en`, `de` | This binary's **own output** language, defaults to `en`. Same `<code>.lang`/`--i18n-dir` convention as `deck-engine` (see "Translations" above) - `main.c` calls `i18n_load()` itself, so `narrative.c`'s/`guidance.c`'s own text (in `engine/i18n/*.lang` under `narrative.*`/`guidance.*` keys) is translated too, not just the card/position/body/sign/aspect vocabulary it shares with `deck-engine`. |
| `--i18n-dir` | no | path | Same convention as `deck-engine`'s: defaults to an `i18n/` directory next to this binary. |
**This `--lang` is unrelated to whatever `--lang` `deck-engine` was run
with to produce the `--format json` input** - that JSON is always
language-independent (slugs only, see above), so it doesn't matter what
language `deck-engine` printed anything in, or whether it was even asked
to print anything at all (`--format json` never touches `--lang`
either). The two `--lang` flags, on the two sides of the pipe, are
independent settings.
```bash ```bash
dist/deck-engine ... --format json | dist/interpreter-cli dist/deck-engine ... --format json | dist/interpreter-cli --format html --lang de
# or: # or:
dist/deck-engine ... --format json > reading.json dist/deck-engine ... --format json > reading.json
dist/interpreter-cli reading.json dist/interpreter-cli --format json reading.json
# or, for daily use (OS clock + user.properties, same inputs as run-engine.sh): # or, for daily use (OS clock + user.properties, same inputs as run-engine.sh,
dist/run-interpreter.sh # including user.properties' lang=):
dist/run-interpreter.sh # text output, language from user.properties
dist/run-interpreter.sh html de # override format/language for this run
``` ```
- **Input**: a file path argument, or stdin if no argument (or `-`) is - **Input**: a file path argument, or stdin if no argument (or `-`) is
@@ -244,30 +292,208 @@ dist/run-interpreter.sh
means "no notable transits today"; a missing key is treated as invalid means "no notable transits today"; a missing key is treated as invalid
input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are
read too, for the sign/house context printed alongside each event below read too, for the sign/house context printed alongside each event below
(not for scoring) an entry naming an unrecognized body, or a missing (not for scoring), and `"spread"."positions"` is read for the full
`house`, is silently skipped rather than failing the load (see Celtic Cross readout and the guidance paragraph below — an entry naming
`parse_bodies()` in `reading_io.c`); `spread` is always ignored. an unrecognized body/card/position, a missing `house`, or a missing
- **Output**: a plain-text report — the day's overall significance level `"spread"` key entirely is silently skipped/zeroed rather than failing
(`Quiet`/`Notable`/`Significant`/`Major`, plus its rank out of the 4 the load (see `parse_bodies()`/`parse_spread()` in `reading_io.c`).
defined levels, e.g. `Notable (2/4)`), a "worth a deeper Celtic Cross - **Input**: same for all three output formats above - only how the
look" line on `Major` days only, and up to 5 of today's aspects ranked parsed data gets rendered differs.
by score, each naming the sign and natal house the transiting planet
currently occupies and the sign and house of the natal planet it's
aspecting, followed by a short narrative sentence about what that
transiting planet classically signifies *and which area of life its
current house governs* (see `CLAUDE.md`'s `narrative.c` bullet) —
omitted only if the source material has nothing to say for that
planet/aspect combination:
``` ### `--format text`
Day significance: Significant (3/4)
(a major transit today - worth a deeper Celtic Cross look)
Top 3 significant transits: A plain-text report, printed in a fixed order —
1. Transiting Saturn in Aries (house 2) Square natal Sun in Taurus (house 3) (orb 0.5°, score 8.10) 1. the day this reading is for (`Reading for: 2026-07-16`), from the
In matters of money, possessions, and personal values (house 2). Brings depression, stagnation, hindrances and obstacles, and some deprivation of the usual benefits. input JSON's top-level `date` field (see `--format json` above) —
2. Transiting Pluto in Aquarius (house 12) Opposition natal Moon in Capricorn (house 11) (orb 0.2°, score 7.92) not necessarily the day the report is actually being read on, if
In matters of solitude, the subconscious, and hidden matters (house 12). Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form. `reading.json` was generated earlier and piped into
3. Transiting Jupiter in Leo (house 5) Trine natal Venus in Aries (house 2) (orb 2.1°, score 3.40) `interpreter-cli` later;
In matters of romance, creativity, and children (house 5). Brings increase and expansion - fullness of fortune and health, and a generally fortunate time. 2. the day's overall significance level (`Quiet`/`Notable`/
``` `Significant`/`Major`, plus its rank out of the 4 defined levels,
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
line on `Major` days only;
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
sign and house of the natal planet it's aspecting, followed by a
short narrative sentence about what that transiting planet
classically signifies *and which area of life its current house
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
the source material has nothing to say for that planet/aspect
combination;
4. the full 10-position Celtic Cross spread, in Waite's own drawing
order, each with its card (and orientation), the position's own
description, and that card's actual meaning;
5. finally, a guidance paragraph tying the day's top transit to the
spread's Attitude/Outcome cards, with its opening sentence tailored
to `day_level` (stronger wording on `Major`, softer on
`Quiet`/`Notable`, falling back to a transit-free reading of the
Attitude card alone on a day with no aspects at all) and, for the
Attitude/Outcome cards it names, their actual Waite meaning for the
orientation they landed in — see `CLAUDE.md`'s `guidance.c` bullet.
This is printed last, as the closing takeaway after the reader has
seen the full spread it references.
```
Reading for: 2026-07-16
Day significance: Major (4/4)
(a major transit today - worth a deeper Celtic Cross look)
Top 5 significant transits:
1. Transiting Pluto in Aquarius (house 3) Conjunction natal Moon in Aquarius (house 3) (orb 2.9°, score 9.60)
In matters of communication, siblings, and everyday learning (house 3). Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form.
2. Transiting Neptune in Aries (house 5) Sextile natal Moon in Aquarius (house 3) (orb 2.5°, score 9.25)
In matters of romance, creativity, and children (house 5). Brings a state of chaos and confusion - an involved, uncertain condition of affairs, with plots, subtlety, or unseen influences at work.
3. Transiting Uranus in Gemini (house 7) Trine natal Moon in Aquarius (house 3) (orb 2.0°, score 8.96)
In matters of partnerships and close relationships (house 7). Brings separations, estrangements, sudden dislocations and violent upsets. Success through official or civic channels, and beneficial changes or appointments, are possible.
Celtic Cross:
1. The Present: The Emperor (Reversed)
This covers them: the general influence affecting the matter.
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
2. The Challenge: The Devil
This crosses them: the nature of the obstacle in the matter.
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
...
7. Themself: The Magician
Their position or attitude in the circumstances.
Skill, diplomacy, address, subtlety; self-confidence, will.
...
10. The Outcome: Strength
What will come: the final result of the matter.
Power, energy, action, courage, magnanimity; complete success and honours.
With Pluto as today's dominant influence: Today concentrates whatever you already
bring to it - your current approach will be amplified, so make sure it's the one
you want.
Your Attitude card is The Magician: Skill, diplomacy, address, subtlety; self-confidence, will.
The spread's likely Outcome is Strength: Power, energy, action, courage, magnanimity; complete success and honours.
```
On a quieter day, only the opening of the guidance paragraph changes:
```
Day significance: Notable (2/4)
...
With a mild touch from Jupiter today: Your instincts are sound, but the day is
testing them - hold your position without forcing the issue.
Your Attitude card is The Sun: Material happiness, fortunate marriage, contentment.
The spread's likely Outcome is Justice: Equity, rightness, probity, executive; triumph of the deserving side in law.
```
The same report with `--lang de` (same input as the Major example
above):
```
Lesung für: 2026-07-16
Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
Top 5 wichtige Transits:
1. Transit Pluto in Wassermann (Haus 3) Konjunktion natal Mond in Wassermann (Haus 3) (Orbis 2.9°, Punktzahl 9.60)
In Fragen von Kommunikation, Geschwistern und alltäglichem Lernen (Haus 3). Bringt tiefgreifenden, oft verborgenen Wandel - das Auftauchen oder die Auflösung von etwas, das seine alte Form überwachsen hat.
...
Keltisches Kreuz:
1. Die Gegenwart: Der Herrscher (Umgekehrt)
Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
...
Pluto ist heute der bestimmende Einfluss: Der heutige Tag verstärkt, was du bereits
mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher,
dass es die richtige ist.
Deine Haltungskarte ist Der Magier: Geschick, Diplomatie, Gewandtheit, Feinsinn; Selbstvertrauen, Wille.
Das wahrscheinliche Ergebnis des Blatts ist Kraft: Macht, Energie, Tatkraft, Mut, Großmut; voller Erfolg und Ehren.
```
### `--format html`
A single self-contained HTML page, same inline-CSS/no-JS approach as
`deck-engine`'s own `--format html`, and the same three sections as text
(significant transits as a table, the full Celtic Cross as an image
grid, guidance as a closing callout) - reusing the exact same
`img/<file>` card-art convention (`tarot_card_image_file()`), so it
expects to sit next to an `img/` directory just like `deck-engine`'s
HTML output:
```html
<div class="card">
<div class="position">The Present</div>
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
<div class="name">The Emperor (Reversed)</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>
```
The guidance section renders as one `<p>` per line of the same text
`--format text` prints last (intro sentence, Attitude line, Outcome
line), inside a `<div class="guidance">`.
### `--format json`
Unlike `deck-engine`'s own `--format json` - which is deliberately
**language-independent** (slugs only, no `--lang`-dependent prose, since
it's a machine interchange format between the two binaries) -
`interpreter-cli`'s `--format json` embeds the *same localized
narrative/guidance prose* `--format text` prints, in whatever `--lang`
was loaded, **alongside** stable, language-independent slug fields. This
is a deliberate difference: rendering that prose is this module's whole
job, so a JSON consumer that wants it doesn't have to reimplement
`narrative.c`/`guidance.c`'s logic itself - but a consumer that only
wants structured data (e.g. watch UI code with its own rendering) still
has the slugs to work with directly.
```json
{
"date": "2026-07-16",
"day_significance": {
"level": "major",
"rank": 4,
"count": 4,
"deserves_framing": true
},
"significant_transits": [
{
"transiting_planet": "pluto",
"transiting_sign": "aquarius",
"transiting_house": 3,
"natal_planet": "moon",
"natal_sign": "aquarius",
"natal_house": 3,
"aspect": "conjunction",
"orb": 2.8821,
"score": 9.5961,
"narrative": "In matters of communication, siblings, and everyday learning (house 3). Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form."
}
],
"celtic_cross": [
{
"position": "present",
"card": "emperor",
"reversed": true,
"card_name": "The Emperor",
"meaning": "Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity."
}
],
"guidance": "With Pluto as today's dominant influence: Today concentrates whatever you already bring to it - your current approach will be amplified, so make sure it's the one you want.\nYour Attitude card is The Magician: Skill, diplomacy, address, subtlety; self-confidence, will.\nThe spread's likely Outcome is Strength: Power, energy, action, courage, magnanimity; complete success and honours."
}
```
- `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
ranking as `--format text`); `celtic_cross` always has exactly 10, in
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
`spread.positions`).
- `orb`/`score` are `%.4f`, matching `deck-engine`'s own JSON precision
for angles.
- `guidance` is always present and non-empty (unlike
`significant_transits`, which can be `[]` on a genuinely quiet day) -
see `guidance.c`'s no-aspects-at-all fallback in `CLAUDE.md`.
- With `--lang de`, every string value above except the slugs
(`"level"`, `"transiting_planet"`, `"aspect"`, `"card"`, `"position"`,
etc.) changes language; the slugs never do.
+132 -21
View File
@@ -18,6 +18,12 @@ exactly where the planets were, and which zodiac sign was rising, at the
moment you were born — your **natal chart**. Everything else the app moment you were born — your **natal chart**. Everything else the app
does is compared against that one fixed reference point. does is compared against that one fixed reference point.
You can also choose a **language** for your reading - English or German.
Every part of the reading described below, including the guidance
paragraph at the end, is available in either; switching languages
doesn't change any of the astrology or the tarot draw itself, only how
it's worded.
## What happens every day ## What happens every day
Each day, the app does two independent things and then combines them: Each day, the app does two independent things and then combines them:
@@ -95,21 +101,51 @@ That wording is condensed from a public-domain 1920 astrology book
(Sepharial's *Transits and Planetary Periods*), the same way the tarot (Sepharial's *Transits and Planetary Periods*), the same way the tarot
card meanings are condensed from Waite's 1911 book. card meanings are condensed from Waite's 1911 book.
Every day — Quiet or Major — the app also reads out your full 10-card
Celtic Cross spread, position by position, each with that card's actual
meaning (drawn from the same Waite text as the rest of the reading), so
you get one complete, readable account of the spread rather than just
a list of card names.
After that spread, you get a short paragraph of actual guidance: how to
meet the day, given both the single biggest transit happening and your
own tarot spread. It looks at that transit's character (is it an easy
angle or a tense one?) together with your *Attitude* card — the
position Waite describes as "his position or attitude in the
circumstances", i.e. how you're currently approaching things — upright
or reversed, and gives a concrete stance to take today. It then names
your *Attitude* and *Outcome* cards again, this time spelling out what
each one actually means for the orientation it landed in — so instead
of just "your Outcome card is the Wheel of Fortune, reversed" you get
that card's real meaning alongside it. This is the one place the app
actually combines the astrology and the tarot into a single piece of
advice, rather than leaving you to read them side by side yourself, and
it's printed last, as the closing takeaway once you've seen the full
spread it's referring to.
The wording flexes with how big the day is: on a Major day it opens
with something like "With Pluto as today's dominant influence"; on a
quieter day with only a mild aspect in play it's softer, e.g. "With a
mild touch from Jupiter today" or, fainter still, "Mercury is only
faintly active today, but for what it's worth"; and on a genuinely
uneventful day with no aspects at all, there's no planet to name, so it
falls back to a plain read of your Attitude card's orientation alone
("There's no standout transit today - it's an astrologically quiet
day..."). The underlying stance advice itself doesn't change with
intensity — only how insistently it's introduced.
## What a day's reading actually gives you ## What a day's reading actually gives you
Put together, one day's reading contains: your natal chart (for Put together, one day's reading contains: your natal chart (for
reference), today's sky and how it's speaking to your chart, how reference), today's sky and how it's speaking to your chart, how
significant today is overall (with a plain-language note on each of the significant today is overall (with a plain-language note on each of the
main events driving that), and your 10-card Celtic Cross spread for the main events driving that), your full 10-card Celtic Cross spread read
day. Right now these sit side by side — the astrology tells you *how out card by card with each card's meaning, and — every day, not just
eventful* today is and *where* to pay attention, and the tarot spread Major ones — a closing paragraph of guidance tying the day's biggest
gives you the actual guidance to sit with. They don't yet get woven transit to your Attitude and Outcome cards specifically. Attitude and
into a single narrative (e.g. a spread explained *in light of* today's Outcome get that treatment in the closing guidance; the other eight
significant transit) — that combined storytelling is the next piece to positions are read in light of your natal chart and today's sky, the
build, not something you get today. way the worked example below does by hand.
*(This page, like that piece, is still a work in progress — expect it
to grow as the reading itself does.)*
## A worked example ## A worked example
@@ -123,7 +159,7 @@ Sagittarius.
are forming aspects back to the natal chart above — see below for which are forming aspects back to the natal chart above — see below for which
ones actually matter. ones actually matter.
**How big is today.** **How big is today, and the significant transits.**
``` ```
Day significance: Major (4/4) Day significance: Major (4/4)
@@ -149,18 +185,93 @@ from the 5th house, and a disruptive-but-possibly-lucky Uranus trine
from the 7th. Communication, romance, and relationships are all from the 7th. Communication, romance, and relationships are all
quietly under pressure at once. quietly under pressure at once.
**Tarot spread (excerpt).** Drawn independently, using today's date: **The Celtic Cross, read out in full** (drawn independently, using
today's date):
- *The Present* — The Emperor, reversed: immaturity or a loss of ```
authority, rather than confident structure. Celtic Cross:
- *The Challenge* — The Devil: force, ravage, an obstacle that isn't 1. The Present: The Emperor (Reversed)
easily reasoned with. This covers him: the general influence affecting the matter.
- *The Near Future* — The World, reversed: stagnation, things staying Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
fixed in place rather than resolving. 2. The Challenge: The Devil
This crosses him: the nature of the obstacle in the matter.
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
3. The Crown: The Empress
This crowns him: the aim or ideal, the best that can be achieved.
Fruitfulness, action, initiative, length of days; also difficulty, doubt, ignorance.
4. The Foundation: Justice
This is beneath him: the basis of the matter, already actual.
Equity, rightness, probity, executive; triumph of the deserving side in law.
5. The Recent Past: Temperance
This is behind him: the influence that is just passing away.
Economy, moderation, frugality, management, accommodation.
6. The Near Future: The World (Reversed)
This is before him: the influence now coming into action.
Inertia, fixity, stagnation, permanence.
7. Himself: The Magician
His position or attitude in the circumstances.
Skill, diplomacy, address, subtlety; self-confidence, will.
8. His House: The Hermit (Reversed)
His environment and the tendencies at work therein.
Concealment, disguise, policy, fear, unreasoned caution.
9. Hopes and Fears: The Moon (Reversed)
His hopes or fears in the matter.
Instability, inconstancy, silence, lesser degrees of deception and error.
10. The Outcome: Strength
What will come: the final result of the matter.
Power, energy, action, courage, magnanimity; complete success and honours.
```
**The closing guidance**, tying the day's biggest transit (Pluto,
above) to the Attitude and Outcome cards from the spread just shown:
```
With Pluto as today's dominant influence: Today concentrates whatever you already
bring to it - your current approach will be amplified, so make sure it's the one
you want.
Your Attitude card is The Magician: Skill, diplomacy, address, subtlety; self-confidence, will.
The spread's likely Outcome is Strength: Power, energy, action, courage, magnanimity; complete success and honours.
```
Read side by side, the astrology and the tarot happen to rhyme here — Read side by side, the astrology and the tarot happen to rhyme here —
both point toward something that wants to shift (Pluto's "hidden both point toward something that wants to shift (Pluto's "hidden
transformation," The Devil's "force") running into something that transformation," The Devil's "force") running into something that
feels stuck (a reversed World, an afflicted Moon) — but that's the feels stuck (a reversed World, an afflicted Moon). The closing guidance
reader's own synthesis for now, not something the app does for you (see picks up part of that thread automatically — an upright Attitude card
above). (The Magician) paired with this neutral, concentrating Pluto transit
becomes "make sure your current approach is the one you want" — reading
every other position (Challenge, Near Future, and the rest) into that
same story the way this paragraph just did by hand is the reader's own
synthesis (see above).
**A quieter day, for contrast.** Someone else — born 10 October 1990 in
Sydney, checking their reading on 15 January 2026 — gets a much lower-key
version of the same guidance, because the biggest thing happening that
day is a loose, single Jupiter aspect rather than an exact Pluto hit:
```
Day significance: Notable (2/4)
...
With a mild touch from Jupiter today: Your instincts are sound, but the day is
testing them - hold your position without forcing the issue.
Your Attitude card is The Sun: Material happiness, fortunate marriage, contentment.
The spread's likely Outcome is Justice: Equity, rightness, probity, executive; triumph of the deserving side in law.
```
Same stance logic (a tense aspect, an upright Attitude card), but the
opening line matches how much the day actually asks of you.
**The same Major day, in German.** Switching languages doesn't change
anything about the reading itself - same cards, same transits, same
guidance logic - only the words:
```
Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
Pluto ist heute der bestimmende Einfluss: Der heutige Tag verstärkt, was du bereits
mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher,
dass es die richtige ist.
Deine Haltungskarte ist Der Magier: Geschick, Diplomatie, Gewandtheit, Feinsinn; Selbstvertrauen, Wille.
Das wahrscheinliche Ergebnis des Blatts ist Kraft: Macht, Energie, Tatkraft, Mut, Großmut; voller Erfolg und Ehren.
```
+151 -12
View File
@@ -22,6 +22,12 @@ ui.transiting=Transit
ui.natal=natal ui.natal=natal
ui.orb=Orbis ui.orb=Orbis
# --- Nur die Watch-App (watch/src/c/main.c) - wird vor der ersten
# Einrichtung ueber die Konfigurationsseite gezeigt, wenn noch keine
# Geburtsdaten fuer eine Deutung vorliegen ---
ui.setup_required_title=Einrichtung erforderlich
ui.setup_required_body=Öffne die Einstellungen dieser App in der Pebble-App, um deine Geburtsdaten einzugeben, und starte Deck in a Dash danach erneut.
# --- Planeten (astro.c) --- # --- Planeten (astro.c) ---
body.sun=Sonne body.sun=Sonne
body.moon=Mond body.moon=Mond
@@ -65,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.
@@ -154,3 +188,108 @@ card.judgement.reversed=Schwäche, Kleinmut, Einfalt; auch Überlegung, Entschei
card.world.name=Die Welt card.world.name=Die Welt
card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Flucht, Ortswechsel. card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Flucht, Ortswechsel.
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) ---
interp.reading_date=Lesung für:
interp.day_significance=Bedeutung des Tages:
interp.day_level.quiet=Ruhig
interp.day_level.notable=Bemerkenswert
interp.day_level.significant=Bedeutend
interp.day_level.major=Einschneidend
interp.major_framing=(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
interp.top_transits.one=Wichtigster Transit:
interp.top_transits.many=Top %d wichtige Transits:
interp.no_notable_transits=Heute keine nennenswerten Transits.
interp.in_sign_house=in %s (Haus %d)
interp.score=Punktzahl
interp.page_title=Deck in a Dash - Interpretation
interp.heading=Tägliche Interpretation
interp.heading_day_significance=Bedeutung des Tages
interp.heading_transits=Wichtige Transits
interp.heading_guidance=Rat für heute
# --- Transit-Deutungen (interpreter/src/narrative.c), sinngemäße
# Übertragung aus Sepharials Transits and Planetary Periods (1920,
# gemeinfrei), Kapitel VIII "Effects of Transits" - eigene Übersetzung
# für dieses Projekt, keine Übernahme aus einer veröffentlichten
# deutschen Ausgabe. Mond und Pluto sind eigener, englischsprachig
# verfasster Text (siehe narrative.c) und hier ebenfalls frei übertragen. ---
narrative.house_frame=In Fragen von %s (Haus %d).
narrative.house.1=deines Selbstbilds, deiner Identität und deines äußeren Auftretens
narrative.house.2=Geld, Besitz und persönlicher Werte
narrative.house.3=Kommunikation, Geschwistern und alltäglichem Lernen
narrative.house.4=Zuhause, Familie und deiner Herkunft
narrative.house.5=Romantik, Kreativität und Kindern
narrative.house.6=täglicher Arbeit, Routine und Gesundheit
narrative.house.7=Partnerschaften und engen Beziehungen
narrative.house.8=gemeinsamen Ressourcen, Nähe und Wandel
narrative.house.9=Reisen, höherer Bildung und Überzeugungen
narrative.house.10=Karriere, Ansehen und öffentlichem Auftreten
narrative.house.11=Freundschaften, Gemeinschaft und Zukunftshoffnungen
narrative.house.12=Rückzug, dem Unbewussten und verborgenen Dingen
narrative.sun.base=
narrative.sun.harmonious=Vorteile durch Vorgesetzte und Aufstieg in deinem Lebens- und Arbeitsbereich - Ehrungen, Vergütungen und erfolgreiche neue Verbindungen.
narrative.sun.discordant=Herabsetzung und Unehre, Verlust der Stellung und ungünstige Beurteilung durch Vorgesetzte.
narrative.moon.base=Färbt den alltäglichen und häuslichen Lebensbereich, oft verbunden mit der Eröffnung neuer Wege.
narrative.moon.harmonious=Diese Veränderungen fallen eher vorteilhaft aus.
narrative.moon.discordant=Diese Veränderungen fallen eher ungünstig aus, mit etwas Unwohlsein oder häuslichen Reibereien.
narrative.mercury.base=Betrifft Schriftliches, Reisen, Handel und alltägliche Angelegenheiten - ein neutraler Bote, dessen Wirkung sich nach der Art des Aspekts richtet, den er bildet.
narrative.mercury.harmonious=
narrative.mercury.discordant=
narrative.venus.base=Rückt häusliche und gesellschaftliche Angelegenheiten in den Vordergrund - Glück, Annehmlichkeiten und Gunstbezeigungen.
narrative.venus.harmonious=Erfolg in Liebesangelegenheiten und künstlerischen Unternehmungen ist wahrscheinlich.
narrative.venus.discordant=Kummer und Enttäuschung sind wahrscheinlicher.
narrative.mars.base=Eine anstrengende Zeit voller Streit, Auseinandersetzungen und Ärger, mit einem gewissen Verletzungsrisiko je nach dem Zeichen, in dem er steht.
narrative.mars.harmonious=Kann Vorteile durch Ärzte, Chirurgen oder neue Projekte und Unternehmungen bringen.
narrative.mars.discordant=
narrative.jupiter.base=Bringt Zuwachs und Ausdehnung - Fülle an Glück und Gesundheit und eine insgesamt günstige Zeit.
narrative.jupiter.harmonious=
narrative.jupiter.discordant=
narrative.saturn.base=Bringt Niedergeschlagenheit, Stillstand, Hindernisse und Erschwernisse sowie eine gewisse Einbuße der gewohnten Vorteile.
narrative.saturn.harmonious=Gunstbezeigungen aus älteren Verbindungen und vergangenen Beziehungen sind weiterhin möglich.
narrative.saturn.discordant=
narrative.uranus.base=Bringt Trennungen, Entfremdungen, plötzliche Verschiebungen und heftige Erschütterungen.
narrative.uranus.harmonious=Erfolg auf amtlichem oder öffentlichem Weg sowie günstige Veränderungen oder Berufungen sind möglich.
narrative.uranus.discordant=
narrative.neptune.base=Bringt einen Zustand von Chaos und Verwirrung - eine verworrene, unsichere Lage der Dinge, mit Intrigen, Heimlichkeiten oder unsichtbaren Einflüssen am Werk.
narrative.neptune.harmonious=
narrative.neptune.discordant=
narrative.pluto.base=Bringt tiefgreifenden, oft verborgenen Wandel - das Auftauchen oder die Auflösung von etwas, das seine alte Form überwachsen hat.
narrative.pluto.harmonious=
narrative.pluto.discordant=
# --- Guidance (interpreter/src/guidance.c), eigener Text für dieses
# Projekt - siehe guidance.c. Bewusst in direkter "Du"-Anrede, anders
# als das "er/ihn" aus Waites eigenem Text oben - entspricht dem
# eigenen Registerwechsel des englischen Originaltexts für diesen
# neuen, unmittelbareren Inhalt. ---
guidance.stance.harmonious.upright=Du begegnest dem Tag bereits mit der richtigen Einstellung - vertraue darauf, statt eine günstige Lage in Frage zu stellen.
guidance.stance.harmonious.reversed=Der Tag selbst spielt dir in die Hände, auch wenn du dich noch nicht gefestigt fühlst - vertraue der Dynamik mehr als deinen derzeitigen Zweifeln.
guidance.stance.discordant.upright=Dein Gespür ist richtig, doch der Tag stellt es auf die Probe - halte deine Position, ohne die Sache zu erzwingen.
guidance.stance.discordant.reversed=Sowohl der Tag als auch dein eigener Stand sind gerade unsicher - jetzt ist der Moment, dich zu sammeln, bevor du weitergehst.
guidance.stance.neutral.upright=Der heutige Tag verstärkt, was du bereits mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher, dass es die richtige ist.
guidance.stance.neutral.reversed=Der heutige Tag verstärkt vieles, auch das, was in deiner eigenen Herangehensweise noch ungeklärt ist - kläre es lieber, bevor du handelst.
guidance.intro.quiet=%s ist heute nur schwach aktiv, aber immerhin:
guidance.intro.notable=%s wirkt heute nur leicht:
guidance.intro.significant=%s ist heute deutlich aktiv:
guidance.intro.major=%s ist heute der bestimmende Einfluss:
guidance.no_transit.upright=Heute gibt es keinen herausragenden Transit - astrologisch ist es ein ruhiger Tag. Das macht ihn vor allem zu einer Frage der Standfestigkeit: vertraue deinem jetzigen Stand und nutze die Ruhe, um voranzukommen.
guidance.no_transit.reversed=Heute gibt es keinen herausragenden Transit - astrologisch ist es ein ruhiger Tag. Das ist ein guter Moment, um in aller Ruhe deinen eigenen Stand neu zu finden, da nichts von außen das Tempo vorgibt.
guidance.attitude_intro=Deine Haltungskarte ist
guidance.outcome_intro=Das wahrscheinliche Ergebnis des Blatts ist
# Planetennamen für guidance.intro.* oben - eine eigene Tabelle
# getrennt von body.*, da Sonne und Mond im Deutschen (wie im
# Englischen) einen bestimmten Artikel erhalten, die übrigen acht
# Planeten aber nicht.
guidance.planet.sun=die Sonne
guidance.planet.moon=der Mond
guidance.planet.mercury=Merkur
guidance.planet.venus=Venus
guidance.planet.mars=Mars
guidance.planet.jupiter=Jupiter
guidance.planet.saturn=Saturn
guidance.planet.uranus=Uranus
guidance.planet.neptune=Neptun
guidance.planet.pluto=Pluto
+147 -12
View File
@@ -29,6 +29,12 @@ ui.transiting=Transiting
ui.natal=natal ui.natal=natal
ui.orb=orb ui.orb=orb
# --- Watch app only (watch/src/c/main.c) - shown before the first
# config-page submission, when there's no birth data yet to compute a
# reading from ---
ui.setup_required_title=Setup Required
ui.setup_required_body=Open this app's settings from the Pebble mobile app to add your birth details, then reopen Deck in a Dash.
# --- Planets/luminaries (astro.c) --- # --- Planets/luminaries (astro.c) ---
body.sun=Sun body.sun=Sun
body.moon=Moon body.moon=Moon
@@ -73,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.
@@ -163,3 +196,105 @@ card.judgement.reversed=Weakness, pusillanimity, simplicity; also deliberation,
card.world.name=The World card.world.name=The World
card.world.upright=Assured success, recompense, voyage, route, emigration, flight, change of place. card.world.upright=Assured success, recompense, voyage, route, emigration, flight, change of place.
card.world.reversed=Inertia, fixity, stagnation, permanence. card.world.reversed=Inertia, fixity, stagnation, permanence.
# --- Interpreter UI labels (interpreter/src/main.c) ---
interp.reading_date=Reading for:
interp.day_significance=Day significance:
interp.day_level.quiet=Quiet
interp.day_level.notable=Notable
interp.day_level.significant=Significant
interp.day_level.major=Major
interp.major_framing=(a major transit today - worth a deeper Celtic Cross look)
interp.top_transits.one=Top significant transit:
interp.top_transits.many=Top %d significant transits:
interp.no_notable_transits=No notable transits today.
interp.in_sign_house=in %s (house %d)
interp.score=score
interp.page_title=Deck in a Dash - Interpretation
interp.heading=Daily Interpretation
interp.heading_day_significance=Day Significance
interp.heading_transits=Significant Transits
interp.heading_guidance=Guidance
# --- Transit narratives (interpreter/src/narrative.c), condensed from
# Sepharial's Transits and Planetary Periods (1920, public domain),
# Chapter VIII "Effects of Transits" - except Moon and Pluto, which are
# original (see narrative.c's own comment for why) ---
narrative.house_frame=In matters of %s (house %d).
narrative.house.1=your sense of self, identity, and outward appearance
narrative.house.2=money, possessions, and personal values
narrative.house.3=communication, siblings, and everyday learning
narrative.house.4=home, family, and your roots
narrative.house.5=romance, creativity, and children
narrative.house.6=daily work, routine, and health
narrative.house.7=partnerships and close relationships
narrative.house.8=shared resources, intimacy, and transformation
narrative.house.9=travel, higher learning, and beliefs
narrative.house.10=career, reputation, and public standing
narrative.house.11=friendships, community, and hopes for the future
narrative.house.12=solitude, the subconscious, and hidden matters
narrative.sun.base=
narrative.sun.harmonious=Benefits from superiors and advancement in your sphere of life and work - honours, emoluments, and successful new associations.
narrative.sun.discordant=Degradation and dishonour, loss of position, and adverse judgement from superiors.
narrative.moon.base=Colours the everyday and domestic sphere of life, often coinciding with the opening of new avenues.
narrative.moon.harmonious=These changes tend to be advantageous.
narrative.moon.discordant=These changes tend to be adverse, with some indisposition or domestic friction.
narrative.mercury.base=Affects writings, journeys, commerce, and everyday activities - a neutral messenger whose effect follows the nature of the aspect it makes.
narrative.mercury.harmonious=
narrative.mercury.discordant=
narrative.venus.base=Brings domestic and social affairs to the fore - happiness, comforts, and favours.
narrative.venus.harmonious=Success in love affairs and artistic pursuits is likely.
narrative.venus.discordant=Grief and disappointment are more likely.
narrative.mars.base=A strenuous time of quarrels, contention, strife and anger, with some risk of hurts or injuries depending on the sign it occupies.
narrative.mars.harmonious=Can bring benefits from doctors, surgeons, or new projects and enterprises.
narrative.mars.discordant=
narrative.jupiter.base=Brings increase and expansion - fullness of fortune and health, and a generally fortunate time.
narrative.jupiter.harmonious=
narrative.jupiter.discordant=
narrative.saturn.base=Brings depression, stagnation, hindrances and obstacles, and some deprivation of the usual benefits.
narrative.saturn.harmonious=Favours from older connections and past associations are still possible.
narrative.saturn.discordant=
narrative.uranus.base=Brings separations, estrangements, sudden dislocations and violent upsets.
narrative.uranus.harmonious=Success through official or civic channels, and beneficial changes or appointments, are possible.
narrative.uranus.discordant=
narrative.neptune.base=Brings a state of chaos and confusion - an involved, uncertain condition of affairs, with plots, subtlety, or unseen influences at work.
narrative.neptune.harmonious=
narrative.neptune.discordant=
narrative.pluto.base=Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form.
narrative.pluto.harmonious=
narrative.pluto.discordant=
# --- Guidance (interpreter/src/guidance.c), original text written for
# this project - see guidance.c's own comment for why. Deliberately
# direct "you/your" address, unlike the "him/his" register used for
# Waite's own text above - matches the English original's own register
# shift for this new, more conversational content. ---
guidance.stance.harmonious.upright=You're already meeting the day in the right spirit - lean into it rather than second-guessing a favorable position.
guidance.stance.harmonious.reversed=The day itself is working in your favor even though you don't feel settled yet - trust the momentum more than your current doubts.
guidance.stance.discordant.upright=Your instincts are sound, but the day is testing them - hold your position without forcing the issue.
guidance.stance.discordant.reversed=Both the day and your own footing are unsettled right now - this is a moment to steady yourself before pushing forward.
guidance.stance.neutral.upright=Today concentrates whatever you already bring to it - your current approach will be amplified, so make sure it's the one you want.
guidance.stance.neutral.reversed=Today intensifies things, including whatever is currently unresolved in your own approach - worth sorting out before acting.
guidance.intro.quiet=%s is only faintly active today, but for what it's worth:
guidance.intro.notable=With a mild touch from %s today:
guidance.intro.significant=With %s clearly active today:
guidance.intro.major=With %s as today's dominant influence:
guidance.no_transit.upright=There's no standout transit today - it's an astrologically quiet day. That leaves things mostly about steadiness: trust your current footing and use the calm to make headway.
guidance.no_transit.reversed=There's no standout transit today - it's an astrologically quiet day. That's a good moment to quietly re-settle your own footing, since nothing external is forcing the pace.
guidance.attitude_intro=Your Attitude card is
guidance.outcome_intro=The spread's likely Outcome is
# Planet names as used in guidance.intro.* above - a separate table from
# body.* because English (and German) give the Sun/Moon a definite
# article that the other eight planets don't take.
guidance.planet.sun=the Sun
guidance.planet.moon=the Moon
guidance.planet.mercury=Mercury
guidance.planet.venus=Venus
guidance.planet.mars=Mars
guidance.planet.jupiter=Jupiter
guidance.planet.saturn=Saturn
guidance.planet.uranus=Uranus
guidance.planet.neptune=Neptune
guidance.planet.pluto=Pluto
+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"
+17 -3
View File
@@ -3,6 +3,12 @@
# "today": same OS-clock seed/transit-moment and user.properties birth # "today": same OS-clock seed/transit-moment and user.properties birth
# data as run-engine.sh, but reports how significant today's transits are # data as run-engine.sh, but reports how significant today's transits are
# instead of printing the full reading. Usage: ./run-interpreter.sh # instead of printing the full reading. Usage: ./run-interpreter.sh
# [text|html|json] [lang]
#
# [lang] overrides user.properties' lang= for this run only, same as
# run-engine.sh - but note it only affects interpreter-cli's own output;
# deck-engine is always run without --lang here, since --format json is
# deliberately language-independent (see docs/input-output-format.md).
# #
# Requires both dist/deck-engine (built by engine/'s Makefile) and # Requires both dist/deck-engine (built by engine/'s Makefile) and
# dist/interpreter-cli (built by interpreter/'s own Makefile) to already # dist/interpreter-cli (built by interpreter/'s own Makefile) to already
@@ -35,6 +41,8 @@ birth_time=""
birth_utc_offset="" birth_utc_offset=""
birth_lat="" birth_lat=""
birth_lon="" birth_lon=""
gender="unspecified"
lang="en"
while IFS='=' read -r key value || [ -n "$key" ]; do while IFS='=' read -r key value || [ -n "$key" ]; do
key="$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" key="$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
@@ -48,6 +56,8 @@ 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" ;;
esac esac
done <"$PROPERTIES_FILE" done <"$PROPERTIES_FILE"
@@ -67,10 +77,13 @@ check_set birth_lon "$birth_lon"
# OS-provided inputs: today's date is the tarot seed (stable all day, a # OS-provided inputs: today's date is the tarot seed (stable all day, a
# new spread each day), current UTC time drives today's transits. The # new spread each day), current UTC time drives today's transits. The
# tarot seed doesn't matter for --format json (only "transits" is read # tarot seed still matters for --format json now that "spread" is read
# downstream) but it's kept identical to run-engine.sh's for consistency. # downstream too (guidance.c's Attitude/Outcome framing) - kept identical
# to run-engine.sh's for consistency either way.
seed="$(date +%Y-%m-%d)" seed="$(date +%Y-%m-%d)"
transit_date="$(date -u +%Y-%m-%dT%H:%M)" transit_date="$(date -u +%Y-%m-%dT%H:%M)"
format="${1:-text}"
lang="${2:-$lang}"
"$ENGINE_BINARY" \ "$ENGINE_BINARY" \
--seed "$seed" \ --seed "$seed" \
@@ -81,4 +94,5 @@ transit_date="$(date -u +%Y-%m-%dT%H:%M)"
--birth-lon "$birth_lon" \ --birth-lon "$birth_lon" \
--date "$transit_date" \ --date "$transit_date" \
--format json \ --format json \
| exec "$INTERPRETER_BINARY" --gender "$gender" \
| 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
+104 -25
View File
@@ -1,14 +1,22 @@
#define _POSIX_C_SOURCE 200809L /* for gmtime_r */
#include "astro.h" #include "astro.h"
#include "i18n.h" #include "i18n.h"
/* The watch build (PBL_SDK_3) uses a compact low-precision ephemeris
* instead of the vendored Astronomy Engine, which at ~127KB of compiled
* code doesn't fit a whole Pebble app's ~64KB budget - see
* lowprec_ephemeris.h's own doc comment. AstroTime abstracts over the
* two representations of "a moment in time" this file can be built
* against: a Julian Date (double) for the watch, or the vendored
* library's own astro_time_t on desktop. Every function below that takes
* or returns a time branches internally on PBL_SDK_3, but keeps the same
* name/signature/call sites either way, so the two implementations stay
* easy to compare line-by-line. */
#ifdef PBL_SDK_3
#include "lowprec_ephemeris.h"
typedef double AstroTime;
#else
#include "../third_party/astronomy/astronomy.h" #include "../third_party/astronomy/astronomy.h"
typedef astro_time_t AstroTime;
#include <math.h>
#include <stdbool.h>
#include <string.h>
/* DEG2RAD / RAD2DEG come from astronomy.h. */
static const astro_body_t k_astro_body[NUM_BODIES] = { static const astro_body_t k_astro_body[NUM_BODIES] = {
[PLANET_SUN] = BODY_SUN, [PLANET_SUN] = BODY_SUN,
@@ -22,6 +30,20 @@ static const astro_body_t k_astro_body[NUM_BODIES] = {
[PLANET_NEPTUNE] = BODY_NEPTUNE, [PLANET_NEPTUNE] = BODY_NEPTUNE,
[PLANET_PLUTO] = BODY_PLUTO, [PLANET_PLUTO] = BODY_PLUTO,
}; };
#endif
#include <math.h>
#include <stdbool.h>
#include <string.h>
/* Not M_PI - see lowprec_ephemeris.c's own comment on why. Only needed
* (and only defined) on the watch path; astronomy.h supplies these on
* desktop, as before. */
#ifdef PBL_SDK_3
#define DECK_PI 3.14159265358979323846
#define DEG2RAD (DECK_PI / 180.0)
#define RAD2DEG (180.0 / DECK_PI)
#endif
static double normalize_degrees(double deg) { static double normalize_degrees(double deg) {
double d = fmod(deg, 360.0); double d = fmod(deg, 360.0);
@@ -36,45 +58,91 @@ static void longitude_to_position(double longitude, PlanetPosition *out) {
/* Mean obliquity of the ecliptic (IAU low-precision polynomial), matching /* Mean obliquity of the ecliptic (IAU low-precision polynomial), matching
* what Astronomy Engine computes internally in its (non-exported) * what Astronomy Engine computes internally in its (non-exported)
* mean_obliq(). T is Julian centuries since J2000.0. */ * mean_obliq(). T is Julian centuries since J2000.0. A slow, purely
* time-based polynomial, not subject to orbital approximation error -
* shared unchanged by both ephemeris implementations. */
static double mean_obliquity_deg(double t_centuries) { static double mean_obliquity_deg(double t_centuries) {
double t = t_centuries; double t = t_centuries;
return 23.4392911 - 0.0130042 * t - 0.00000016 * t * t + 0.000000504 * t * t * t; return 23.4392911 - 0.0130042 * t - 0.00000016 * t * t + 0.000000504 * t * t * t;
} }
static double julian_centuries_since_j2000(const AstroTime *time) {
#ifdef PBL_SDK_3
return (*time - 2451545.0) / 36525.0;
#else
return time->tt / 36525.0;
#endif
}
static double sidereal_time_hours(AstroTime *time) {
#ifdef PBL_SDK_3
return lowprec_gmst_hours(*time);
#else
return Astronomy_SiderealTime(time);
#endif
}
/* Ascendant (rising ecliptic degree), via the standard RAMC/obliquity/ /* Ascendant (rising ecliptic degree), via the standard RAMC/obliquity/
* latitude identity (Duffett-Smith & Zwart, "Practical Astronomy with * latitude identity (Duffett-Smith & Zwart, "Practical Astronomy with
* your Calculator or Spreadsheet"). Astronomy Engine has no built-in * your Calculator or Spreadsheet"). Neither ephemeris implementation has
* Ascendant function. */ * a built-in Ascendant function. */
static double compute_ascendant(astro_time_t *time, double latitude_deg, static double compute_ascendant(AstroTime *time, double latitude_deg,
double longitude_deg) { double longitude_deg) {
double gast_hours = Astronomy_SiderealTime(time); double gast_hours = sidereal_time_hours(time);
double ramc_deg = normalize_degrees(gast_hours * 15.0 + longitude_deg); double ramc_deg = normalize_degrees(gast_hours * 15.0 + longitude_deg);
double eps_deg = mean_obliquity_deg(time->tt / 36525.0); double eps_deg = mean_obliquity_deg(julian_centuries_since_j2000(time));
double ramc = ramc_deg * DEG2RAD; double ramc = ramc_deg * DEG2RAD;
double eps = eps_deg * DEG2RAD; double eps = eps_deg * DEG2RAD;
double lat = latitude_deg * DEG2RAD; double lat = latitude_deg * DEG2RAD;
/* tan(lat) as sin(lat)/cos(lat) - no <math.h> tan() needed. */
#ifdef PBL_SDK_3
double y = -lowprec_cos(ramc);
double x = lowprec_sin(ramc) * lowprec_cos(eps) +
(lowprec_sin(lat) / lowprec_cos(lat)) * lowprec_sin(eps);
return normalize_degrees(lowprec_atan2(y, x) * RAD2DEG);
#else
double y = -cos(ramc); double y = -cos(ramc);
double x = sin(ramc) * cos(eps) + tan(lat) * sin(eps); double x = sin(ramc) * cos(eps) + (sin(lat) / cos(lat)) * sin(eps);
return normalize_degrees(atan2(y, x) * RAD2DEG); return normalize_degrees(atan2(y, x) * RAD2DEG);
#endif
} }
static astro_time_t time_from_birth(const BirthData *birth) { static AstroTime time_from_birth(const BirthData *birth) {
#ifdef PBL_SDK_3
double jd = lowprec_julian_date(birth->year, birth->month, birth->day,
birth->hour, birth->minute, 0.0);
return jd - birth->utc_offset_hours / 24.0;
#else
astro_time_t local = Astronomy_MakeTime(birth->year, birth->month, birth->day, astro_time_t local = Astronomy_MakeTime(birth->year, birth->month, birth->day,
birth->hour, birth->minute, 0.0); birth->hour, birth->minute, 0.0);
return Astronomy_AddDays(local, -birth->utc_offset_hours / 24.0); return Astronomy_AddDays(local, -birth->utc_offset_hours / 24.0);
#endif
} }
static astro_time_t time_from_unix(time_t utc_moment) { /* Plain gmtime() (not the POSIX-only gmtime_r()) - deliberately: this
struct tm tm_utc; * project is single-threaded everywhere it runs, including on the watch,
gmtime_r(&utc_moment, &tm_utc); * where only the non-reentrant classic C library functions are provided
return Astronomy_MakeTime(tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday, * (see astro.h's own include-guard comment). Used immediately, so its
tm_utc.tm_hour, tm_utc.tm_min, (double)tm_utc.tm_sec); * static internal buffer is never a hazard here. */
static AstroTime time_from_unix(time_t utc_moment) {
struct tm *tm_utc = gmtime(&utc_moment);
#ifdef PBL_SDK_3
return lowprec_julian_date(tm_utc->tm_year + 1900, tm_utc->tm_mon + 1, tm_utc->tm_mday,
tm_utc->tm_hour, tm_utc->tm_min, (double)tm_utc->tm_sec);
#else
return Astronomy_MakeTime(tm_utc->tm_year + 1900, tm_utc->tm_mon + 1, tm_utc->tm_mday,
tm_utc->tm_hour, tm_utc->tm_min, (double)tm_utc->tm_sec);
#endif
} }
static void compute_body_positions(astro_time_t time, PlanetPosition out[NUM_BODIES]) { static void compute_body_positions(AstroTime time, PlanetPosition out[NUM_BODIES]) {
#ifdef PBL_SDK_3
for (int b = 0; b < NUM_BODIES; b++) {
longitude_to_position(lowprec_geocentric_longitude((Body)b, time), &out[b]);
}
#else
/* Astronomy_EclipticLongitude() computes *heliocentric* longitude (and /* Astronomy_EclipticLongitude() computes *heliocentric* longitude (and
* outright rejects BODY_SUN) - wrong for astrology, which needs the * outright rejects BODY_SUN) - wrong for astrology, which needs the
* apparent geocentric position. Astronomy_GeoVector() + Astronomy_Ecliptic() * apparent geocentric position. Astronomy_GeoVector() + Astronomy_Ecliptic()
@@ -84,6 +152,7 @@ static void compute_body_positions(astro_time_t time, PlanetPosition out[NUM_BOD
astro_ecliptic_t eclip = Astronomy_Ecliptic(geo); astro_ecliptic_t eclip = Astronomy_Ecliptic(geo);
longitude_to_position(eclip.elon, &out[b]); longitude_to_position(eclip.elon, &out[b]);
} }
#endif
} }
/* Whole-sign house number (1-12) for `sign`, counting from `ascendant_sign` /* Whole-sign house number (1-12) for `sign`, counting from `ascendant_sign`
@@ -101,7 +170,7 @@ static void assign_houses(PlanetPosition bodies[NUM_BODIES], ZodiacSign ascendan
} }
void astro_compute_natal_chart(const BirthData *birth, NatalChart *out) { void astro_compute_natal_chart(const BirthData *birth, NatalChart *out) {
astro_time_t time = time_from_birth(birth); AstroTime time = time_from_birth(birth);
compute_body_positions(time, out->bodies); compute_body_positions(time, out->bodies);
out->ascendant_longitude = compute_ascendant(&time, birth->latitude, birth->longitude); out->ascendant_longitude = compute_ascendant(&time, birth->latitude, birth->longitude);
@@ -142,13 +211,23 @@ static double orb_for_pair(Body transiting, Body natal) {
void astro_compute_daily_transits(time_t utc_moment, const NatalChart *natal, void astro_compute_daily_transits(time_t utc_moment, const NatalChart *natal,
DailyTransits *out) { DailyTransits *out) {
astro_time_t time = time_from_unix(utc_moment); AstroTime time = time_from_unix(utc_moment);
compute_body_positions(time, out->bodies); compute_body_positions(time, out->bodies);
assign_houses(out->bodies, natal->houses[0]); assign_houses(out->bodies, natal->houses[0]);
#ifdef PBL_SDK_3
/* Sun-to-Moon angle directly from the positions just computed above -
* same convention as Astronomy_MoonPhase() (0 = new, 90 = first
* quarter, 180 = full, 270 = last quarter), so moon_phase_from_angle()
* below needs no platform-specific branch of its own. */
double phase_angle = normalize_degrees(out->bodies[PLANET_MOON].ecliptic_longitude -
out->bodies[PLANET_SUN].ecliptic_longitude);
#else
astro_angle_result_t phase = Astronomy_MoonPhase(time); astro_angle_result_t phase = Astronomy_MoonPhase(time);
out->moon_phase = moon_phase_from_angle(phase.angle); double phase_angle = phase.angle;
#endif
out->moon_phase = moon_phase_from_angle(phase_angle);
out->aspect_count = 0; out->aspect_count = 0;
for (int t = 0; t < NUM_BODIES; t++) { for (int t = 0; t < NUM_BODIES; t++) {
+10
View File
@@ -1,7 +1,17 @@
#ifndef DECK_ASTRO_H #ifndef DECK_ASTRO_H
#define DECK_ASTRO_H #define DECK_ASTRO_H
/* Pebble's SDK pre-defines <time.h>'s own include guard (so a real
* #include <time.h> is a silent no-op) and instead declares struct
* tm/time_t/gmtime()/etc. itself, inside pebble.h - see PBL_SDK_3,
* predefined by the Pebble build for every target platform. Desktop
* builds (deck-engine, the interpreter, and this file's own tests) use
* plain <time.h> as normal. */
#ifdef PBL_SDK_3
#include <pebble.h>
#else
#include <time.h> #include <time.h>
#endif
#define NUM_BODIES 10 #define NUM_BODIES 10
#define MAX_ASPECTS 100 #define MAX_ASPECTS 100
+32 -5
View File
@@ -1,12 +1,33 @@
#include "i18n.h" #include "i18n.h"
#include <stdio.h>
#include <string.h> #include <string.h>
/* Fixed-size, no heap allocation - deliberately (see i18n.h): keeps this /* i18n_load_table()'s catalog: just three pointers, always compiled -
* usable in a small embedded environment later, not just on desktop. * negligible RAM regardless of platform. Deliberately zero-copy: the
* ~140 keys are shipped today (see engine/i18n/en.lang); this leaves * watch's compiled-in translation tables already live in flash as
* plenty of headroom for more languages/keys without growing the format. */ * `static const char *const` arrays, so i18n_get() reads them in place
* rather than copying ~140 keys/values into RAM the watch doesn't have
* (see i18n_load()'s g_entries[] below, sized for desktop use only). A
* later i18n_load_table() call (e.g. switching language) simply
* re-points these, no free() needed since nothing was ever copied. */
static const char *const *g_table_keys;
static const char *const *g_table_values;
static int g_table_count = 0;
void i18n_load_table(const char *const *keys, const char *const *values, int count) {
g_table_keys = keys;
g_table_values = values;
g_table_count = count;
}
#ifndef PBL_SDK_3
#include <stdio.h>
/* Fixed-size, no heap allocation. ~140 keys are shipped today (see
* engine/i18n/en.lang); this leaves plenty of headroom for more
* languages/keys without growing the format. Desktop-only: at ~140KB
* this is far too large for the watch's own RAM budget, which is why
* the watch instead uses i18n_load_table()'s zero-copy path above. */
#define I18N_MAX_ENTRIES 320 #define I18N_MAX_ENTRIES 320
#define I18N_MAX_KEY 48 #define I18N_MAX_KEY 48
#define I18N_MAX_VALUE 400 #define I18N_MAX_VALUE 400
@@ -67,10 +88,16 @@ bool i18n_load(const char *path) {
fclose(f); fclose(f);
return true; return true;
} }
#endif /* PBL_SDK_3 */
const char *i18n_get(const char *key, const char *fallback) { const char *i18n_get(const char *key, const char *fallback) {
for (int i = 0; i < g_table_count; i++) {
if (strcmp(g_table_keys[i], key) == 0) return g_table_values[i];
}
#ifndef PBL_SDK_3
for (int i = 0; i < g_entry_count; i++) { for (int i = 0; i < g_entry_count; i++) {
if (strcmp(g_entries[i].key, key) == 0) return g_entries[i].value; if (strcmp(g_entries[i].key, key) == 0) return g_entries[i].value;
} }
#endif
return fallback; return fallback;
} }
+20 -4
View File
@@ -9,11 +9,27 @@
* file can't be opened (the catalog is left as it was in that case). * file can't be opened (the catalog is left as it was in that case).
* *
* This uses stdio, so - like main.c and reading_print_text/_html - it's * This uses stdio, so - like main.c and reading_print_text/_html - it's
* a desktop-only entry point, not something the Pebble watchapp will * a desktop-only entry point: Pebble's SDK doesn't provide fopen/fgets
* call as-is. i18n_get() below is a pure table lookup, so it's fine to * at all (a compile-time error, not just a runtime failure), so this
* port; the watchapp will just need its own (non-file-based) way to * function isn't even compiled when building for the watch (guarded
* populate the catalog, e.g. from a compiled-in resource. */ * #ifndef PBL_SDK_3 in i18n.c) - see i18n_load_table() below, its
* watch-side equivalent. */
#ifndef PBL_SDK_3
bool i18n_load(const char *path); bool i18n_load(const char *path);
#endif
/* The watch's non-file-based equivalent of i18n_load(): points i18n_get()
* at two parallel arrays (keys[i] -> values[i], `count` entries) instead
* of parsing a file - for a compiled-in translation table generated from
* engine/i18n's .lang files at watch-app build time (see the watch
* app's own build tooling). Zero-copy - just stores the three pointers -
* so `keys`/`values` must outlive the call (a `static const char *const`
* array is the expected caller, living in flash/ROM, not a local/heap
* buffer). This is what keeps the watch from needing i18n_load()'s
* ~140KB g_entries[] catalog, which its RAM budget can't afford.
* Available on every platform, since it has no platform-specific
* dependencies, but only the watch actually calls it today. */
void i18n_load_table(const char *const *keys, const char *const *values, int count);
/* Returns the translation for `key` from the loaded catalog, or /* Returns the translation for `key` from the loaded catalog, or
* `fallback` if no catalog is loaded or `key` isn't in it. Every caller * `fallback` if no catalog is loaded or `key` isn't in it. Every caller
+345
View File
@@ -0,0 +1,345 @@
#include "lowprec_ephemeris.h"
#include <math.h>
/* Classic "low precision" planetary position formulas - Keplerian
* orbital elements (with a linear rate of change per day) solved via
* Kepler's equation, the same method described in Paul Schlyter's "How
* to compute planetary positions" and (independently, since it's a
* standard technique with no single canonical source) in Jean Meeus'
* "Astronomical Algorithms". Elements are as of epoch J2000.0.
*
* Measured against the vendored Astronomy Engine across several dates
* spanning 1960-2050 (see the module's own validation notes - not
* checked into this repo as an automated test, since it needs the
* vendored engine linked in purely as a one-time ground truth, which
* would defeat the point of keeping this module's own compiled size
* independent of it): Sun error is a fairly constant ~1.4-1.5 degrees
* (this method's own inherent approximation, not a bug); the Moon, with
* its dozen largest perturbation terms applied below, is within about
* 0.1-0.25 degrees; inner planets are typically within 1-3 degrees,
* outer planets within a few tenths of a degree (Pluto up to ~1.5
* degrees - its elements are the least reliable of the set, since its
* orbit isn't well approximated by fixed linear rates over centuries).
* GMST (lowprec_gmst_hours(), for the Ascendant) matched to within
* 0.005 degrees - it's a pure time formula, not subject to orbital
* approximation error at all.
*
* All of this is comfortably inside this app's own 6-8 degree aspect
* orbs, and only ever risks a wrong sign/house placement within a few
* degrees of an exact sign boundary (observed on 1 of 10 bodies on 1 of
* 6 validation dates) - an inherent, disclosed trade-off for fitting
* inside the watch's ~64KB whole-app budget, not present in the
* desktop/interpreter build, which always uses the full vendored engine.
*
* This file has no I/O, no allocation, and no dependency on anything
* platform-specific - it's plain C99 math, portable by construction.
*
* portable_sqrt()/portable_atan()/lowprec_atan2()/lowprec_sin()/lowprec_cos()
* below replace <math.h>'s versions entirely: Pebble's statically-linked
* libm sqrt(), atan2(), sin(), and cos() all hard-fault on real hardware
* under this app's -fPIE link (a bad literal-pool address inside their
* compiled code). fmod() is unaffected and used freely. */
/* Not M_PI - it's a BSD/POSIX math.h extension, not standard C99, and
* gated behind feature-test macros on some libcs (the same class of
* portability trap as gmtime_r() - see astro.c's own comment). */
#define DECK_PI 3.14159265358979323846
#define DEG2RAD (DECK_PI / 180.0)
#define RAD2DEG (180.0 / DECK_PI)
static double normalize_deg(double deg) {
double d = fmod(deg, 360.0);
return d < 0.0 ? d + 360.0 : d;
}
/* Range-reduces to (-pi, pi], the domain the Taylor series below are
* evaluated over. */
static double reduce_to_pi(double rad) {
double r = fmod(rad, 2.0 * DECK_PI);
if (r < 0.0) r += 2.0 * DECK_PI;
if (r > DECK_PI) r -= 2.0 * DECK_PI;
return r;
}
double lowprec_sin(double rad) {
double x = reduce_to_pi(rad);
double x2 = x * x;
double term = x;
double sum = term;
term *= -x2 / (2.0 * 3.0); sum += term; /* -x^3/3! */
term *= -x2 / (4.0 * 5.0); sum += term; /* +x^5/5! */
term *= -x2 / (6.0 * 7.0); sum += term; /* -x^7/7! */
term *= -x2 / (8.0 * 9.0); sum += term; /* +x^9/9! */
term *= -x2 / (10.0 * 11.0); sum += term; /* -x^11/11! */
term *= -x2 / (12.0 * 13.0); sum += term; /* +x^13/13! */
term *= -x2 / (14.0 * 15.0); sum += term; /* -x^15/15! */
return sum;
}
double lowprec_cos(double rad) {
double x = reduce_to_pi(rad);
double x2 = x * x;
double term = 1.0;
double sum = term;
term *= -x2 / (1.0 * 2.0); sum += term; /* -x^2/2! */
term *= -x2 / (3.0 * 4.0); sum += term; /* +x^4/4! */
term *= -x2 / (5.0 * 6.0); sum += term; /* -x^6/6! */
term *= -x2 / (7.0 * 8.0); sum += term; /* +x^8/8! */
term *= -x2 / (9.0 * 10.0); sum += term; /* -x^10/10! */
term *= -x2 / (11.0 * 12.0); sum += term; /* +x^12/12! */
term *= -x2 / (13.0 * 14.0); sum += term; /* -x^14/14! */
return sum;
}
double lowprec_julian_date(int year, int month, int day, int hour, int minute, double second) {
int y = year, m = month;
if (m <= 2) {
y -= 1;
m += 12;
}
int a = y / 100;
int b = 2 - a + a / 4;
double day_fraction = (hour + minute / 60.0 + second / 3600.0) / 24.0;
return (double)(int)(365.25 * (y + 4716)) + (double)(int)(30.6001 * (m + 1)) +
day + day_fraction + b - 1524.5;
}
double lowprec_gmst_hours(double jd) {
/* Meeus 12.4, dropping the T^2/T^3 terms (fractions of a second even
* over centuries - far below this module's own precision floor). */
double gmst_deg = normalize_deg(280.46061837 + 360.98564736629 * (jd - 2451545.0));
return gmst_deg / 15.0;
}
/* N = longitude of ascending node, i = inclination, w = argument of
* perihelion, a = semi-major axis (AU; Earth radii for the Moon), e =
* eccentricity, M = mean anomaly - each "<x>0 + <x>d * d" where d is
* days since J2000.0. The Sun's "orbit" here is really Earth's own
* heliocentric orbit (N = i = 0, so the Sun's geocentric position falls
* straight out of the same flat 2-body solver used for everything
* else); Earth's own heliocentric position for geocentrizing the other
* planets is simply the Sun's position negated. */
typedef struct {
double N0, Nd;
double i0, id;
double w0, wd;
double a0, ad;
double e0, ed;
double M0, Md;
} OrbitalElements;
static const OrbitalElements k_elements[NUM_BODIES] = {
[PLANET_SUN] = {
0.0, 0.0, 0.0, 0.0,
282.9404, 4.70935e-5,
1.000000, 0.0,
0.016709, -1.151e-9,
356.0470, 0.9856002585,
},
[PLANET_MOON] = {
125.1228, -0.0529538083,
5.1454, 0.0,
318.0634, 0.1643573223,
60.2666, 0.0,
0.054900, 0.0,
134.9634, 13.0649929509,
},
[PLANET_MERCURY] = {
48.3313, 3.24587e-5,
7.0047, 5.00e-8,
29.1241, 1.01444e-5,
0.387098, 0.0,
0.205635, 5.59e-10,
168.6562, 4.0923344368,
},
[PLANET_VENUS] = {
76.6799, 2.46590e-5,
3.3946, 2.75e-8,
54.8910, 1.38374e-5,
0.723330, 0.0,
0.006773, -1.302e-9,
48.0052, 1.6021302244,
},
[PLANET_MARS] = {
49.5574, 2.11081e-5,
1.8497, -1.78e-8,
286.5016, 2.92961e-5,
1.523688, 0.0,
0.093405, 2.516e-9,
18.6021, 0.5240207766,
},
[PLANET_JUPITER] = {
100.4542, 2.76854e-5,
1.3030, -1.557e-7,
273.8777, 1.64505e-5,
5.20256, 0.0,
0.048498, 4.469e-9,
19.8950, 0.0830853001,
},
[PLANET_SATURN] = {
113.6634, 2.38980e-5,
2.4886, -1.081e-7,
339.3939, 2.97661e-5,
9.55475, 0.0,
0.055546, -9.499e-9,
316.9670, 0.0334442282,
},
[PLANET_URANUS] = {
74.0005, 1.3978e-5,
0.7733, 1.9e-8,
96.6612, 3.0565e-5,
19.18171, -1.55e-8,
0.047318, 7.45e-9,
142.5905, 0.011725806,
},
[PLANET_NEPTUNE] = {
131.7806, 3.0173e-5,
1.7700, -2.55e-7,
272.8461, -6.027e-6,
30.05826, 3.313e-8,
0.008606, 2.15e-9,
260.2471, 0.005995147,
},
/* Approximate fixed elements (not accurate as fixed linear rates over
* long spans, but Pluto is only ever used as a slow outer planet with
* a wide orb here). */
[PLANET_PLUTO] = {
110.30347, 0.0,
17.14175, 0.0,
113.76329, 0.0,
39.48168677, 0.0,
0.24880766, 0.0,
14.53, 0.00396,
},
};
/* Newton-Raphson sqrt. Fixed iteration count rather than a
* convergence-check loop, so it can't ever fail to terminate. */
static double portable_sqrt(double x) {
if (x <= 0.0) return 0.0;
double guess = (x < 1.0) ? 1.0 : x;
for (int i = 0; i < 50; i++) {
guess = 0.5 * (guess + x / guess);
}
return guess;
}
/* atan(z) for z in [-1,1] via a minimax polynomial (Abramowitz & Stegun
* 4.4.49-style coefficients), max error ~1.2e-5 radians. */
static double portable_atan(double z) {
double z2 = z * z;
return z * (0.9998660 +
z2 * (-0.3302995 +
z2 * (0.1801410 +
z2 * (-0.0851330 + z2 * 0.0208351))));
}
double lowprec_atan2(double y, double x) {
if (x == 0.0 && y == 0.0) return 0.0;
double ax = x < 0.0 ? -x : x;
double ay = y < 0.0 ? -y : y;
double angle;
if (ax >= ay) {
angle = portable_atan(ay / ax);
if (x < 0.0) angle = DECK_PI - angle;
} else {
angle = DECK_PI / 2.0 - portable_atan(ax / ay);
if (x < 0.0) angle = DECK_PI - angle;
}
return (y < 0.0) ? -angle : angle;
}
/* Solves Kepler's equation for the given elements at day-number `d`
* (days since J2000.0), returning rectangular heliocentric (geocentric
* for the Sun/Moon "orbits") ecliptic coordinates in AU (Earth radii for
* the Moon). */
static void solve_orbit(const OrbitalElements *el, double d, double *x, double *y) {
double N = (el->N0 + el->Nd * d) * DEG2RAD;
double i = (el->i0 + el->id * d) * DEG2RAD;
double w = (el->w0 + el->wd * d) * DEG2RAD;
double a = el->a0 + el->ad * d;
double e = el->e0 + el->ed * d;
double M = normalize_deg(el->M0 + el->Md * d) * DEG2RAD;
double E = M + e * lowprec_sin(M) * (1.0 + e * lowprec_cos(M));
for (int iter = 0; iter < 8; iter++) {
double delta = (E - e * lowprec_sin(E) - M) / (1.0 - e * lowprec_cos(E));
E -= delta;
}
double xv = a * (lowprec_cos(E) - e);
double yv = a * (portable_sqrt(1.0 - e * e) * lowprec_sin(E));
double v = lowprec_atan2(yv, xv);
double r = portable_sqrt(xv * xv + yv * yv);
double vw = v + w;
*x = r * (lowprec_cos(N) * lowprec_cos(vw) - lowprec_sin(N) * lowprec_sin(vw) * lowprec_cos(i));
*y = r * (lowprec_sin(N) * lowprec_cos(vw) + lowprec_cos(N) * lowprec_sin(vw) * lowprec_cos(i));
}
/* The dozen largest lunar perturbation terms (Schlyter's "more
* accurate" Moon correction), applied as a direct correction in degrees
* to the Moon's mean-orbit longitude - brings the Moon from several
* degrees of error down to a few arcminutes, worth the modest extra
* code given how often the Moon matters here (a natal luminary, and the
* fastest-moving transiting body). */
static double moon_longitude_correction(double d) {
double Ms = normalize_deg(356.0470 + 0.9856002585 * d) * DEG2RAD; /* Sun mean anomaly */
double Mm = normalize_deg(134.9634 + 13.0649929509 * d) * DEG2RAD; /* Moon mean anomaly */
double Nm = normalize_deg(125.1228 - 0.0529538083 * d) * DEG2RAD; /* Moon's node */
double ws = normalize_deg(282.9404 + 4.70935e-5 * d) * DEG2RAD; /* Sun's perihelion */
double wm = normalize_deg(318.0634 + 0.1643573223 * d) * DEG2RAD; /* Moon's perihelion */
double Ls = ws + Ms; /* Sun's mean longitude */
double Lm = Nm + wm + Mm; /* Moon's mean longitude */
double D = Lm - Ls; /* elongation */
double F = Lm - Nm; /* argument of latitude */
double corr = 0.0;
corr += -1.274 * lowprec_sin(Mm - 2.0 * D);
corr += 0.658 * lowprec_sin(2.0 * D);
corr += -0.186 * lowprec_sin(Ms);
corr += -0.059 * lowprec_sin(2.0 * Mm - 2.0 * D);
corr += -0.057 * lowprec_sin(Mm - 2.0 * D + Ms);
corr += 0.053 * lowprec_sin(Mm + 2.0 * D);
corr += 0.046 * lowprec_sin(2.0 * D - Ms);
corr += 0.041 * lowprec_sin(Mm - Ms);
corr += -0.035 * lowprec_sin(D);
corr += -0.031 * lowprec_sin(Mm + Ms);
corr += -0.015 * lowprec_sin(2.0 * F - 2.0 * D);
corr += 0.011 * lowprec_sin(Mm - 4.0 * D);
return corr;
}
double lowprec_geocentric_longitude(Body body, double jd) {
double d = jd - 2451545.0;
double sun_x, sun_y;
solve_orbit(&k_elements[PLANET_SUN], d, &sun_x, &sun_y);
if (body == PLANET_SUN) {
return normalize_deg(lowprec_atan2(sun_y, sun_x) * RAD2DEG);
}
if (body == PLANET_MOON) {
double moon_x, moon_y;
solve_orbit(&k_elements[PLANET_MOON], d, &moon_x, &moon_y);
double lon = lowprec_atan2(moon_y, moon_x) * RAD2DEG + moon_longitude_correction(d);
return normalize_deg(lon);
}
/* Earth's own heliocentric position is the Sun's geocentric one,
* negated (both "orbits" share the same ecliptic plane by
* definition here, i.e. i = 0 for the Sun's elements). */
double earth_x = -sun_x, earth_y = -sun_y;
double planet_x, planet_y;
solve_orbit(&k_elements[body], d, &planet_x, &planet_y);
double geo_x = planet_x - earth_x;
double geo_y = planet_y - earth_y;
return normalize_deg(lowprec_atan2(geo_y, geo_x) * RAD2DEG);
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef DECK_LOWPREC_EPHEMERIS_H
#define DECK_LOWPREC_EPHEMERIS_H
#include "astro.h"
/* A compact, self-contained low-precision ephemeris for the watch build
* only (PBL_SDK_3) - the vendored Astronomy Engine (astro.c's normal
* desktop path) compiles to ~127KB of code, well over a whole Pebble
* app's entire 64KB code+data+bss budget, so it can't be linked into the
* watchapp at all. This module replaces it there via classic Keplerian
* orbital-element formulas (the widely-published "low precision"
* planetary position method - see lowprec_ephemeris.c's own comment for
* accuracy and sourcing). Not used, and not compiled, on desktop - see
* astro.c's AstroTime typedef and the Makefile's ENGINE_SRCS, which never
* includes this file. */
/* Julian Date (TT and UT are treated as identical at this precision -
* their few-tens-of-seconds difference is far below this method's own
* ~1 arcminute-to-a-few-degrees accuracy) for the given UTC calendar
* date/time. */
double lowprec_julian_date(int year, int month, int day, int hour, int minute, double second);
/* Geocentric apparent ecliptic longitude of `body` at Julian Date `jd`,
* in degrees [0, 360). */
double lowprec_geocentric_longitude(Body body, double jd);
/* Greenwich Mean Sidereal Time at Julian Date `jd`, in hours [0, 24) -
* the watch's replacement for Astronomy_SiderealTime(), used the same
* way (RAMC = gmst*15 + geographic longitude) to compute the Ascendant. */
double lowprec_gmst_hours(double jd);
/* atan2()/sin()/cos() replacements for the watch build - see
* lowprec_ephemeris.c's file-level comment. Exported (not file-static)
* since astro.c's compute_ascendant() also needs them. Same signature/
* semantics as their libm counterparts, in radians. */
double lowprec_atan2(double y, double x);
double lowprec_sin(double rad);
double lowprec_cos(double rad);
#endif
+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);
+24 -11
View File
@@ -3,6 +3,20 @@
#include <math.h> #include <math.h>
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
time_t utc_moment, DailyReading *out) {
out->utc_moment = utc_moment;
out->gender = gender;
astro_compute_natal_chart(birth, &out->natal);
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
tarot_draw_celtic_cross(tarot_seed, &out->spread);
}
/* Everything below uses fprintf, which Pebble's SDK blocks at compile
* time (see pebble_warn_unsupported_functions.h) - not compiled at all
* for the watch, which only ever calls reading_generate() above. */
#ifndef PBL_SDK_3
/* UI-label keys ("ui.*") are looked up here rather than in i18n.h/.c /* UI-label keys ("ui.*") are looked up here rather than in i18n.h/.c
* because they're specific to these two renderers, not part of the * because they're specific to these two renderers, not part of the
* portable astro/tarot vocabulary. */ * portable astro/tarot vocabulary. */
@@ -10,13 +24,6 @@ static const char *ui(const char *key, const char *fallback) {
return i18n_get(key, fallback); return i18n_get(key, fallback);
} }
void reading_generate(const char *tarot_seed, const BirthData *birth,
time_t utc_moment, DailyReading *out) {
astro_compute_natal_chart(birth, &out->natal);
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
tarot_draw_celtic_cross(tarot_seed, &out->spread);
}
static void print_position_deg(FILE *out, const PlanetPosition *p) { static void print_position_deg(FILE *out, const PlanetPosition *p) {
fprintf(out, "%5.1f%s %-11s (%s %d)", p->degree_in_sign, "\xc2\xb0", fprintf(out, "%5.1f%s %-11s (%s %d)", p->degree_in_sign, "\xc2\xb0",
astro_sign_name(p->sign), ui("ui.house", "House"), p->house); astro_sign_name(p->sign), ui("ui.house", "House"), p->house);
@@ -60,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));
} }
} }
@@ -132,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");
@@ -155,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]);
@@ -194,3 +205,5 @@ void reading_print_json(const DailyReading *r, FILE *out) {
} }
fprintf(out, " ]\n }\n}\n"); fprintf(out, " ]\n }\n}\n");
} }
#endif /* !PBL_SDK_3 */
+23 -2
View File
@@ -1,13 +1,30 @@
#ifndef DECK_READING_H #ifndef DECK_READING_H
#define DECK_READING_H #define DECK_READING_H
/* See astro.h's own comment on this same #ifdef - Pebble's SDK
* pre-defines <time.h>'s include guard and declares time_t/struct tm
* itself inside pebble.h. */
#ifdef PBL_SDK_3
#include <pebble.h>
#else
#include <stdio.h> #include <stdio.h>
#include <time.h> #include <time.h>
#endif
#include "astro.h" #include "astro.h"
#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;
@@ -15,11 +32,14 @@ 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
* UI exists. Not used by (and not needed by) the watchapp. */ * UI exists - use fprintf, which Pebble's SDK blocks at compile time, so
* these aren't even declared (and reading.c doesn't define them) when
* building for the watch. Not used by (and not needed by) the watchapp. */
#ifndef PBL_SDK_3
void reading_print_text(const DailyReading *r, FILE *out); void reading_print_text(const DailyReading *r, FILE *out);
void reading_print_html(const DailyReading *r, FILE *out); void reading_print_html(const DailyReading *r, FILE *out);
@@ -28,5 +48,6 @@ void reading_print_html(const DailyReading *r, FILE *out);
* tarot_*_name()) so the output is identical regardless of --lang. This * tarot_*_name()) so the output is identical regardless of --lang. This
* is the interchange format interpreter/ reads - see its json.c. */ * is the interchange format interpreter/ reads - see its json.c. */
void reading_print_json(const DailyReading *r, FILE *out); void reading_print_json(const DailyReading *r, FILE *out);
#endif
#endif #endif
+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;
} }
+179
View File
@@ -0,0 +1,179 @@
#include "guidance.h"
#include <stdbool.h>
#include <stdio.h>
/* i18n_get() only - see narrative.c's own comment on why this one extra
* engine header is needed despite guidance.h's otherwise header-only
* engine dependency. */
#include "../../engine/src/i18n.h"
/* Slugs for building "guidance.planet.<slug>"/i18n keys - mirrors
* reading_io.c's own k_body_slug, same duplication policy (see
* significance.c's comment for why each independently-testable module
* keeps its own small table rather than linking astro.c). */
static const char *const k_body_slug[NUM_BODIES] = {
"sun", "moon", "mercury", "venus", "mars",
"jupiter", "saturn", "uranus", "neptune", "pluto",
};
/* English fallback only - "the Sun"/"the Moon" take a definite article
* that the other eight planets don't; see engine/i18n/en.lang's own
* comment on the guidance.planet.* keys this backs. */
static const char *const k_planet_name_fallback[NUM_BODIES] = {
"the Sun", "the Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
};
static const char *planet_display_name(Body body) {
char key[32];
snprintf(key, sizeof key, "guidance.planet.%s", k_body_slug[body]);
return i18n_get(key, k_planet_name_fallback[body]);
}
typedef enum {
CHAR_HARMONIOUS = 0,
CHAR_DISCORDANT,
CHAR_NEUTRAL,
NUM_TRANSIT_CHARACTERS
} TransitCharacter;
typedef struct {
const char *slug; /* builds "guidance.stance.<slug>.*" keys */
const char *upright; /* Attitude card fell upright (English fallback) */
const char *reversed; /* Attitude card fell reversed (English fallback) */
} AttitudeStance;
/* Original guidance text for this project - see guidance.h's doc
* comment for why. Fallback (English) text only; German translations
* live in engine/i18n/de.lang under the matching "guidance.*" keys.
* Indexed by TransitCharacter. */
static const AttitudeStance k_stance[NUM_TRANSIT_CHARACTERS] = {
[CHAR_HARMONIOUS] = {
"harmonious",
"You're already meeting the day in the right spirit - lean into "
"it rather than second-guessing a favorable position.",
"The day itself is working in your favor even though you don't "
"feel settled yet - trust the momentum more than your current "
"doubts.",
},
[CHAR_DISCORDANT] = {
"discordant",
"Your instincts are sound, but the day is testing them - hold "
"your position without forcing the issue.",
"Both the day and your own footing are unsettled right now - "
"this is a moment to steady yourself before pushing forward.",
},
[CHAR_NEUTRAL] = {
"neutral",
"Today concentrates whatever you already bring to it - your "
"current approach will be amplified, so make sure it's the one "
"you want.",
"Today intensifies things, including whatever is currently "
"unresolved in your own approach - worth sorting out before "
"acting.",
},
};
static TransitCharacter classify_transit(AspectType type) {
if (type == ASPECT_TRINE || type == ASPECT_SEXTILE) return CHAR_HARMONIOUS;
if (type == ASPECT_SQUARE || type == ASPECT_OPPOSITION) return CHAR_DISCORDANT;
return CHAR_NEUTRAL;
}
static const char *stance_text(TransitCharacter character, bool reversed) {
const AttitudeStance *s = &k_stance[character];
char key[48];
snprintf(key, sizeof key, "guidance.stance.%s.%s", s->slug, reversed ? "reversed" : "upright");
return i18n_get(key, reversed ? s->reversed : s->upright);
}
/* How to introduce the top transit, tailored to how strongly it's
* scoring (interp->day_level) - independent of the stance sentence
* itself (k_stance, above), which is written to stay true regardless of
* intensity. Each entry takes exactly one %s: the transiting planet's
* display name, always as the sentence's grammatical subject (matters
* for German, where the other three day levels put the planet name in
* non-nominative position in a naive word-for-word translation - see
* engine/i18n/de.lang's guidance.intro.* entries, which are phrased to
* avoid that). Fallback (English) text only. */
static const char *const k_intro_slug[DAY_SIGNIFICANCE_COUNT] = {
[DAY_QUIET] = "quiet",
[DAY_NOTABLE] = "notable",
[DAY_SIGNIFICANT] = "significant",
[DAY_MAJOR] = "major",
};
static const char *const k_intro_fallback[DAY_SIGNIFICANCE_COUNT] = {
[DAY_QUIET] = "%s is only faintly active today, but for what it's worth:",
[DAY_NOTABLE] = "With a mild touch from %s today:",
[DAY_SIGNIFICANT] = "With %s clearly active today:",
[DAY_MAJOR] = "With %s as today's dominant influence:",
};
static const char *intro_template(DaySignificance level) {
char key[32];
snprintf(key, sizeof key, "guidance.intro.%s", k_intro_slug[level]);
return i18n_get(key, k_intro_fallback[level]);
}
/* Fallback for a day with no aspects in orb at all (top_item_count ==
* 0, always DAY_QUIET) - there's no transiting planet to name, so the
* guidance falls back to the Attitude card's orientation alone. */
static const char *no_transit_text(bool reversed) {
static const char *const upright =
"There's no standout transit today - it's an astrologically quiet "
"day. That leaves things mostly about steadiness: trust your "
"current footing and use the calm to make headway.";
static const char *const reversed_text =
"There's no standout transit today - it's an astrologically quiet "
"day. That's a good moment to quietly re-settle your own "
"footing, since nothing external is forcing the pace.";
return i18n_get(reversed ? "guidance.no_transit.reversed" : "guidance.no_transit.upright",
reversed ? reversed_text : upright);
}
static const char *reversed_marker(bool reversed) {
static char buf[32];
if (!reversed) return "";
snprintf(buf, sizeof buf, " %s", i18n_get("ui.reversed", "(Reversed)"));
return buf;
}
void guidance_write(char *buf, size_t buf_size, const char *indent, const DailyInterpretation *interp,
const CelticCrossSpread *spread) {
if (buf_size == 0) return;
buf[0] = '\0';
const TarotDraw *attitude = &spread->positions[POSITION_ATTITUDE];
const TarotDraw *outcome = &spread->positions[POSITION_OUTCOME];
/* Chained snprintf() calls, each bounds-checked against the running
* length before the next one runs - see narrative_write()'s matching
* comment for why (never fprintf/sprintf/vsnprintf). */
size_t len;
if (interp->top_item_count == 0) {
len = (size_t)snprintf(buf, buf_size, "%s%s\n", indent, no_transit_text(attitude->reversed));
} else {
const Aspect *top = &interp->top_items[0].aspect;
TransitCharacter character = classify_transit(top->type);
const char *stance = stance_text(character, attitude->reversed);
char intro[192];
snprintf(intro, sizeof(intro), intro_template(interp->day_level),
planet_display_name(top->transiting_planet));
len = (size_t)snprintf(buf, buf_size, "%s%s %s\n", indent, intro, stance);
}
if (len >= buf_size) return;
len += (size_t)snprintf(buf + len, buf_size - len, "%s%s %s%s: %s\n", indent,
i18n_get("guidance.attitude_intro", "Your Attitude card is"),
tarot_card_name(attitude->card), reversed_marker(attitude->reversed),
tarot_card_meaning(attitude->card, attitude->reversed));
if (len >= buf_size) return;
snprintf(buf + len, buf_size - len, "%s%s %s%s: %s\n", indent,
i18n_get("guidance.outcome_intro", "The spread's likely Outcome is"),
tarot_card_name(outcome->card), reversed_marker(outcome->reversed),
tarot_card_meaning(outcome->card, outcome->reversed));
}
+73
View File
@@ -0,0 +1,73 @@
#ifndef DECK_GUIDANCE_H
#define DECK_GUIDANCE_H
#include <stddef.h>
/* Header-only dependency on the engine for struct/enum *definitions*,
* same policy as narrative.h/significance.h - see their comments for
* why. Unlike those, though, the root Makefile *does* link
* engine/src/tarot_data.c's *implementation* (tarot_card_name()/
* tarot_card_meaning()) into the interpreter binary - a deliberate,
* narrow exception; see the Makefile's own comment on
* INTERP_TAROT_TEXT_OBJS for why that doesn't compromise this module's
* independent testability. */
#include "../../engine/src/tarot.h"
#include "significance.h"
/* Generous upper bound on guidance_write()'s output (intro/stance line
* + Attitude line + Outcome line, each ending in a newline, including
* the longest tarot_card_meaning() strings) across every language this
* project ships a translation for - see narrative.h's NARRATIVE_TEXT_MAX
* for the same reasoning. */
#define GUIDANCE_TEXT_MAX 1024
/* Ties the day's single most significant transit
* (interp->top_items[0]) to the Celtic Cross spread, and prints a short
* paragraph of concrete guidance on how to meet the day - the "combined
* storytelling" piece docs/reading.md flags as future work, now built.
* Prints on every day, quiet or major, tailored to interp->day_level -
* callers can invoke it unconditionally after every
* interpret_daily_reading() call.
*
* The core guidance keys off two things: the character of the top
* transit's aspect (harmonious/discordant/neutral, same classification
* narrative.c uses) and whether the spread's Attitude position - Waite's
* "Himself: his position or attitude in the circumstances", the
* position most directly about how the reader is meeting the day - fell
* upright or reversed. That crossing (3 x 2 = 6 combinations) is
* original text written for this project, not drawn from Waite or
* Sepharial, since neither source discusses combining astrology and
* tarot; it stays valid regardless of the day's intensity. What *does*
* vary with interp->day_level is only the sentence introducing the top
* transit (e.g. "With Saturn as today's dominant influence" on a Major
* day vs. "Saturn is only faintly active today, but for what it's
* worth" on a Quiet one). A day with no aspects in orb at all
* (interp->top_item_count == 0, always DAY_QUIET) has no transiting
* planet to introduce, so it falls back to a stance keyed on the
* Attitude card's orientation alone.
*
* The paragraph also names the Attitude and Outcome cards and, via
* tarot_card_meaning(), states their actual Waite meaning for the
* orientation they landed in (e.g. what Wheel of Fortune reversed
* means) - not a duplicated table, the real thing, since tarot_data.c
* is linked (see above).
*
* Every piece of this module's own text (stance/intro/no-transit/label
* strings) is looked up via i18n_get() under "guidance.*" keys
* (engine/i18n's .lang files) before falling back to the English text baked
* into guidance.c, so it respects whatever --lang the caller loaded
* with i18n_load()/i18n_load_table() (main.c does so before calling
* this). The German guidance.intro.* templates are deliberately phrased
* so the planet name placeholder is always the sentence's grammatical
* subject (nominative case) across all four day levels - see
* guidance.c's own comment on k_intro_fallback.
*
* Writes into `buf` (a caller-supplied buffer of `buf_size` bytes,
* always left null-terminated - GUIDANCE_TEXT_MAX is a safe size)
* instead of a FILE*, using only snprintf() internally (never
* fprintf/sprintf/vsnprintf, all of which Pebble's SDK blocks at compile
* time), so this function links into the watch app unchanged. */
void guidance_write(char *buf, size_t buf_size, const char *indent, const DailyInterpretation *interp,
const CelticCrossSpread *spread);
#endif
+470 -52
View File
@@ -3,23 +3,59 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "../../engine/src/i18n.h"
#include "guidance.h"
#include "narrative.h" #include "narrative.h"
#include "reading_io.h" #include "reading_io.h"
#include "significance.h" #include "significance.h"
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
static void print_usage(const char *prog) { static void print_usage(const char *prog) {
fprintf(stderr, fprintf(stderr,
"Usage: %s [reading.json]\n\n" "Usage: %s [--format text|html|json] [--lang <code>] [--i18n-dir <path>]\n"
" [reading.json]\n\n"
"Reads a DailyReading in deck-engine's --format json shape - from the\n" "Reads a DailyReading in deck-engine's --format json shape - from the\n"
"given file, or from stdin if no file is given (or it is \"-\") - and\n" "given file, or from stdin if no file is given (or it is \"-\") - and\n"
"prints a significance report: the day's overall level and the top\n" "prints a full report: the day's overall significance level, the\n"
"aspects driving it, each with a short narrative note.\n\n" "top aspects driving it (each with a short narrative note), a full\n"
"readout of the Celtic Cross spread with every card's meaning, and\n"
"finally guidance tying the day's top transit to the spread's\n"
"Attitude/Outcome cards.\n\n"
" --format Output format, defaults to text. html links card art via\n"
" img/<file>, same convention as deck-engine's own html\n"
" output - save/run this next to the img/ directory\n"
" `make images` copies into dist/. json embeds the same\n"
" narrative/guidance prose as text (in whatever --lang was\n"
" loaded) alongside stable, language-independent slugs.\n"
" --lang Reading language code, defaults to en. Matches a file\n"
" named <code>.lang in --i18n-dir (see engine/i18n/) -\n"
" note this is this binary's OWN output language, not\n"
" related to whatever --lang deck-engine was run with to\n"
" produce its --format json input (which is always\n"
" language-independent - see docs/input-output-format.md).\n"
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
" i18n/ directory next to this binary (`make i18n`\n"
" copies engine/i18n/ there as dist/i18n/).\n\n"
"Typical use:\n" "Typical use:\n"
" dist/deck-engine ... --format json | %s\n" " dist/deck-engine ... --format json | %s\n"
" dist/deck-engine ... --format json > reading.json && %s reading.json\n", " dist/deck-engine ... --format json > reading.json && %s reading.json\n",
prog, prog, prog); prog, prog, prog);
} }
/* Same convention as engine/src/main.c's own default_i18n_path() -
* duplicated rather than shared, since the two binaries are never
* linked together (see CLAUDE.md's "Interpretation" section). */
static void default_i18n_path(const char *argv0, const char *lang, char *out, size_t out_size) {
const char *slash = strrchr(argv0, '/');
if (slash) {
int dir_len = (int)(slash - argv0);
snprintf(out, out_size, "%.*s/i18n/%s.lang", dir_len, argv0, lang);
} else {
snprintf(out, out_size, "i18n/%s.lang", lang);
}
}
static char *read_all(FILE *f, size_t *out_length) { static char *read_all(FILE *f, size_t *out_length) {
size_t cap = 4096; size_t cap = 4096;
size_t len = 0; size_t len = 0;
@@ -39,63 +75,463 @@ static char *read_all(FILE *f, size_t *out_length) {
return buf; return buf;
} }
/* Display names for the report - deliberately a small local table rather static const char *ui(const char *key, const char *fallback) { return i18n_get(key, fallback); }
* than linking engine/src/astro.c's astro_body_name()/astro_aspect_name()
* (which would pull in the vendored Astronomy Engine just for cosmetic /* Slugs for i18n key building (body.<slug>/sign.<slug>/aspect.<slug>,
* text). Same policy as reading_io.c's slug tables. */ * mirroring astro.c's own tables) and reused as-is for --format json's
* language-independent fields - deliberately a small local table rather
* than linking astro.c (would pull in the vendored Astronomy Engine just
* for cosmetic text/slugs). Same policy as reading_io.c's own tables. */
static const char *const k_body_slug[NUM_BODIES] = {
"sun", "moon", "mercury", "venus", "mars",
"jupiter", "saturn", "uranus", "neptune", "pluto",
};
static const char *const k_sign_slug[12] = {
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
};
static const char *const k_aspect_slug[5] = {
"conjunction", "sextile", "square", "trine", "opposition",
};
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
"quiet", "notable", "significant", "major",
};
static const char *body_display_name(Body body) { static const char *body_display_name(Body body) {
static const char *const names[NUM_BODIES] = { static const char *const fallback[NUM_BODIES] = {
"Sun", "Moon", "Mercury", "Venus", "Mars", "Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
}; };
return names[body]; char key[32];
snprintf(key, sizeof key, "body.%s", k_body_slug[body]);
return ui(key, fallback[body]);
} }
static const char *aspect_display_name(AspectType type) { static const char *aspect_display_name(AspectType type) {
static const char *const names[5] = { static const char *const fallback[5] = {
"Conjunction", "Sextile", "Square", "Trine", "Opposition", "Conjunction", "Sextile", "Square", "Trine", "Opposition",
}; };
return names[type]; char key[32];
snprintf(key, sizeof key, "aspect.%s", k_aspect_slug[type]);
return ui(key, fallback[type]);
} }
static const char *sign_display_name(ZodiacSign sign) { static const char *sign_display_name(ZodiacSign sign) {
static const char *const names[12] = { static const char *const fallback[12] = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
}; };
return names[sign]; char key[32];
snprintf(key, sizeof key, "sign.%s", k_sign_slug[sign]);
return ui(key, fallback[sign]);
}
/* "YYYY-MM-DD" for reading->utc_moment - the day this reading is for
* (see reading_load_json()/parse_date() in reading_io.c), not today's
* real date if the two ever differ (e.g. reading.json was generated
* earlier and interpreted later). */
static void format_date(time_t utc_moment, char *out, size_t out_size) {
struct tm *utc = gmtime(&utc_moment);
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
}
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return ui("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return ui("interp.day_level.significant", "Significant");
case DAY_MAJOR: return ui("interp.day_level.major", "Major");
default: return "Unknown";
}
}
static const char *reversed_marker(bool reversed) {
static char buf[32];
if (!reversed) return "";
snprintf(buf, sizeof buf, " %s", ui("ui.reversed", "(Reversed)"));
return buf;
} }
/* pos->house is 0 when reading_load_json had no sign/house data for this /* pos->house is 0 when reading_load_json had no sign/house data for this
* body (see parse_bodies() in reading_io.c) - a valid whole-sign house * body (see parse_bodies() in reading_io.c) - a valid whole-sign house
* is always 1-12, so this is a safe "unknown, say nothing" sentinel. */ * is always 1-12, so this is a safe "unknown, say nothing" sentinel. */
static void print_body_in_sign(const PlanetPosition *pos) { static void print_body_in_sign_text(FILE *out, const PlanetPosition *pos) {
if (pos->house < 1 || pos->house > 12) return; if (pos->house < 1 || pos->house > 12) return;
printf(" in %s (house %d)", sign_display_name(pos->sign), pos->house); char buf[64];
snprintf(buf, sizeof buf, ui("interp.in_sign_house", "in %s (house %d)"),
sign_display_name(pos->sign), pos->house);
fprintf(out, " %s", buf);
} }
static const char *day_level_name(DaySignificance level) { /* Captures narrative_write()/guidance_write()'s output into a heap
switch (level) { * string, for embedding into JSON - a stack buffer plus a single
case DAY_QUIET: return "Quiet"; * malloc()+memcpy() copy, since main.c doesn't need to stream this
case DAY_NOTABLE: return "Notable"; * anywhere. Strips a single trailing newline; caller must free() the
case DAY_SIGNIFICANT: return "Significant"; * result. */
case DAY_MAJOR: return "Major"; static char *capture_narrative(const Aspect *aspect, int house) {
char stack_buf[NARRATIVE_TEXT_MAX];
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
char stack_buf[GUIDANCE_TEXT_MAX];
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
size_t len = strlen(stack_buf);
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
char *buf = malloc(len + 1);
memcpy(buf, stack_buf, len + 1);
return buf;
}
/* Prints one significant-transit item's full detail (title line with
* sign/house/orb/score, plus its narrative sentence) - shared between
* the "Significant Transits" list and the day's top item, which is
* repeated in full just above the guidance paragraph (see
* interp_print_text) so the reader has that same detail in view right
* next to the guidance text it informs. `index` is 1-based and prefixes
* the line with "N. "; pass 0 to omit it, for the repeated top item,
* which isn't part of the numbered list. */
static void print_transit_item_text(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
if (index > 0) fprintf(out, " %d. ", index);
fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
char narrative_buf[NARRATIVE_TEXT_MAX];
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
reading->transits.bodies[a->transiting_planet].house);
fputs(narrative_buf, out);
}
static void print_top_transits_header(FILE *out, int count) {
if (count == 1) {
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
} else {
char buf[64];
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count);
fprintf(out, "%s\n", buf);
} }
return "Unknown"; }
/* ===== --format text ===== */
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread, Gender gender) {
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &spread->positions[i];
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i, gender),
tarot_card_name(draw->card), reversed_marker(draw->reversed));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender));
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
}
}
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
fprintf(out, "%s\n", ui("interp.major_framing",
"(a major transit today - worth a deeper Celtic Cross look)"));
}
fprintf(out, "\n");
fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
if (interp->top_item_count > 0) {
print_transit_item_text(out, reading, &interp->top_items[0], 0);
}
char guidance_buf[GUIDANCE_TEXT_MAX];
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
fputs(guidance_buf, out);
fprintf(out, "\n");
if (interp->top_item_count == 0) {
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
print_top_transits_header(out, interp->top_item_count);
for (int i = 0; i < interp->top_item_count; i++) {
print_transit_item_text(out, reading, &interp->top_items[i], i + 1);
}
}
fprintf(out, "\n");
print_celtic_cross_text(out, &reading->spread, reading->gender);
}
/* ===== --format html ===== */
/* HTML counterpart of print_transit_item_text() - same shared-between-
* the-repeated-top-item-and-the-full-list purpose, one table row (title
* + orb/score) plus a narrative row if non-empty. `index` is 1-based;
* pass 0 to omit the "N." cell, for the repeated top item. */
static void print_transit_item_html(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
char index_buf[16] = "";
if (index > 0) snprintf(index_buf, sizeof index_buf, "%d.", index);
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
fprintf(out, "<tr><td>%s</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
index_buf, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"), body_display_name(a->natal_planet),
ui("ui.orb", "orb"), a->orb, ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
/* One Celtic Cross card as a ".card" div - shared between the
* Attitude/Outcome preview (right after guidance) and the full spread
* further down, so both render identically. */
static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw,
Gender gender) {
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name(position, gender), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description(position, gender),
tarot_card_meaning(draw->card, draw->reversed));
}
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out,
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
"<title>%s</title>\n"
"<style>\n"
"body { font-family: sans-serif; max-width: 900px; margin: 2em auto; }\n"
"table { border-collapse: collapse; margin-bottom: 1.5em; }\n"
"td { padding: 2px 10px 2px 0; }\n"
"h1, h2 { border-bottom: 1px solid #ccc; }\n"
".spread { display: flex; flex-wrap: wrap; gap: 1.5em; }\n"
".card { width: 160px; }\n"
".card img { width: 140px; display: block; }\n"
".card img.reversed { transform: rotate(180deg); }\n"
".card .position { font-weight: bold; }\n"
".card .name { font-style: italic; }\n"
".guidance { background: #f6f6f6; padding: 1em; border-radius: 6px; }\n"
"</style></head><body>\n",
ui("interp.page_title", "Deck in a Dash - Interpretation"));
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "<p class=\"reading-date\">%s %s</p>\n",
ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
ui("interp.heading_day_significance", "Day Significance"),
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
fprintf(out, "<br>%s", ui("interp.major_framing",
"(a major transit today - worth a deeper Celtic Cross look)"));
}
fprintf(out, "</p>\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "<div class=\"spread attitude-outcome\">\n");
print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE], reading->gender);
print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME], reading->gender);
fprintf(out, "</div>\n");
fprintf(out, "<div class=\"guidance\">\n");
if (interp->top_item_count > 0) {
fprintf(out, "<table>\n");
print_transit_item_html(out, reading, &interp->top_items[0], 0);
fprintf(out, "</table>\n");
}
const char *start = guidance;
for (const char *p = guidance; ; p++) {
if (*p == '\n' || *p == '\0') {
if (p > start) fprintf(out, "<p>%.*s</p>\n", (int)(p - start), start);
if (*p == '\0') break;
start = p + 1;
}
}
free(guidance);
fprintf(out, "</div>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
print_transit_item_html(out, reading, &interp->top_items[i], i + 1);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i], reading->gender);
}
fprintf(out, "</div>\n");
fprintf(out, "</body></html>\n");
}
/* ===== --format json ===== */
static void json_string(FILE *out, const char *s) {
fputc('"', out);
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
switch (c) {
case '"': fputs("\\\"", out); break;
case '\\': fputs("\\\\", out); break;
case '\n': fputs("\\n", out); break;
case '\r': fputs("\\r", out); break;
case '\t': fputs("\\t", out); break;
default:
if (c < 0x20) fprintf(out, "\\u%04x", c);
else fputc((char)c, out);
}
}
fputc('"', out);
}
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "{\n");
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, " \"date\": \"%s\",\n", date_buf);
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
fprintf(out, " \"count\": %d,\n", DAY_SIGNIFICANCE_COUNT);
fprintf(out, " \"deserves_framing\": %s\n", interpretation_deserves_framing(interp) ? "true" : "false");
fprintf(out, " },\n");
fprintf(out, " \"significant_transits\": [\n");
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
const PlanetPosition *tp = &reading->transits.bodies[a->transiting_planet];
const PlanetPosition *np = &reading->natal.bodies[a->natal_planet];
char *text = capture_narrative(a, tp->house);
fprintf(out, " {\n");
fprintf(out, " \"transiting_planet\": \"%s\",\n", k_body_slug[a->transiting_planet]);
fprintf(out, " \"transiting_sign\": \"%s\",\n", k_sign_slug[tp->sign]);
fprintf(out, " \"transiting_house\": %d,\n", tp->house);
fprintf(out, " \"natal_planet\": \"%s\",\n", k_body_slug[a->natal_planet]);
fprintf(out, " \"natal_sign\": \"%s\",\n", k_sign_slug[np->sign]);
fprintf(out, " \"natal_house\": %d,\n", np->house);
fprintf(out, " \"aspect\": \"%s\",\n", k_aspect_slug[a->type]);
fprintf(out, " \"orb\": %.4f,\n", a->orb);
fprintf(out, " \"score\": %.4f,\n", item->score);
fprintf(out, " \"narrative\": ");
json_string(out, text);
fprintf(out, "\n }%s\n", i + 1 < interp->top_item_count ? "," : "");
free(text);
}
fprintf(out, " ],\n");
fprintf(out, " \"celtic_cross\": [\n");
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
fprintf(out, " {\"position\": \"%s\", \"card\": \"%s\", \"reversed\": %s, \"card_name\": ",
tarot_position_slug((CelticCrossPosition)i), tarot_card_slug(draw->card),
draw->reversed ? "true" : "false");
json_string(out, tarot_card_name(draw->card));
fprintf(out, ", \"meaning\": ");
json_string(out, tarot_card_meaning(draw->card, draw->reversed));
fprintf(out, "}%s\n", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
}
fprintf(out, " ],\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, " \"guidance\": ");
json_string(out, guidance);
fprintf(out, "\n");
free(guidance);
fprintf(out, "}\n");
} }
int main(int argc, char **argv) { int main(int argc, char **argv) {
if (argc > 2 || (argc == 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0))) { const char *format_str = "text";
print_usage(argv[0]); const char *lang = "en", *i18n_dir_arg = NULL;
return argc > 2 ? 1 : 0; const char *input_path = NULL;
for (int i = 1; i < argc; i++) {
const char *arg = argv[i];
if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
print_usage(argv[0]);
return 0;
} else if (strcmp(arg, "--format") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
format_str = argv[i];
} else if (strcmp(arg, "--lang") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
lang = argv[i];
} else if (strcmp(arg, "--i18n-dir") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
i18n_dir_arg = argv[i];
} else if (arg[0] == '-' && strcmp(arg, "-") != 0) {
fprintf(stderr, "Unknown argument: %s\n", arg);
print_usage(argv[0]);
return 1;
} else if (input_path == NULL) {
input_path = arg;
} else {
print_usage(argv[0]);
return 1;
}
}
OutputFormat format;
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
else if (strcmp(format_str, "json") == 0) format = FORMAT_JSON;
else {
fprintf(stderr, "Invalid --format, expected text, html, or json\n");
return 1;
}
char i18n_path[512];
if (i18n_dir_arg) {
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
} else {
default_i18n_path(argv[0], lang, i18n_path, sizeof i18n_path);
}
if (!i18n_load(i18n_path) && strcmp(lang, "en") != 0) {
fprintf(stderr, "warning: could not load translations for '%s' from %s - "
"falling back to built-in English\n", lang, i18n_path);
} }
FILE *in = stdin; FILE *in = stdin;
bool opened_file = false; bool opened_file = false;
if (argc == 2 && strcmp(argv[1], "-") != 0) { if (input_path && strcmp(input_path, "-") != 0) {
in = fopen(argv[1], "r"); in = fopen(input_path, "r");
if (!in) { if (!in) {
fprintf(stderr, "error: could not open %s\n", argv[1]); fprintf(stderr, "error: could not open %s\n", input_path);
return 1; return 1;
} }
opened_file = true; opened_file = true;
@@ -116,30 +552,12 @@ int main(int argc, char **argv) {
DailyInterpretation interp; DailyInterpretation interp;
interpret_daily_reading(&reading, &interp); interpret_daily_reading(&reading, &interp);
printf("Day significance: %s (%d/%d)\n", day_level_name(interp.day_level), if (format == FORMAT_HTML) {
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT); interp_print_html(stdout, &reading, &interp);
if (interpretation_deserves_framing(&interp)) { } else if (format == FORMAT_JSON) {
printf("(a major transit today - worth a deeper Celtic Cross look)\n"); interp_print_json(stdout, &reading, &interp);
} } else {
printf("\n"); interp_print_text(stdout, &reading, &interp);
if (interp.top_item_count == 0) {
printf("No notable transits today.\n");
return 0;
}
printf("Top %d significant transit%s:\n", interp.top_item_count,
interp.top_item_count == 1 ? "" : "s");
for (int i = 0; i < interp.top_item_count; i++) {
const SignificantItem *item = &interp.top_items[i];
const Aspect *a = &item->aspect;
printf(" %d. Transiting %s", i + 1, body_display_name(a->transiting_planet));
print_body_in_sign(&reading.transits.bodies[a->transiting_planet]);
printf(" %s natal %s", aspect_display_name(a->type), body_display_name(a->natal_planet));
print_body_in_sign(&reading.natal.bodies[a->natal_planet]);
printf(" (orb %.1f\xc2\xb0, score %.2f)\n", a->orb, item->score);
narrative_print(stdout, " ", a, reading.transits.bodies[a->transiting_planet].house);
} }
return 0; return 0;
} }
+111 -62
View File
@@ -1,86 +1,103 @@
#include "narrative.h" #include "narrative.h"
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h>
/* i18n_get() only - narrative.h already pulls in astro.h; this is the
* one additional engine header needed to route k_narratives/
* k_house_area through the same translation catalog tarot_data.c uses
* (see the Makefile's INTERP_TAROT_TEXT_OBJS comment for why i18n.c is
* linked into this binary). */
#include "../../engine/src/i18n.h"
typedef struct { typedef struct {
const char *slug; /* builds "narrative.<slug>.*" i18n keys */
const char *base; /* always relevant, may be "" (see the Sun) */ const char *base; /* always relevant, may be "" (see the Sun) */
const char *harmonious; /* shown only under a trine/sextile; may be "" */ const char *harmonious; /* shown only under a trine/sextile; may be "" */
const char *discordant; /* shown only under a square/opposition; may be "" */ const char *discordant; /* shown only under a square/opposition; may be "" */
} TransitNarrative; } TransitNarrative;
/* See narrative.h's doc comment for sourcing (Sepharial, "Transits and /* Fallback (English) text, also the source of truth when no translation
* Planetary Periods", 1920, Chapter VIII) and the Moon/Pluto exception. */ * catalog is loaded - see narrative.h's doc comment for sourcing
* (Sepharial, "Transits and Planetary Periods", 1920, Chapter VIII) and
* the Moon/Pluto exception. German translations live in
* engine/i18n/de.lang under the same "narrative.<slug>.*" keys. */
static const TransitNarrative k_narratives[NUM_BODIES] = { static const TransitNarrative k_narratives[NUM_BODIES] = {
[PLANET_SUN] = { [PLANET_SUN] = {
.base = "", "sun", "",
.harmonious = "Benefits from superiors and advancement in your sphere of " "Benefits from superiors and advancement in your sphere of "
"life and work - honours, emoluments, and successful new " "life and work - honours, emoluments, and successful new "
"associations.", "associations.",
.discordant = "Degradation and dishonour, loss of position, and adverse " "Degradation and dishonour, loss of position, and adverse "
"judgement from superiors.", "judgement from superiors.",
}, },
[PLANET_MOON] = { [PLANET_MOON] = {
/* Original text, not from Sepharial - see narrative.h. */ /* Original text, not from Sepharial - see narrative.h. */
.base = "Colours the everyday and domestic sphere of life, often " "moon",
"coinciding with the opening of new avenues.", "Colours the everyday and domestic sphere of life, often "
.harmonious = "These changes tend to be advantageous.", "coinciding with the opening of new avenues.",
.discordant = "These changes tend to be adverse, with some indisposition " "These changes tend to be advantageous.",
"or domestic friction.", "These changes tend to be adverse, with some indisposition "
"or domestic friction.",
}, },
[PLANET_MERCURY] = { [PLANET_MERCURY] = {
.base = "Affects writings, journeys, commerce, and everyday activities - " "mercury",
"a neutral messenger whose effect follows the nature of the " "Affects writings, journeys, commerce, and everyday activities - "
"aspect it makes.", "a neutral messenger whose effect follows the nature of the "
.harmonious = "", "aspect it makes.",
.discordant = "", "", "",
}, },
[PLANET_VENUS] = { [PLANET_VENUS] = {
.base = "Brings domestic and social affairs to the fore - happiness, " "venus",
"comforts, and favours.", "Brings domestic and social affairs to the fore - happiness, "
.harmonious = "Success in love affairs and artistic pursuits is likely.", "comforts, and favours.",
.discordant = "Grief and disappointment are more likely.", "Success in love affairs and artistic pursuits is likely.",
"Grief and disappointment are more likely.",
}, },
[PLANET_MARS] = { [PLANET_MARS] = {
.base = "A strenuous time of quarrels, contention, strife and anger, with " "mars",
"some risk of hurts or injuries depending on the sign it " "A strenuous time of quarrels, contention, strife and anger, with "
"occupies.", "some risk of hurts or injuries depending on the sign it "
.harmonious = "Can bring benefits from doctors, surgeons, or new projects " "occupies.",
"and enterprises.", "Can bring benefits from doctors, surgeons, or new projects "
.discordant = "", "and enterprises.",
"",
}, },
[PLANET_JUPITER] = { [PLANET_JUPITER] = {
.base = "Brings increase and expansion - fullness of fortune and health, " "jupiter",
"and a generally fortunate time.", "Brings increase and expansion - fullness of fortune and health, "
.harmonious = "", "and a generally fortunate time.",
.discordant = "", "", "",
}, },
[PLANET_SATURN] = { [PLANET_SATURN] = {
.base = "Brings depression, stagnation, hindrances and obstacles, and " "saturn",
"some deprivation of the usual benefits.", "Brings depression, stagnation, hindrances and obstacles, and "
.harmonious = "Favours from older connections and past associations are " "some deprivation of the usual benefits.",
"still possible.", "Favours from older connections and past associations are "
.discordant = "", "still possible.",
"",
}, },
[PLANET_URANUS] = { [PLANET_URANUS] = {
.base = "Brings separations, estrangements, sudden dislocations and " "uranus",
"violent upsets.", "Brings separations, estrangements, sudden dislocations and "
.harmonious = "Success through official or civic channels, and " "violent upsets.",
"beneficial changes or appointments, are possible.", "Success through official or civic channels, and "
.discordant = "", "beneficial changes or appointments, are possible.",
"",
}, },
[PLANET_NEPTUNE] = { [PLANET_NEPTUNE] = {
.base = "Brings a state of chaos and confusion - an involved, uncertain " "neptune",
"condition of affairs, with plots, subtlety, or unseen " "Brings a state of chaos and confusion - an involved, uncertain "
"influences at work.", "condition of affairs, with plots, subtlety, or unseen "
.harmonious = "", "influences at work.",
.discordant = "", "", "",
}, },
[PLANET_PLUTO] = { [PLANET_PLUTO] = {
/* Original text, not from Sepharial - see narrative.h. */ /* Original text, not from Sepharial - see narrative.h. */
.base = "Brings deep, often hidden transformation - the surfacing or " "pluto",
"dismantling of something that has outgrown its old form.", "Brings deep, often hidden transformation - the surfacing or "
.harmonious = "", "dismantling of something that has outgrown its old form.",
.discordant = "", "", "",
}, },
}; };
@@ -96,7 +113,8 @@ static bool aspect_is_discordant(AspectType type) {
* house governs. This is centuries-old, uncredited astrological * house governs. This is centuries-old, uncredited astrological
* convention (the same status as the sign/aspect names already baked * convention (the same status as the sign/aspect names already baked
* into engine/src/astro.c), not text drawn from Sepharial or any other * into engine/src/astro.c), not text drawn from Sepharial or any other
* single source. Indexed house - 1. */ * single source. Indexed house - 1; fallback (English) text only, keyed
* as "narrative.house.<1-12>" in engine/i18n's .lang files. */
static const char *const k_house_area[12] = { static const char *const k_house_area[12] = {
"your sense of self, identity, and outward appearance", "your sense of self, identity, and outward appearance",
"money, possessions, and personal values", "money, possessions, and personal values",
@@ -112,22 +130,53 @@ static const char *const k_house_area[12] = {
"solitude, the subconscious, and hidden matters", "solitude, the subconscious, and hidden matters",
}; };
void narrative_print(FILE *out, const char *indent, const Aspect *aspect, static const char *narrative_field(const char *slug, const char *field, const char *fallback) {
char key[48];
snprintf(key, sizeof key, "narrative.%s.%s", slug, field);
return i18n_get(key, fallback);
}
void narrative_write(char *buf, size_t buf_size, const char *indent, const Aspect *aspect,
int transiting_house) { int transiting_house) {
if (buf_size == 0) return;
buf[0] = '\0';
const TransitNarrative *n = &k_narratives[aspect->transiting_planet]; const TransitNarrative *n = &k_narratives[aspect->transiting_planet];
const char *base = narrative_field(n->slug, "base", n->base);
const char *extra = ""; const char *extra = "";
if (aspect_is_harmonious(aspect->type)) extra = n->harmonious; if (aspect_is_harmonious(aspect->type)) extra = narrative_field(n->slug, "harmonious", n->harmonious);
else if (aspect_is_discordant(aspect->type)) extra = n->discordant; else if (aspect_is_discordant(aspect->type)) extra = narrative_field(n->slug, "discordant", n->discordant);
if (n->base[0] == '\0' && extra[0] == '\0') return; if (base[0] == '\0' && extra[0] == '\0') return;
/* Chained snprintf() calls, each bounds-checked against the running
* length before the next one runs - never fprintf/sprintf/vsnprintf,
* all of which Pebble's SDK blocks at compile time (see narrative.h's
* doc comment). Once `len` reaches buf_size (truncated), every
* subsequent call is skipped rather than computing buf_size - len,
* which would underflow. */
size_t len = (size_t)snprintf(buf, buf_size, "%s", indent);
if (len >= buf_size) return;
fprintf(out, "%s", indent);
if (transiting_house >= 1 && transiting_house <= 12) { if (transiting_house >= 1 && transiting_house <= 12) {
fprintf(out, "In matters of %s (house %d). ", char house_key[24];
k_house_area[transiting_house - 1], transiting_house); snprintf(house_key, sizeof house_key, "narrative.house.%d", transiting_house);
const char *area = i18n_get(house_key, k_house_area[transiting_house - 1]);
char frame[256];
snprintf(frame, sizeof frame, i18n_get("narrative.house_frame", "In matters of %s (house %d)."),
area, transiting_house);
len += (size_t)snprintf(buf + len, buf_size - len, "%s ", frame);
if (len >= buf_size) return;
} }
if (n->base[0] != '\0') fprintf(out, "%s", n->base); if (base[0] != '\0') {
if (extra[0] != '\0') fprintf(out, "%s%s", n->base[0] != '\0' ? " " : "", extra); len += (size_t)snprintf(buf + len, buf_size - len, "%s", base);
fprintf(out, "\n"); if (len >= buf_size) return;
}
if (extra[0] != '\0') {
len += (size_t)snprintf(buf + len, buf_size - len, "%s%s", base[0] != '\0' ? " " : "", extra);
if (len >= buf_size) return;
}
snprintf(buf + len, buf_size - len, "\n");
} }
+25 -7
View File
@@ -1,12 +1,20 @@
#ifndef DECK_NARRATIVE_H #ifndef DECK_NARRATIVE_H
#define DECK_NARRATIVE_H #define DECK_NARRATIVE_H
#include <stdio.h> #include <stddef.h>
/* Header-only dependency on the engine, same policy as significance.h - /* Header-only dependency on the engine, same policy as significance.h -
* see its comment for why. */ * see its comment for why. */
#include "../../engine/src/astro.h" #include "../../engine/src/astro.h"
/* Generous upper bound on narrative_write()'s output (indent + house
* framing + base + harmonious/discordant addendum + newline) across
* every language this project ships a translation for - callers on
* constrained platforms (the watch) are free to size their own stack
* buffer smaller if they know their own strings are shorter; this is
* just a safe default for a caller that doesn't want to think about it. */
#define NARRATIVE_TEXT_MAX 512
/* Condensed, public-domain descriptions of what each transiting planet /* Condensed, public-domain descriptions of what each transiting planet
* classically signifies, sourced from Sepharial's "Transits and * classically signifies, sourced from Sepharial's "Transits and
* Planetary Periods" (1920, public domain - res/Transits_and_Planetary_ * Planetary Periods" (1920, public domain - res/Transits_and_Planetary_
@@ -22,15 +30,19 @@
* narrative.c is original, written for this project in a matching * narrative.c is original, written for this project in a matching
* voice, not drawn from Sepharial. * voice, not drawn from Sepharial.
* *
* Prints a narrative sentence for `aspect`'s transiting planet to `out`, * Writes a narrative sentence for `aspect`'s transiting planet into
* indented with `indent` and followed by a newline - or prints nothing * `buf` (a caller-supplied buffer of `buf_size` bytes, always left
* if the source material has no text applicable to this planet/aspect * null-terminated - NARRATIVE_TEXT_MAX is a safe size), indented with
* `indent` and followed by a newline - or writes an empty string if the
* source material has no text applicable to this planet/aspect
* combination (e.g. an exactly neutral conjunction to a planet whose * combination (e.g. an exactly neutral conjunction to a planet whose
* only text is a "well/badly aspected" addendum, like the Sun). A * only text is a "well/badly aspected" addendum, like the Sun). A
* conjunction is treated as neutral - neither the "harmonious" nor the * conjunction is treated as neutral - neither the "harmonious" nor the
* "discordant" addendum is shown for it - since Sepharial's own text * "discordant" addendum is shown for it - since Sepharial's own text
* says a conjunction "takes the nature of what it conjoins," something * says a conjunction "takes the nature of what it conjoins," something
* this data doesn't model. * this data doesn't model. Uses only snprintf() internally (never
* fprintf/sprintf/vsnprintf, all of which Pebble's SDK blocks at compile
* time), so this function links into the watch app unchanged.
* *
* `transiting_house` is the whole-sign house (1-12) the transiting * `transiting_house` is the whole-sign house (1-12) the transiting
* planet currently occupies in the natal chart - i.e. * planet currently occupies in the natal chart - i.e.
@@ -39,8 +51,14 @@
* personalized way transits are read (see astro.h's own house-field * personalized way transits are read (see astro.h's own house-field
* comment). Pass a value outside 1-12 (e.g. the "no data" sentinel `0` * comment). Pass a value outside 1-12 (e.g. the "no data" sentinel `0`
* used elsewhere - see reading_io.c's parse_bodies()) to omit that * used elsewhere - see reading_io.c's parse_bodies()) to omit that
* framing and print the planet's narrative on its own. */ * framing and print the planet's narrative on its own.
void narrative_print(FILE *out, const char *indent, const Aspect *aspect, *
* Every string here is looked up via i18n_get() under "narrative.*"
* keys (engine/i18n's .lang files) before falling back to the English text
* baked into narrative.c, the same catalog tarot_data.c uses - so this
* text respects whatever --lang the caller loaded with i18n_load()/
* i18n_load_table() (main.c does so before calling this). */
void narrative_write(char *buf, size_t buf_size, const char *indent, const Aspect *aspect,
int transiting_house); int transiting_house);
#endif #endif
+112
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
@@ -22,6 +25,26 @@ static const char *const k_sign_slug[12] = {
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
}; };
/* Mirrors tarot_data.c's own k_card_slug/k_position_slug, same
* duplication policy as k_body_slug above. */
static const char *const k_card_slug[TAROT_DECK_SIZE] = {
"fool", "magician", "high_priestess", "empress", "emperor", "hierophant",
"lovers", "chariot", "strength", "hermit", "wheel_of_fortune", "justice",
"hanged_man", "death", "temperance", "devil", "tower", "star", "moon",
"sun", "judgement", "world",
};
static const char *const k_position_slug[TAROT_SPREAD_SIZE] = {
"present", "challenge", "crown", "foundation", "recent_past",
"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) {
@@ -77,6 +100,89 @@ static bool aspect_type_from_slug(const char *slug, AspectType *out) {
return false; return false;
} }
static bool card_from_slug(const char *slug, TarotCard *out) {
for (int i = 0; i < TAROT_DECK_SIZE; i++) {
if (strcmp(slug, k_card_slug[i]) == 0) {
*out = (TarotCard)i;
return true;
}
}
return false;
}
static bool gender_from_slug(const char *slug, Gender *out) {
for (int i = 0; i < 3; i++) {
if (strcmp(slug, k_gender_slug[i]) == 0) {
*out = (Gender)i;
return true;
}
}
return false;
}
static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
if (strcmp(slug, k_position_slug[i]) == 0) {
*out = (CelticCrossPosition)i;
return true;
}
}
return false;
}
/* Fills out->positions[] from a "spread"."positions" JSON array. Used only
* for guidance_print()'s Attitude/Outcome framing, never for scoring, so
* this is deliberately lenient like parse_bodies(): an entry with an
* unrecognized/missing "position" or "card" slug is skipped, and a
* missing "spread" key entirely just leaves every position zeroed
* (card = the first enum value, reversed = false) rather than failing
* the whole load. */
static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
int count = json_array_count(positions);
for (int i = 0; i < count; i++) {
const JsonValue *item = json_array_get(positions, i);
CelticCrossPosition position;
TarotCard card;
if (!position_from_slug(json_as_string(json_object_get(item, "position")), &position)) continue;
if (!card_from_slug(json_as_string(json_object_get(item, "card")), &card)) continue;
out->positions[position].card = card;
const JsonValue *reversed = json_object_get(item, "reversed");
out->positions[position].reversed = reversed && reversed->type == JSON_BOOL && reversed->as.boolean;
}
}
/* 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));
@@ -118,10 +224,16 @@ 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);
const JsonValue *spread = json_object_get(root, "spread");
parse_spread(spread ? json_object_get(spread, "positions") : NULL, &out->spread);
json_free(root); json_free(root);
return ok; return ok;
} }
+17 -9
View File
@@ -10,18 +10,26 @@
/* Parses deck-engine's `--format json` output (text, null-terminated at /* Parses deck-engine's `--format json` output (text, null-terminated at
* text[length]) and fills *out with just enough of a DailyReading for * text[length]) and fills *out with just enough of a DailyReading for
* interpret_daily_reading() to work on and for reporting sign/house * interpret_daily_reading() to work on, for reporting sign/house context
* context around each significant event: out->transits.aspects[]/ * around each significant event, and for guidance_print()'s tarot
* aspect_count (used for scoring - see significance.c), and * framing: out->transits.aspects[]/aspect_count (used for scoring - see
* out->natal.bodies[]/out->transits.bodies[] (sign + house per body, * significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
* used only for display). out->natal.ascendant_longitude/houses[] and * house per body, used only for display), out->spread.positions[]
* out->spread are left zeroed - nothing reads them yet. * (card + reversed per Celtic Cross position, used only by
* guidance.c), out->utc_moment (the top-level "date" field, parsed
* back to midnight UTC of that date - used only for display, e.g. main.c
* printing which day a reading is for), 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
* aspects today" reading, not an error). A missing/unrecognized entry * aspects today" reading, not an error). A missing/unrecognized entry in
* in "natal"."bodies"/"transits"."bodies" is not an error - see * "natal"."bodies"/"transits"."bodies"/"spread"."positions", or a
* parse_bodies() in reading_io.c. */ * missing "spread" key entirely, is not an error - see parse_bodies()/
* parse_spread() in reading_io.c. */
bool reading_load_json(const char *text, size_t length, DailyReading *out); bool reading_load_json(const char *text, size_t length, DailyReading *out);
#endif #endif
+193
View File
@@ -0,0 +1,193 @@
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "../../engine/src/i18n.h"
#include "../src/guidance.h"
static DailyInterpretation make_interp(DaySignificance level, AspectType type, Body transiting) {
DailyInterpretation interp;
interp.top_item_count = 1;
interp.day_level = level;
interp.top_items[0].kind = ITEM_ASPECT;
interp.top_items[0].score = 9.5;
interp.top_items[0].aspect.transiting_planet = transiting;
interp.top_items[0].aspect.natal_planet = PLANET_MOON;
interp.top_items[0].aspect.type = type;
interp.top_items[0].aspect.orb = 0.5;
return interp;
}
static DailyInterpretation make_empty_interp(void) {
DailyInterpretation interp;
interp.top_item_count = 0;
interp.day_level = DAY_QUIET;
return interp;
}
static CelticCrossSpread make_spread(TarotCard attitude_card, bool attitude_reversed,
TarotCard outcome_card, bool outcome_reversed) {
CelticCrossSpread spread;
spread.positions[POSITION_ATTITUDE].card = attitude_card;
spread.positions[POSITION_ATTITUDE].reversed = attitude_reversed;
spread.positions[POSITION_OUTCOME].card = outcome_card;
spread.positions[POSITION_OUTCOME].reversed = outcome_reversed;
return spread;
}
static void test_harmonious_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_TRINE, PLANET_JUPITER);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "already meeting the day in the right spirit") != NULL);
assert(strstr(text, "Jupiter") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
assert(strstr(text, "(Reversed)") == NULL);
printf("PASS test_harmonious_upright\n");
}
static void test_discordant_reversed(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_DEVIL, true, CARD_WORLD, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "Both the day and your own footing are unsettled") != NULL);
assert(strstr(text, "Saturn") != NULL);
assert(strstr(text, "The Devil (Reversed): Evil fatality") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
printf("PASS test_discordant_reversed\n");
}
static void test_neutral_conjunction_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_CONJUNCTION, PLANET_PLUTO);
CelticCrossSpread spread = make_spread(CARD_EMPEROR, false, CARD_MOON, true);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "Today concentrates whatever you already bring to it") != NULL);
assert(strstr(text, "Pluto") != NULL);
assert(strstr(text, "The Emperor: Stability, power") != NULL);
assert(strstr(text, "The Moon (Reversed): Instability, inconstancy") != NULL);
printf("PASS test_neutral_conjunction_upright\n");
}
/* The stance sentence itself doesn't change with intensity - only the
* sentence introducing the transiting planet does (see k_intro in
* guidance.c). These three cover the remaining day levels below Major
* (which test_harmonious_upright already covers). */
static void test_significant_day_uses_significant_intro(void) {
DailyInterpretation interp = make_interp(DAY_SIGNIFICANT, ASPECT_SEXTILE, PLANET_VENUS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "clearly active today") != NULL);
assert(strstr(text, "Venus") != NULL);
printf("PASS test_significant_day_uses_significant_intro\n");
}
static void test_notable_day_uses_notable_intro(void) {
DailyInterpretation interp = make_interp(DAY_NOTABLE, ASPECT_SEXTILE, PLANET_MARS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "mild touch from Mars") != NULL);
printf("PASS test_notable_day_uses_notable_intro\n");
}
static void test_quiet_day_with_a_faint_item_still_names_it(void) {
DailyInterpretation interp = make_interp(DAY_QUIET, ASPECT_SEXTILE, PLANET_MERCURY);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "only faintly active today") != NULL);
assert(strstr(text, "Mercury") != NULL);
printf("PASS test_quiet_day_with_a_faint_item_still_names_it\n");
}
static void test_no_aspects_at_all_falls_back_to_attitude_only(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "astrologically quiet day") != NULL);
assert(strstr(text, "trust your current footing") != NULL);
assert(strstr(text, "The Star: Loss, theft") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
printf("PASS test_no_aspects_at_all_falls_back_to_attitude_only\n");
}
static void test_no_aspects_at_all_respects_reversed_attitude(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, true, CARD_SUN, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "quietly re-settle your own footing") != NULL);
printf("PASS test_no_aspects_at_all_respects_reversed_attitude\n");
}
/* Proves guidance_write() actually routes through i18n_get() rather
* than just printing the hardcoded fallback. Run last: i18n_load()
* replaces the process-global catalog for the rest of the binary's
* lifetime. */
static void test_translation_catalog_overrides_fallback_text(void) {
char path[] = "/tmp/deck_guidance_test_XXXXXX";
int fd = mkstemp(path);
assert(fd >= 0);
FILE *f = fdopen(fd, "w");
fprintf(f, "guidance.stance.discordant.upright=UEBERSETZTE HALTUNG\n");
fprintf(f, "guidance.intro.major=UEBERSETZTE EINLEITUNG %%s\n");
fprintf(f, "guidance.planet.saturn=UEBERSETZTER SATURN\n");
fprintf(f, "guidance.attitude_intro=UEBERSETZTE HALTUNGSKARTE\n");
fclose(f);
assert(i18n_load(path));
unlink(path);
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
char text[GUIDANCE_TEXT_MAX];
guidance_write(text, sizeof text, "", &interp, &spread);
assert(strstr(text, "UEBERSETZTE HALTUNG") != NULL);
assert(strstr(text, "UEBERSETZTE EINLEITUNG UEBERSETZTER SATURN") != NULL);
assert(strstr(text, "UEBERSETZTE HALTUNGSKARTE") != NULL);
assert(strstr(text, "Your instincts are sound") == NULL); /* English fallback must not leak through */
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
int main(void) {
test_harmonious_upright();
test_discordant_reversed();
test_neutral_conjunction_upright();
test_significant_day_uses_significant_intro();
test_notable_day_uses_notable_intro();
test_quiet_day_with_a_faint_item_still_names_it();
test_no_aspects_at_all_falls_back_to_attitude_only();
test_no_aspects_at_all_respects_reversed_attitude();
test_translation_catalog_overrides_fallback_text();
printf("All guidance tests passed.\n");
return 0;
}
+104 -6
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"
@@ -41,13 +42,15 @@ static void test_json_parse_rejects_malformed(void) {
} }
/* A trimmed-but-structurally-faithful fixture matching deck-engine's real /* A trimmed-but-structurally-faithful fixture matching deck-engine's real
* --format json shape: "spread" is present with decoy content the loader * --format json shape. "natal"/"transits" bodies are real (if partial) -
* must skip over without understanding its schema, to prove it navigates * reading_load_json parses sign/house from them for display, alongside
* by key path rather than assuming any position. "natal"/"transits" * the aspects used for scoring. "spread" only fills in the Attitude and
* bodies are real (if partial) - reading_load_json now parses sign/house * Outcome positions (the two guidance.c reads); the other eight are
* from them for display, alongside the aspects used for scoring. */ * 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}],"
@@ -57,7 +60,10 @@ static const char *k_fixture =
" {\"transiting_planet\": \"saturn\", \"natal_planet\": \"sun\", \"type\": \"square\", \"orb\": 0.5}" " {\"transiting_planet\": \"saturn\", \"natal_planet\": \"sun\", \"type\": \"square\", \"orb\": 0.5}"
" ]" " ]"
" }," " },"
" \"spread\": {\"positions\": [{\"position\": \"present\", \"card\": \"devil\", \"reversed\": false}]}" " \"spread\": {\"positions\": ["
" {\"position\": \"attitude\", \"card\": \"devil\", \"reversed\": true},"
" {\"position\": \"outcome\", \"card\": \"world\", \"reversed\": false}"
" ]}"
"}"; "}";
static void test_reading_load_json_extracts_aspects(void) { static void test_reading_load_json_extracts_aspects(void) {
@@ -77,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);
@@ -95,6 +157,35 @@ static void test_reading_load_json_extracts_body_sign_and_house(void) {
printf("PASS test_reading_load_json_extracts_body_sign_and_house\n"); printf("PASS test_reading_load_json_extracts_body_sign_and_house\n");
} }
static void test_reading_load_json_extracts_spread_positions(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
assert(reading.spread.positions[POSITION_ATTITUDE].card == CARD_DEVIL);
assert(reading.spread.positions[POSITION_ATTITUDE].reversed == true);
assert(reading.spread.positions[POSITION_OUTCOME].card == CARD_WORLD);
assert(reading.spread.positions[POSITION_OUTCOME].reversed == false);
/* Positions absent from the fixture (e.g. Present) get the zeroed
* default (CARD_FOOL, upright) rather than garbage. */
assert(reading.spread.positions[POSITION_PRESENT].card == CARD_FOOL);
assert(reading.spread.positions[POSITION_PRESENT].reversed == false);
printf("PASS test_reading_load_json_extracts_spread_positions\n");
}
static void test_reading_load_json_missing_spread_is_not_fatal(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.spread.positions[POSITION_OUTCOME].card == CARD_FOOL);
printf("PASS test_reading_load_json_missing_spread_is_not_fatal\n");
}
static void test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(void) { static void test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(void) {
const char *text = const char *text =
"{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]}," "{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]},"
@@ -146,7 +237,14 @@ 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_missing_spread_is_not_fatal();
test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(); test_reading_load_json_unknown_body_slug_is_skipped_not_fatal();
test_reading_load_json_empty_aspects_is_valid(); test_reading_load_json_empty_aspects_is_valid();
test_reading_load_json_rejects_missing_transits(); test_reading_load_json_rejects_missing_transits();
+51 -39
View File
@@ -1,117 +1,128 @@
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
#include <assert.h> #include <assert.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h>
#include "../../engine/src/i18n.h"
#include "../src/narrative.h" #include "../src/narrative.h"
static const char *slurp(FILE *f) {
static char buf[4096];
long len = ftell(f);
rewind(f);
size_t n = fread(buf, 1, (size_t)len, f);
buf[n] = '\0';
return buf;
}
static void test_hard_aspect_shows_base_but_not_harmonious_bonus(void) { static void test_hard_aspect_shows_base_but_not_harmonious_bonus(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON, Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 }; .type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 0); narrative_write(text, sizeof text, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "depression") != NULL); assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") == NULL); assert(strstr(text, "past associations") == NULL);
fclose(f);
printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n"); printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n");
} }
static void test_soft_aspect_adds_harmonious_bonus(void) { static void test_soft_aspect_adds_harmonious_bonus(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN, Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
.type = ASPECT_TRINE, .orb = 1.0 }; .type = ASPECT_TRINE, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 0); narrative_write(text, sizeof text, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "depression") != NULL); assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") != NULL); assert(strstr(text, "past associations") != NULL);
fclose(f);
printf("PASS test_soft_aspect_adds_harmonious_bonus\n"); printf("PASS test_soft_aspect_adds_harmonious_bonus\n");
} }
static void test_conjunction_is_neutral_prints_base_only(void) { static void test_conjunction_is_neutral_prints_base_only(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN, Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
.type = ASPECT_CONJUNCTION, .orb = 1.0 }; .type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 0); narrative_write(text, sizeof text, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "depression") != NULL); assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") == NULL); assert(strstr(text, "past associations") == NULL);
fclose(f);
printf("PASS test_conjunction_is_neutral_prints_base_only\n"); printf("PASS test_conjunction_is_neutral_prints_base_only\n");
} }
/* The Sun has no unconditional base text (see narrative.c) - a neutral /* The Sun has no unconditional base text (see narrative.c) - a neutral
* conjunction to it should print nothing at all. */ * conjunction to it should write nothing at all. */
static void test_empty_base_and_neutral_aspect_prints_nothing(void) { static void test_empty_base_and_neutral_aspect_prints_nothing(void) {
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON, Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
.type = ASPECT_CONJUNCTION, .orb = 1.0 }; .type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 0); narrative_write(text, sizeof text, " ", &a, 0);
long len = ftell(f);
assert(len == 0); assert(text[0] == '\0');
fclose(f);
printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n"); printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n");
} }
static void test_discordant_aspect_on_empty_base_planet(void) { static void test_discordant_aspect_on_empty_base_planet(void) {
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON, Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
.type = ASPECT_OPPOSITION, .orb = 1.0 }; .type = ASPECT_OPPOSITION, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 0); narrative_write(text, sizeof text, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "Degradation") != NULL); assert(strstr(text, "Degradation") != NULL);
fclose(f);
printf("PASS test_discordant_aspect_on_empty_base_planet\n"); printf("PASS test_discordant_aspect_on_empty_base_planet\n");
} }
static void test_known_house_adds_area_framing(void) { static void test_known_house_adds_area_framing(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON, Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 }; .type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 3); narrative_write(text, sizeof text, " ", &a, 3);
const char *text = slurp(f);
assert(strstr(text, "house 3") != NULL); assert(strstr(text, "house 3") != NULL);
assert(strstr(text, "communication") != NULL); assert(strstr(text, "communication") != NULL);
assert(strstr(text, "depression") != NULL); assert(strstr(text, "depression") != NULL);
fclose(f);
printf("PASS test_known_house_adds_area_framing\n"); printf("PASS test_known_house_adds_area_framing\n");
} }
static void test_unknown_house_omits_area_framing(void) { static void test_unknown_house_omits_area_framing(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON, Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 }; .type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile(); char text[NARRATIVE_TEXT_MAX];
narrative_print(f, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */ narrative_write(text, sizeof text, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
const char *text = slurp(f);
assert(strstr(text, "house") == NULL); assert(strstr(text, "house") == NULL);
assert(strstr(text, "depression") != NULL); assert(strstr(text, "depression") != NULL);
fclose(f);
printf("PASS test_unknown_house_omits_area_framing\n"); printf("PASS test_unknown_house_omits_area_framing\n");
} }
/* Proves narrative_write() actually routes through i18n_get() rather
* than just printing the hardcoded fallback - loads a translation file
* that overrides one planet's base text and one house's area text, and
* checks the override wins. Run last: i18n_load() replaces the
* process-global catalog for the rest of the test binary's lifetime. */
static void test_translation_catalog_overrides_fallback_text(void) {
char path[] = "/tmp/deck_narrative_test_XXXXXX";
int fd = mkstemp(path);
assert(fd >= 0);
FILE *f = fdopen(fd, "w");
fprintf(f, "narrative.saturn.base=UEBERSETZTER SATURN TEXT\n");
fprintf(f, "narrative.house.3=UEBERSETZTES HAUS DREI\n");
fclose(f);
assert(i18n_load(path));
unlink(path);
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
char text[NARRATIVE_TEXT_MAX];
narrative_write(text, sizeof text, "", &a, 3);
assert(strstr(text, "UEBERSETZTER SATURN TEXT") != NULL);
assert(strstr(text, "UEBERSETZTES HAUS DREI") != NULL);
assert(strstr(text, "depression") == NULL); /* English fallback must not leak through */
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
int main(void) { int main(void) {
test_hard_aspect_shows_base_but_not_harmonious_bonus(); test_hard_aspect_shows_base_but_not_harmonious_bonus();
test_soft_aspect_adds_harmonious_bonus(); test_soft_aspect_adds_harmonious_bonus();
@@ -120,6 +131,7 @@ int main(void) {
test_discordant_aspect_on_empty_base_planet(); test_discordant_aspect_on_empty_base_planet();
test_known_house_adds_area_framing(); test_known_house_adds_area_framing();
test_unknown_house_omits_area_framing(); test_unknown_house_omits_area_framing();
test_translation_catalog_overrides_fallback_text();
printf("All narrative tests passed.\n"); printf("All narrative tests passed.\n");
return 0; return 0;
} }
+7
View File
@@ -0,0 +1,7 @@
# Copy to registry.env (gitignored, never commit the real values) and
# fill in your cr.ladkau.de credentials. Used by build-image.sh,
# run-image.sh, and upload-image.sh.
REGISTRY=cr.ladkau.de
REGISTRY_IMAGE=cr.ladkau.de/deck-in-a-dash/builder
REGISTRY_USER=
REGISTRY_PASSWORD=
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.
Executable
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Runs `make test package watchapp android` inside the local build-image
# container, mirroring what the Gitea Actions build workflow does -
# useful for testing build-image changes locally before pushing
# anything to cr.ladkau.de.
#
# Usage: ./run-image.sh [VERSION]
# VERSION is passed through to `make` (see the Makefile's `package`/
# `watchapp`/`android` targets); omit it for their own git-tag-based
# default.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
IMAGE="$REGISTRY_IMAGE:$IMAGE_TAG"
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|| fail "$IMAGE not found locally — run ./build-image.sh first"
VERSION="${1:-${VERSION:-}}"
# CI always starts from a fresh checkout, but this script bind-mounts the
# live host repo — so build output left over from a previous local run
# (build/, dist/, watch/build, the generated watch resources below, and
# the Android app's own build/generated-resource dirs) would make this
# run skip work a real fresh checkout wouldn't, silently hiding bugs that
# only show up in CI. Wipe them first so every run exercises a true
# from-scratch build, same as CI.
echo "== Cleaning generated build artifacts for a fresh build =="
rm -rf \
"$ROOT/build" "$ROOT/dist" "$ROOT/watch/build" \
"$ROOT/watch/resources/img" "$ROOT/watch/resources/app_icon.png" \
"$ROOT/watch/src/c/i18n_tables.auto.c" \
"$ROOT/android/build" "$ROOT/android/app/build" "$ROOT/android/app/.cxx" \
"$ROOT/android/app/src/main/assets/i18n" "$ROOT/android/app/src/main/res/drawable-nodpi" \
"$ROOT/android/app/src/main/res"/mipmap-mdpi "$ROOT/android/app/src/main/res"/mipmap-hdpi \
"$ROOT/android/app/src/main/res"/mipmap-xhdpi "$ROOT/android/app/src/main/res"/mipmap-xxhdpi \
"$ROOT/android/app/src/main/res"/mipmap-xxxhdpi
echo "== Running make test package watchapp android inside $IMAGE =="
# The container runs as root (needed for the toolchain baked into the
# image), so anything it writes into this bind mount — dist/, build/,
# watch/build, android/.gradle, etc. — would otherwise come back owned
# by root, leaving the host repo unusable without sudo. Chown everything
# back to the host user on exit, whether the build succeeds or fails.
docker run --rm \
-v "$ROOT:/workspace" \
-w /workspace \
-e VERSION="$VERSION" \
-e HOST_UID="$(id -u)" \
-e HOST_GID="$(id -g)" \
"$IMAGE" \
bash -c '
git config --global --add safe.directory /workspace
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
make test package watchapp android
'
echo "== Done — artifacts in dist/ =="
ls -la "$ROOT/dist"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Pushes the build environment image — already built locally with
# ./build-image.sh, and ideally verified with ./run-image.sh — to
# cr.ladkau.de. The Gitea Actions build workflow pulls this image to run
# `make test package watchapp`.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
ROOT="$(pwd)"
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
command -v docker >/dev/null 2>&1 \
|| fail "docker not found in PATH"
[ -f "$ROOT/registry.env" ] \
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
# shellcheck disable=SC1091
source "$ROOT/registry.env"
: "${REGISTRY:?registry.env must set REGISTRY}"
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
VERSION="$(<"$ROOT/build-image/VERSION")"
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|| fail "$REGISTRY_IMAGE:$VERSION not found locally — run ./build-image.sh first"
echo "== Logging in to $REGISTRY =="
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
docker push "$REGISTRY_IMAGE:$VERSION"
docker push "$REGISTRY_IMAGE:latest"
echo "== Done =="
echo "Image: $REGISTRY_IMAGE:$VERSION"
+59
View File
@@ -0,0 +1,59 @@
# Deck in a Dash - Pebble watch app
The actual watchapp: shows the same daily tarot + astrology reading as
`dist/run-interpreter.sh`, computed entirely on-device (no companion
app, no server - birth data, language, and notification settings are
entered via a Pebble config page and persisted on-watch).
## Layout
```
watch/
package.json Pebble app manifest (uuid, targetPlatforms, messageKeys, resources)
wscript waf build rules - see its own top-of-file comment for
why this app's C sources are NOT a plain src/c/**/*.c
glob (most of the actual logic lives in ../engine/src/,
shared byte-for-byte with the desktop deck-engine/
interpreter-cli binaries)
src/c/ Watch-only C: UI, persisted config, on-device reading
orchestration, daily notification wakeup
src/pkjs/ pkjs config webview (birth data/language/notification
settings) - a self-contained `data:` URI form, no
server
resources/ Bitmap resources (card art, app icon) - resources/img/
is generated (gitignored), see below
scripts/ Build-time codegen: gen_i18n_tables.py (translation
tables from ../engine/i18n/*.lang) and
gen_card_images.py (downsized card art from
../res/img/*.jpeg) - both run automatically by
wscript on every `pebble build` (see its own comment
for why that stays cheap on repeat builds)
```
## Building
Requires the Pebble SDK/tool (`pebble` CLI) and Pillow (`pip install
Pillow`, used only by gen_card_images.py) - neither is part of this
repo's own `make`/`make test` toolchain, which only builds the desktop
`deck-engine`/`interpreter-cli` binaries. From this directory:
```bash
pebble build # -> build/watch.pbw
```
The root Makefile's `make watchapp` target wraps this and copies the
resulting `.pbw` into `dist/`, versioned the same way as `make package` -
see the root CLAUDE.md's "Packaging" section.
## Platform support
`targetPlatforms` deliberately excludes **aplite** (original Pebble /
Pebble Steel). A Pebble app's entire code+data+bss footprint shares one
64KB region on basalt-class platforms, but only 24KB on aplite - and
even this project's minimal skeleton (blank window + one
`reading_generate()` call, no UI/config/persistence/notification code
yet) already uses 19KB of it, leaving only ~5.5KB of headroom. That's
not enough room for the multi-screen UI, on-watch config persistence,
and wakeup-based daily notification this app still needs to add -
basalt and newer (chalk/diorite/emery/flint/gabbro) all have comfortable
46-112KB of free heap for the same skeleton.
+165
View File
@@ -0,0 +1,165 @@
{
"name": "deck_in_a_dash_watch",
"author": "Matthias Ladkau",
"version": "1.0.0",
"keywords": [
"pebble-app"
],
"private": true,
"dependencies": {},
"pebble": {
"displayName": "Deck in a Dash",
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
"sdkVersion": "3",
"enableMultiJS": true,
"capabilities": [
"configurable"
],
"targetPlatforms": [
"basalt",
"chalk",
"diorite",
"emery",
"flint",
"gabbro"
],
"watchapp": {
"watchface": false
},
"messageKeys": [
"BIRTH_YEAR",
"BIRTH_MONTH",
"BIRTH_DAY",
"BIRTH_HOUR",
"BIRTH_MINUTE",
"BIRTH_UTC_OFFSET_MINUTES",
"BIRTH_LAT_MICRODEG",
"BIRTH_LON_MICRODEG",
"GENDER",
"LANG",
"NOTIFY_ENABLED",
"NOTIFY_HOUR",
"NOTIFY_MINUTE"
],
"resources": {
"media": [
{
"type": "bitmap",
"name": "IMAGE_APP_ICON",
"file": "app_icon.png",
"menuIcon": true
},
{
"type": "bitmap",
"name": "IMG_CARD_FOOL",
"file": "img/fool.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_MAGICIAN",
"file": "img/magician.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HIGH_PRIESTESS",
"file": "img/high_priestess.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_EMPRESS",
"file": "img/empress.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_EMPEROR",
"file": "img/emperor.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HIEROPHANT",
"file": "img/hierophant.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_LOVERS",
"file": "img/lovers.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_CHARIOT",
"file": "img/chariot.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_STRENGTH",
"file": "img/strength.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HERMIT",
"file": "img/hermit.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_WHEEL_OF_FORTUNE",
"file": "img/wheel_of_fortune.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_JUSTICE",
"file": "img/justice.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HANGED_MAN",
"file": "img/hanged_man.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_DEATH",
"file": "img/death.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_TEMPERANCE",
"file": "img/temperance.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_DEVIL",
"file": "img/devil.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_TOWER",
"file": "img/tower.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_STAR",
"file": "img/star.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_MOON",
"file": "img/moon.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_SUN",
"file": "img/sun.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_JUDGEMENT",
"file": "img/judgement.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_WORLD",
"file": "img/world.png"
}
]
}
}
}
+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()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Downsizes res/img/*.jpeg into watch/resources/img/*.png for Pebble.
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
suitable for the desktop HTML reading (an <img> tag pointed at the file
as-is) but far too large for a Pebble app's own resource budget. This
script resizes each to CARD_WIDTH px wide (preserving aspect ratio) and
writes a PNG per card, named after tarot_data.c's own k_card_slug table
so watch/package.json's "resources.media" entries (IMG_CARD_<SLUG
UPPERCASE>, file img/<slug>.png) line up without a separate mapping
table anywhere.
CARD_WIDTH=72 (-> 72x120) is a hardware-verified ceiling, not a design
choice: full-display-width images (144-260px depending on platform) were
tried first and reliably crash real hardware with "PNG memory allocation
failed" - a runtime PNG-decoder memory constraint independent of the
app's own heap budget (lazy-loading only one image at a time didn't help
either). 72px is the size the original per-card screens used
successfully all session before that experiment.
Generated output, like i18n_tables.auto.c - not committed (see
.gitignore), regenerate by running this script directly or via the root
Makefile's `make watchapp` (task/target that also runs `pebble build`).
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_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "img"))
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "img"))
CARD_WIDTH = 72
# (source file in res/img/, slug matching engine/src/tarot_data.c's
# k_card_slug table) - in TarotCard enum order (CARD_FOOL = 0 ... CARD_WORLD).
CARDS = [
("RWS_Tarot_00_Fool.jpeg", "fool"),
("RWS_Tarot_01_Magician.jpeg", "magician"),
("RWS_Tarot_02_High_Priestess.jpeg", "high_priestess"),
("RWS_Tarot_03_Empress.jpeg", "empress"),
("RWS_Tarot_04_Emperor.jpeg", "emperor"),
("RWS_Tarot_05_Hierophant.jpeg", "hierophant"),
("RWS_Tarot_06_Lovers.jpeg", "lovers"),
("RWS_Tarot_07_Chariot.jpeg", "chariot"),
("RWS_Tarot_08_Strength.jpeg", "strength"),
("RWS_Tarot_09_Hermit.jpeg", "hermit"),
("RWS_Tarot_10_Wheel_of_Fortune.jpeg", "wheel_of_fortune"),
("RWS_Tarot_11_Justice.jpeg", "justice"),
("RWS_Tarot_12_Hanged_Man.jpeg", "hanged_man"),
("RWS_Tarot_13_Death.jpeg", "death"),
("RWS_Tarot_14_Temperance.jpeg", "temperance"),
("RWS_Tarot_15_Devil.jpeg", "devil"),
("RWS_Tarot_16_Tower.jpeg", "tower"),
("RWS_Tarot_17_Star.jpeg", "star"),
("RWS_Tarot_18_Moon.jpeg", "moon"),
("RWS_Tarot_19_Sun.jpeg", "sun"),
("RWS_Tarot_20_Judgement.jpeg", "judgement"),
("RWS_Tarot_21_World.jpeg", "world"),
]
def main():
os.makedirs(OUT_DIR, exist_ok=True)
written = 0
for src_name, slug in CARDS:
src_path = os.path.join(SRC_DIR, src_name)
out_path = os.path.join(OUT_DIR, f"{slug}.png")
# wscript runs this on every `pebble build` (like gen_i18n_tables.py)
# so a full 22-image resize must be cheap on the common no-op case -
# skip any card whose output is already newer than its source JPEG.
if os.path.exists(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(src_path):
continue
with Image.open(src_path) as img:
ratio = CARD_WIDTH / img.width
height = round(img.height * ratio)
resized = img.convert("RGB").resize((CARD_WIDTH, height), Image.LANCZOS)
resized.save(out_path, "PNG", optimize=True)
written += 1
print(f"wrote {written}/{len(CARDS)} card images to {OUT_DIR}", file=sys.stderr)
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Generates watch/src/c/i18n_tables.auto.c from engine/i18n/*.lang.
Compiles each non-English .lang file into a `static const char *const`
keys[]/values[] pair for i18n_load_table() (see engine/src/i18n.h's own
doc comment on why the watch needs a zero-copy, compiled-in table
instead of i18n_load()'s file-based parsing). English is deliberately
skipped: the fallback text already baked into tarot_data.c/astro.c/
narrative.c/guidance.c *is* English, so shipping a redundant English
catalog would roughly double this app's translation-data footprint
(which counts against the same ~64KB whole-app budget as everything
else - see watch/README.md) for zero behavioural benefit.
This is a generated file (like message_keys.auto.c, resource_ids.auto.c
elsewhere in a Pebble project) - not meant to be hand-edited or
committed; watch/wscript regenerates it on every build. Parsing rules
mirror engine/src/i18n.c's i18n_load() exactly: '#'-prefixed and blank
lines are skipped, the first '=' splits key/value, and both sides are
trimmed of surrounding spaces/tabs only (not quotes - values are raw
text, same as the desktop file loader).
"""
import glob
import os
import sys
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
I18N_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "engine", "i18n"))
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "src", "c", "i18n_tables.auto.c"))
def parse_lang_file(path):
entries = []
with open(path, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.rstrip("\r\n")
stripped = line.strip(" \t")
if not stripped or stripped.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip(" \t")
value = value.strip(" \t")
if not key:
continue
entries.append((key, value))
return entries
def c_string_literal(s):
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return '"' + escaped + '"'
def main():
lang_files = sorted(glob.glob(os.path.join(I18N_DIR, "*.lang")))
languages = []
for path in lang_files:
code = os.path.splitext(os.path.basename(path))[0]
if code == "en":
continue # see module doc comment - English is never a compiled table
languages.append((code, parse_lang_file(path)))
lines = []
lines.append("/* Generated by watch/scripts/gen_i18n_tables.py from engine/i18n's .lang")
lines.append(" * files - do not edit by hand, and do not commit (see .gitignore). */")
lines.append("")
lines.append('#include "i18n_tables.h"')
lines.append("")
lines.append('#include "../../../engine/src/i18n.h"')
lines.append("")
lines.append("#include <stddef.h>")
lines.append("#include <string.h>")
lines.append("")
for code, entries in languages:
lines.append(f"static const char *const k_keys_{code}[] = {{")
for key, _ in entries:
lines.append(f" {c_string_literal(key)},")
lines.append("};")
lines.append(f"static const char *const k_values_{code}[] = {{")
for _, value in entries:
lines.append(f" {c_string_literal(value)},")
lines.append("};")
lines.append("")
lines.append("void watch_i18n_load(const char *lang_code) {")
for code, entries in languages:
lines.append(f' if (lang_code && strcmp(lang_code, "{code}") == 0) {{')
lines.append(f" i18n_load_table(k_keys_{code}, k_values_{code}, {len(entries)});")
lines.append(" return;")
lines.append(" }")
lines.append(" i18n_load_table(NULL, NULL, 0); /* \"en\" or unrecognized: use built-in English */")
lines.append("}")
lines.append("")
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with open(OUT_PATH, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"wrote {OUT_PATH} ({', '.join(code for code, _ in languages)})", file=sys.stderr)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#include "app_message.h"
#include <pebble.h>
/* AppMessage's Dictionary has no float tuple type, so latitude/
* longitude/utc_offset_hours (all doubles in BirthData) cross the wire
* as scaled int32s - microdegrees for lat/lon (~0.11m precision, far
* finer than this app's astrology needs), minutes for the UTC offset
* (covers every real-world offset, including the half/quarter-hour
* ones) - see config.c's own persisted representation, which uses the
* same scaling so no conversion happens twice. */
static ConfigUpdatedCallback s_callback;
static bool read_int(DictionaryIterator *iter, uint32_t key, int32_t *out) {
Tuple *t = dict_find(iter, key);
if (!t) return false;
*out = t->value->int32;
return true;
}
static void inbox_received_handler(DictionaryIterator *iter, void *context) {
WatchConfig cfg;
config_load(&cfg); /* start from existing settings - a field the page
* doesn't send (e.g. it only changed the language)
* keeps its previous value rather than resetting. */
int32_t v;
bool got_birth = false;
if (read_int(iter, MESSAGE_KEY_BIRTH_YEAR, &v)) { cfg.birth.year = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_MONTH, &v)) { cfg.birth.month = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_DAY, &v)) { cfg.birth.day = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_HOUR, &v)) { cfg.birth.hour = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_MINUTE, &v)) { cfg.birth.minute = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_UTC_OFFSET_MINUTES, &v)) {
cfg.birth.utc_offset_hours = v / 60.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_BIRTH_LAT_MICRODEG, &v)) {
cfg.birth.latitude = v / 1000000.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_BIRTH_LON_MICRODEG, &v)) {
cfg.birth.longitude = v / 1000000.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v;
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
if (lang_tuple) {
strncpy(cfg.lang, lang_tuple->value->cstring, sizeof(cfg.lang) - 1);
cfg.lang[sizeof(cfg.lang) - 1] = '\0';
}
if (read_int(iter, MESSAGE_KEY_NOTIFY_ENABLED, &v)) cfg.notify_enabled = (v != 0);
if (read_int(iter, MESSAGE_KEY_NOTIFY_HOUR, &v)) cfg.notify_hour = v;
if (read_int(iter, MESSAGE_KEY_NOTIFY_MINUTE, &v)) cfg.notify_minute = v;
/* Birth data is the one thing that makes a config submission "complete" -
* language/notification-only re-submissions (e.g. from a later re-open
* of the settings page) must not un-configure an already-set-up watch. */
if (got_birth) cfg.configured = true;
config_log(&cfg); /* confirms what was actually received/parsed */
config_save(&cfg);
if (s_callback) s_callback(&cfg);
}
static void inbox_dropped_handler(AppMessageResult reason, void *context) {
APP_LOG(APP_LOG_LEVEL_ERROR, "AppMessage dropped: reason %d", (int)reason);
}
/* app_message_*_size_maximum() return the largest buffer the platform
* *could* hand out (8200 bytes each) - not a size to actually request.
* The real payload is ~150-200 bytes; 256 bytes each leaves headroom
* without eating into this app's ~12KB heap budget (see watch/README.md). */
#define INBOX_SIZE 256
#define OUTBOX_SIZE 256
void app_message_init(ConfigUpdatedCallback on_config_updated) {
s_callback = on_config_updated;
app_message_register_inbox_received(inbox_received_handler);
app_message_register_inbox_dropped(inbox_dropped_handler);
app_message_open(INBOX_SIZE, OUTBOX_SIZE);
}
+20
View File
@@ -0,0 +1,20 @@
#ifndef WATCH_APP_MESSAGE_H
#define WATCH_APP_MESSAGE_H
#include "config.h"
/* Invoked with the freshly-saved config whenever a settings submission
* arrives from the config page (src/pkjs/index.js, task #43) - main.c
* uses this to recompute the reading and refresh/show the menu. */
typedef void (*ConfigUpdatedCallback)(const WatchConfig *cfg);
/* Registers the AppMessage inbox handler and opens the message buffers.
* Call once at startup, before app_event_loop(). Message keys
* (BIRTH_YEAR, BIRTH_UTC_OFFSET_MINUTES, LANG, NOTIFY_ENABLED, etc.) are
* declared in watch/package.json's "messageKeys" and turn into
* MESSAGE_KEY_* constants in the auto-generated message_keys.auto.h -
* see app_message.c's own comment on why latitude/longitude/utc offset
* travel as scaled integers rather than floats. */
void app_message_init(ConfigUpdatedCallback on_config_updated);
#endif
+90
View File
@@ -0,0 +1,90 @@
#include "config.h"
#include <string.h>
/* Persist keys. BirthData's latitude/longitude/utc_offset_hours are
* doubles, but Pebble's persist API has no float storage - stored as
* scaled int32s instead (microdegrees / minutes), matching how
* AppMessage carries the same fields from the config page - see
* app_message.c. */
enum {
PKEY_CONFIGURED = 0,
PKEY_BIRTH_YEAR,
PKEY_BIRTH_MONTH,
PKEY_BIRTH_DAY,
PKEY_BIRTH_HOUR,
PKEY_BIRTH_MINUTE,
PKEY_BIRTH_UTC_OFFSET_MINUTES,
PKEY_BIRTH_LAT_MICRODEG,
PKEY_BIRTH_LON_MICRODEG,
PKEY_GENDER,
PKEY_LANG,
PKEY_NOTIFY_ENABLED,
PKEY_NOTIFY_HOUR,
PKEY_NOTIFY_MINUTE,
};
void config_load(WatchConfig *out) {
memset(out, 0, sizeof *out);
out->configured = persist_exists(PKEY_CONFIGURED) && persist_read_bool(PKEY_CONFIGURED);
if (!out->configured) {
strcpy(out->lang, "en");
return;
}
out->birth.year = (int)persist_read_int(PKEY_BIRTH_YEAR);
out->birth.month = (int)persist_read_int(PKEY_BIRTH_MONTH);
out->birth.day = (int)persist_read_int(PKEY_BIRTH_DAY);
out->birth.hour = (int)persist_read_int(PKEY_BIRTH_HOUR);
out->birth.minute = (int)persist_read_int(PKEY_BIRTH_MINUTE);
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0;
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
out->gender = (Gender)persist_read_int(PKEY_GENDER); /* defaults to 0 = GENDER_UNSPECIFIED */
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
strcpy(out->lang, "en");
}
out->notify_enabled = persist_read_bool(PKEY_NOTIFY_ENABLED);
out->notify_hour = (int)persist_read_int(PKEY_NOTIFY_HOUR);
out->notify_minute = (int)persist_read_int(PKEY_NOTIFY_MINUTE);
}
void config_save(const WatchConfig *cfg) {
persist_write_bool(PKEY_CONFIGURED, cfg->configured);
persist_write_int(PKEY_BIRTH_YEAR, cfg->birth.year);
persist_write_int(PKEY_BIRTH_MONTH, cfg->birth.month);
persist_write_int(PKEY_BIRTH_DAY, cfg->birth.day);
persist_write_int(PKEY_BIRTH_HOUR, cfg->birth.hour);
persist_write_int(PKEY_BIRTH_MINUTE, cfg->birth.minute);
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0));
persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
persist_write_int(PKEY_GENDER, (int32_t)cfg->gender);
persist_write_string(PKEY_LANG, cfg->lang);
persist_write_bool(PKEY_NOTIFY_ENABLED, cfg->notify_enabled);
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
}
/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support
* floating-point format specifiers. */
void config_log(const WatchConfig *cfg) {
if (!cfg->configured) {
APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet");
return;
}
APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin",
cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
(int)(cfg->birth.utc_offset_hours * 60));
APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 gender=%d lang=%s",
(long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000),
(int)cfg->gender, cfg->lang);
APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
}

Some files were not shown because too many files have changed in this diff Show More