Compare commits
13 Commits
41422adf98
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c60af5142 | |||
| 3bc1263294 | |||
| a3e437277f | |||
| ff2b43f1e0 | |||
| 4e83ebbf50 | |||
| 7a77c4a07d | |||
| 1ebbfebe31 | |||
| b94e76931a | |||
| 1a654da3e9 | |||
| d9b6e2889b | |||
| 83248561cc | |||
| e522b4d6f5 | |||
| 1662d550c7 |
@@ -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
|
||||
@@ -2,6 +2,33 @@
|
||||
# data once filled in — never commit it.
|
||||
/dist
|
||||
/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/
|
||||
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
|
||||
@@ -13,3 +40,7 @@ core
|
||||
core.*
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Container registry credentials for build-image.sh/run-image.sh/
|
||||
# upload-image.sh — never commit; copy registry.env.example instead.
|
||||
/registry.env
|
||||
|
||||
@@ -5,13 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Project goal
|
||||
|
||||
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
||||
Cross) and astrology (natal chart + transits) reading. Currently only the
|
||||
**core engine** exists: a plain-C, dependency-free library plus a
|
||||
Cross) and astrology (natal chart + transits) reading. This repository
|
||||
holds the **core engine**: a plain-C, dependency-free library plus a
|
||||
standalone CLI binary (`dist/deck-engine`) that can be built and run
|
||||
without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI,
|
||||
resource packs) has not been started yet — the engine is deliberately
|
||||
built so it can be linked straight into it later without rework (see
|
||||
"Portability to the watch" below).
|
||||
without any Pebble SDK or emulator, deliberately built so it links
|
||||
straight into Pebble C/UI watch code without rework (see "Portability to
|
||||
the watch" below).
|
||||
|
||||
## 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
|
||||
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 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
|
||||
@@ -64,6 +67,174 @@ 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,
|
||||
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. `ReadingScreen`'s layout mirrors
|
||||
`watch/src/c/ui_report_window.c`'s `build_content()` section-for-section
|
||||
(date → day significance, shown with its rank/count e.g. "Notable
|
||||
(2/4)" → a Guidance section leading with an Attitude/Outcome card
|
||||
preview, image only, then the day's top transit, then the guidance
|
||||
paragraph → the full significant-transits list → the full Celtic Cross
|
||||
spread), not the interpreter's own `--format html` layout, which orders
|
||||
sections differently and is a separate, standalone rendering path.
|
||||
|
||||
The compiled-in `.lang` strings the JNI bridge already localizes (card
|
||||
text, narrative, guidance) don't cover the Compose UI's own chrome
|
||||
(nav labels, section headings, notification text) - those are ordinary
|
||||
Android string resources, which by default resolve against the
|
||||
*device's* system locale, not `DeckConfig.lang`. Since this app's
|
||||
reading language is an explicit in-Settings choice independent of the
|
||||
system locale (`android/app/src/main/res/values-de/strings.xml` holds
|
||||
the German chrome strings), every chrome string is read through
|
||||
`ui/AppStrings.kt`'s `appStringResource()` instead of Compose's own
|
||||
`stringResource()` - it wraps the `Context` in a `Configuration`
|
||||
forced to `LocalAppLanguage` (provided once, at the root, from
|
||||
`ConfigRepository`'s `DeckConfig.lang`) via
|
||||
`util/LocaleUtils.kt`'s `Context.localizedContext()`, the same helper
|
||||
`ReadingNotificationWorker` uses for its own notification/channel text
|
||||
outside Compose entirely.
|
||||
|
||||
**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
|
||||
|
||||
```
|
||||
@@ -92,19 +263,20 @@ deck_in_a_dash/
|
||||
tests/smoke_test.c
|
||||
scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
|
||||
interpreter/ Separate module + binary; see "Interpretation" below.
|
||||
dist/ Build output (gitignored-style; see Build & run).
|
||||
Makefile Single root Makefile, builds both engine/ and interpreter/.
|
||||
dist/ Build output (gitignored-style; see Build & run) and,
|
||||
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
|
||||
|
||||
`reading_generate(seed, birth, utc_moment, &DailyReading)` in
|
||||
`reading.h` is the single entry point that matters — it's what the
|
||||
watchapp will call once it exists. It populates a plain struct
|
||||
(`NatalChart` + `DailyTransits` + `CelticCrossSpread`) that the caller
|
||||
walks directly to build a UI. `reading_print_text`/`reading_print_html`
|
||||
are desktop-only conveniences for inspecting a reading (used by the CLI);
|
||||
the watchapp will never call them.
|
||||
`reading.h` is the single entry point that matters — the real API
|
||||
surface. It populates a plain struct (`NatalChart` + `DailyTransits` +
|
||||
`CelticCrossSpread`) that a caller walks directly to build a UI.
|
||||
`reading_print_text`/`reading_print_html` are desktop-only text/HTML
|
||||
renderers of that same struct, used by the CLI.
|
||||
|
||||
### Determinism (`rng.c`)
|
||||
|
||||
@@ -162,6 +334,23 @@ more common (but less original) ordering; don't use it as a reference for
|
||||
the position enum order. Card and position text in `tarot_data.c` is
|
||||
condensed from Waite's own wording in Part III §3 (public domain).
|
||||
|
||||
**Gender** (`Gender` in `tarot.h`: `GENDER_UNSPECIFIED`/`GENDER_MALE`/
|
||||
`GENDER_FEMALE`) is who the reading is for — it only affects the
|
||||
pronouns in the 9 Celtic Cross position names/descriptions that have one
|
||||
(Waite addresses the querent as "him" throughout; every other position
|
||||
text in this codebase — card meanings, narrative, guidance — is already
|
||||
pronoun-neutral or direct "you" address, so nothing else varies with
|
||||
it). `DailyReading.gender` is set by `reading_generate()` the same way
|
||||
`utc_moment` is, `deck-engine`'s `--gender male|female|unspecified`
|
||||
flag/`dist/user.properties`' `gender=` field/the watch config page's
|
||||
Gender field all feed it, and `reading_print_json`/`reading_load_json`
|
||||
carry it across the `deck-engine` → `interpreter-cli` JSON boundary
|
||||
(`tarot_gender_slug()`) the same way `date` does. `GENDER_UNSPECIFIED`
|
||||
is the default and renders pronoun-neutral phrasing (singular "they" in
|
||||
English; German rephrases around a noun like "die fragende Person"
|
||||
rather than a pronoun, since German has no equivalent singular-they
|
||||
construction in common use).
|
||||
|
||||
### Translations (`i18n.c`, `engine/i18n/*.lang`)
|
||||
|
||||
All display text (planet/sign/moon-phase/aspect names, tarot card names
|
||||
@@ -179,51 +368,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
|
||||
own labels — deliberately keyed off separate slug strings rather than
|
||||
the C enum names, so renaming an enumerator can never silently break a
|
||||
`.lang` file. **To add a language**: copy `engine/i18n/en.lang` to
|
||||
`.lang` file. `position.<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
|
||||
translate the values — no source changes needed, `make` picks up any
|
||||
`*.lang` file there automatically.
|
||||
|
||||
`i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and
|
||||
`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
|
||||
to port to the watch once it has its own (non-file-based) way to
|
||||
populate the catalog, e.g. from a compiled-in resource.
|
||||
is a pure fixed-size-array lookup with no heap allocation, so it links
|
||||
into the watch as-is given a non-file-based way to populate the catalog,
|
||||
e.g. from a compiled-in resource.
|
||||
|
||||
### Portability to the watch
|
||||
|
||||
`rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate`
|
||||
are kept free of `stdio`/CLI assumptions specifically so they can link
|
||||
into the Pebble watchapp unchanged later. Only `reading_print_text`/
|
||||
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load`
|
||||
(reads a file from disk) are desktop-only and won't port as-is.
|
||||
are kept free of `stdio`/CLI assumptions specifically so they link into
|
||||
the Pebble watchapp unchanged. Only `reading_print_text`/`_html` (text
|
||||
formatting), `main.c` (CLI arg parsing), and `i18n_load` (reads a file
|
||||
from disk) are desktop-only and don't port as-is.
|
||||
|
||||
## Interpretation (`interpreter/`)
|
||||
|
||||
A second, separate top-level module and **its own binary**
|
||||
(`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
|
||||
a short narrative sentence about what that transit classically means —
|
||||
the first piece of a larger "turn the raw reading into an actual
|
||||
narrative" effort described in project chat history; the Celtic Cross
|
||||
spread itself isn't woven into that narrative yet (see
|
||||
`docs/reading.md`). `deck-engine` itself is deliberately unchanged by
|
||||
this beyond gaining `--format json`; the two binaries compose over that
|
||||
JSON, never by linking together:
|
||||
significant a day's transits are*, prints a short narrative sentence for
|
||||
each of the top items about what that transit classically means, reads
|
||||
out the full Celtic Cross spread with every card's meaning, and finally
|
||||
prints guidance tying the day's top transit to the spread's
|
||||
Attitude/Outcome cards; see `docs/reading.md`. Like `deck-engine`, it
|
||||
supports `--format text|html|json` and `--lang <code>`/`--i18n-dir
|
||||
<path>` for its own output - see `main.c`'s bullets below for both.
|
||||
`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
|
||||
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
|
||||
|
||||
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, 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
|
||||
separate compilation units throughout — no object file is ever shared
|
||||
between the two binaries (see the next bullet).
|
||||
Note `--lang` means something different on each side of the pipe:
|
||||
`deck-engine`'s own `--lang` (if passed at all) is irrelevant here, since
|
||||
`--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
|
||||
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
|
||||
@@ -234,17 +439,15 @@ between the two binaries (see the next bullet).
|
||||
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
|
||||
`DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true
|
||||
only at `DAY_MAJOR` — the intended hook for giving the Celtic Cross
|
||||
reading a "this matters" intro sentence naming `top_items[0]` on days
|
||||
that earn it; that rendering doesn't exist yet, `main.c`'s report is
|
||||
plain text only.
|
||||
only at `DAY_MAJOR` — `main.c` uses it solely to decide whether to
|
||||
print an extra "worth a deeper Celtic Cross look" line; it's not a
|
||||
gate on `guidance.c` (below), which runs on every day level.
|
||||
- Score = `transiting-planet weight (slow/outer planets score higher) ×
|
||||
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
|
||||
(transits to natal Sun/Moon score higher than to other planets)`. The
|
||||
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
|
||||
thresholds in `significance.c` are hand-tuned, not derived from
|
||||
anything physical — expect to retune them once real readings are
|
||||
compared against how "major" a day actually feels.
|
||||
anything physical.
|
||||
- **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence
|
||||
description printed under each top item, condensed from Sepharial's
|
||||
*Transits and Planetary Periods* (1920, public domain —
|
||||
@@ -267,7 +470,81 @@ between the two binaries (see the next bullet).
|
||||
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`
|
||||
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:
|
||||
the full `DailyReading` (natal, transits, spread), using the
|
||||
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
|
||||
@@ -283,31 +560,37 @@ between the two binaries (see the next bullet).
|
||||
and fills a `DailyReading` with those (used for scoring), plus
|
||||
`"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign
|
||||
house, via `parse_bodies()` — used only for the sign/house context
|
||||
`main.c` prints alongside each significant event, never for scoring).
|
||||
`"spread"` is left zeroed entirely - nothing reads it. An aspect naming
|
||||
a planet/aspect-type slug it doesn't recognize, or a document missing
|
||||
`"transits"."aspects"` entirely (as opposed to a present-but-empty
|
||||
array, which is a valid "no aspects today"), makes the whole load fail;
|
||||
by contrast an unrecognized body slug or missing `house` inside
|
||||
`"bodies"` is silently skipped (that body's `house` stays `0`, an
|
||||
invalid whole-sign house number used as "no data" - `main.c` checks for
|
||||
it before printing sign/house), since that's supplementary display
|
||||
context rather than something scoring depends on.
|
||||
- **Deliberately header-only dependency on the engine, throughout**:
|
||||
`significance.h`/`reading_io.h`/`narrative.h` `#include` engine headers
|
||||
(`reading.h`/`astro.h`) for the struct/enum *definitions*, but the root
|
||||
`Makefile` never compiles or links any engine `.c` file (or the
|
||||
vendored Astronomy Engine) into the interpreter's binary or tests —
|
||||
every
|
||||
test in `interpreter/tests/` runs against hand-built JSON text or
|
||||
hand-built `DailyReading` values, with no real ephemeris/tarot-draw
|
||||
call involved anywhere. Consequently `reading_io.c` (planet/aspect/sign
|
||||
slugs) and `main.c` (planet/aspect/sign display names) each duplicate a
|
||||
small local table rather than linking `astro.c` to reuse its own -
|
||||
keep those in sync if the engine's slugs/names ever change.
|
||||
- Only `Aspect`s compete for the top 5 so far (`SignificantItemKind` is
|
||||
currently just `ITEM_ASPECT`); moon phase and house ingresses are
|
||||
candidate future item kinds but aren't scored yet.
|
||||
`main.c` prints alongside each significant event, never for scoring)
|
||||
and `"spread"."positions"` (each position's card + reversed flag, via
|
||||
`parse_spread()` — used by `main.c`'s full Celtic Cross readout and by
|
||||
`guidance.c`'s Attitude/Outcome framing, never for scoring). An
|
||||
aspect naming a planet/aspect-type slug it doesn't recognize, or a
|
||||
document missing `"transits"."aspects"` entirely (as opposed to a
|
||||
present-but-empty array, which is a valid "no aspects today"), makes
|
||||
the whole load fail; by contrast an unrecognized body/card/position
|
||||
slug, a missing `house` inside `"bodies"`, or a missing `"spread"` key
|
||||
entirely is silently skipped/zeroed (`main.c` checks for the body
|
||||
"no data" sentinel before printing sign/house), since all of that is
|
||||
supplementary display context rather than something scoring depends
|
||||
on.
|
||||
- **Mostly header-only dependency on the engine**: `significance.h`/
|
||||
`reading_io.h`/`narrative.h`/`guidance.h` `#include` engine headers
|
||||
(`reading.h`/`astro.h`/`tarot.h`) for the struct/enum *definitions*,
|
||||
and the root `Makefile` never compiles or links `astro.c`, `tarot.c`,
|
||||
`rng.c`, `reading.c`, or the vendored Astronomy Engine into the
|
||||
interpreter's binary or tests — every test in `interpreter/tests/`
|
||||
runs against hand-built JSON text or hand-built `DailyReading`/
|
||||
`DailyInterpretation` values, with no real ephemeris/tarot-draw call
|
||||
involved anywhere. Consequently `reading_io.c` (planet/aspect/sign/
|
||||
card/position slugs) and `main.c` (planet/aspect/sign display names)
|
||||
each duplicate a small local table rather than linking `astro.c` to
|
||||
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
|
||||
|
||||
@@ -317,11 +600,28 @@ between the two binaries (see the next bullet).
|
||||
`VENDORED.md` for the pinned upstream commit.
|
||||
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
|
||||
from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
|
||||
domain.
|
||||
domain. The `GENDER_MALE` variant of the Celtic Cross position text
|
||||
keeps Waite's own "him"/"his" wording verbatim; the
|
||||
`GENDER_UNSPECIFIED`/`GENDER_FEMALE` variants are original paraphrases
|
||||
written for this project, not Waite's own text (see "Tarot" above).
|
||||
- `engine/i18n/de.lang`'s card/spread text is an original translation of
|
||||
that same public-domain English text, made for this project — not
|
||||
taken from any specific published German edition.
|
||||
taken from any specific published German edition. Its `Gender`-varying
|
||||
position text follows the same policy as the English original: `.male`
|
||||
is a direct translation of Waite's own wording, `.female`/unsuffixed
|
||||
are original paraphrases for this project.
|
||||
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
|
||||
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
||||
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
||||
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).
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
CC ?= cc
|
||||
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
|
||||
|
||||
BUILD_DIR := build
|
||||
@@ -36,41 +43,71 @@ ENGINE_TEST_BINARY := $(BUILD_DIR)/smoke_test
|
||||
|
||||
# ---- interpreter ----
|
||||
|
||||
# Only ever compiles this module's own sources - significance.h/
|
||||
# reading_io.h reach into engine/src/ for struct/enum *definitions*
|
||||
# (plain #includes), but no engine/src/*.c (or the vendored Astronomy
|
||||
# Engine) is compiled or linked here. That's what keeps this module (and
|
||||
# its tests) independent of the engine's actual ephemeris/tarot-draw
|
||||
# Mostly compiles only this module's own sources - significance.h/
|
||||
# reading_io.h/narrative.h reach into engine/src/ for struct/enum
|
||||
# *definitions* (plain #includes) without linking any engine/src/*.c (or
|
||||
# the vendored Astronomy Engine). That's what keeps those modules (and
|
||||
# their tests) independent of the engine's actual ephemeris/tarot-draw
|
||||
# implementation - see significance.c's and reading_io.c's own comments
|
||||
# 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_TEST_DIR := interpreter/tests
|
||||
|
||||
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_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
|
||||
|
||||
SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/significance_test.o
|
||||
JSON_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/json_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
|
||||
JSON_TEST_BIN := $(BUILD_DIR)/json_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
|
||||
|
||||
$(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ)
|
||||
@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)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||
|
||||
# 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).
|
||||
@@ -109,29 +146,135 @@ scripts:
|
||||
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \
|
||||
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)
|
||||
./$(SIGNIFICANCE_TEST_BIN)
|
||||
./$(JSON_TEST_BIN)
|
||||
./$(NARRATIVE_TEST_BIN)
|
||||
./$(GUIDANCE_TEST_BIN)
|
||||
|
||||
$(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)
|
||||
$(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)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||
|
||||
$(NARRATIVE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/narrative.o $(NARRATIVE_TEST_OBJ)
|
||||
$(CC) $(CFLAGS) -o $@ $^
|
||||
$(NARRATIVE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/narrative.o $(INTERP_TAROT_TEXT_OBJS) $(NARRATIVE_TEST_OBJ)
|
||||
$(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
|
||||
@mkdir -p $(dir $@)
|
||||
$(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:
|
||||
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
|
||||
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
|
||||
# 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"
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
||||
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
|
||||
reading without needing a Pebble device, emulator, or SDK. The watchapp
|
||||
itself hasn't been built yet; the engine is designed so it can be linked
|
||||
into it later without rework.
|
||||
reading without needing a Pebble device, emulator, or SDK, designed so
|
||||
it links straight into Pebble watch code without rework.
|
||||
|
||||
## What it computes
|
||||
|
||||
@@ -45,12 +44,36 @@ engine/
|
||||
the user.properties template.
|
||||
dist/ Build output (gitignored) — see below.
|
||||
interpreter/ Separate module + binary (dist/interpreter-cli):
|
||||
scores how significant a day's transits are
|
||||
and prints a short narrative note for each,
|
||||
reading deck-engine's --format json output
|
||||
(see CLAUDE.md).
|
||||
scores how significant a day's transits are,
|
||||
reads out the full Celtic Cross spread with
|
||||
every card's meaning, and closes with guidance
|
||||
(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.
|
||||
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
|
||||
@@ -66,10 +89,47 @@ struct format reference
|
||||
make # -> dist/deck-engine, dist/interpreter-cli, dist/img/, dist/i18n/,
|
||||
# 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 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.
|
||||
|
||||
**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
|
||||
|
||||
**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`):
|
||||
scores how significant a day's transits are, reading `deck-engine`'s
|
||||
`--format json` output — the two binaries compose over that JSON, they're
|
||||
never linked together. `dist/run-interpreter.sh` is the daily-use
|
||||
equivalent of `run-engine.sh` (same OS clock + `user.properties` inputs),
|
||||
piping straight into `interpreter-cli`:
|
||||
scores how significant a day's transits are, reads out the full Celtic
|
||||
Cross spread with every card's meaning, and closes with guidance tying
|
||||
the day's biggest transit to the Attitude/Outcome cards - reading
|
||||
`deck-engine`'s `--format json` output. Its own output supports the same
|
||||
`--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
|
||||
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:
|
||||
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
|
||||
@@ -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
|
||||
public domain, except the Moon and Pluto entries, which are original
|
||||
(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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(...);
|
||||
}
|
||||
@@ -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,118 @@
|
||||
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 de.ladkau.deckinadash.util.localizedContext
|
||||
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, config.lang)
|
||||
NotificationScheduler.reschedule(applicationContext, config)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun postNotification(reading: ReadingDto, lang: String) {
|
||||
val localized = applicationContext.localizedContext(lang)
|
||||
ensureChannel(localized)
|
||||
|
||||
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(
|
||||
localized.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(localized: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = applicationContext.getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
localized.getString(R.string.notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = localized.getString(R.string.notification_channel_description)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.ladkau.deckinadash.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.ladkau.deckinadash.util.localizedContext
|
||||
|
||||
/** The reading language selected in Settings (DeckConfig.lang) - provided
|
||||
* once near the root (see DeckInADashApp) and read by every screen's
|
||||
* appStringResource() call below instead of Compose's own stringResource(),
|
||||
* which always follows the device's system locale rather than this
|
||||
* in-app setting. */
|
||||
val LocalAppLanguage = compositionLocalOf { "en" }
|
||||
|
||||
@Composable
|
||||
private fun rememberLocalizedContext(): Context {
|
||||
val lang = LocalAppLanguage.current
|
||||
val base = LocalContext.current
|
||||
return remember(base, lang) { base.localizedContext(lang) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun appStringResource(@StringRes id: Int): String = rememberLocalizedContext().getString(id)
|
||||
|
||||
@Composable
|
||||
fun appStringResource(@StringRes id: Int, vararg formatArgs: Any): String =
|
||||
rememberLocalizedContext().getString(id, *formatArgs)
|
||||
@@ -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,122 @@
|
||||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.data.ConfigRepository
|
||||
import de.ladkau.deckinadash.data.DeckConfig
|
||||
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
|
||||
|
||||
// Provided here, at the root, so every screen's appStringResource()
|
||||
// call below picks up the configured reading language rather than
|
||||
// the device's system locale - see AppStrings.kt's own comment.
|
||||
val context = LocalContext.current
|
||||
val configRepository = remember { ConfigRepository(context) }
|
||||
val appConfig by configRepository.config.collectAsState(initial = DeckConfig())
|
||||
|
||||
CompositionLocalProvider(LocalAppLanguage provides appConfig.lang) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet {
|
||||
NavigationDrawerItem(
|
||||
label = { Text(appStringResource(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(appStringResource(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(appStringResource(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(appStringResource(R.string.app_name)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = appStringResource(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.unit.dp
|
||||
import de.ladkau.deckinadash.R
|
||||
import de.ladkau.deckinadash.ui.appStringResource
|
||||
|
||||
/** 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 = appStringResource(R.string.about_body),
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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.OutlinedButton
|
||||
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.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 de.ladkau.deckinadash.ui.appStringResource
|
||||
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 isDirty by viewModel.isDirty.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(appStringResource(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 {
|
||||
appStringResource(R.string.config_birth_date)
|
||||
}
|
||||
Text(label)
|
||||
}
|
||||
|
||||
Text(appStringResource(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(appStringResource(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(appStringResource(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(appStringResource(R.string.config_longitude)) },
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
)
|
||||
|
||||
Text(appStringResource(R.string.config_gender), style = MaterialTheme.typography.titleSmall)
|
||||
val genderOptions = listOf(
|
||||
Gender.UNSPECIFIED to appStringResource(R.string.config_gender_unspecified),
|
||||
Gender.MALE to appStringResource(R.string.config_gender_male),
|
||||
Gender.FEMALE to appStringResource(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(appStringResource(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))
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(onClick = { viewModel.cancel() }, enabled = isDirty) {
|
||||
Text(appStringResource(R.string.config_cancel))
|
||||
}
|
||||
Button(onClick = { viewModel.save {} }, enabled = isDirty) {
|
||||
Text(appStringResource(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(appStringResource(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(appStringResource(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(appStringResource(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,67 @@
|
||||
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.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
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()
|
||||
|
||||
// The last value actually persisted (or loaded from DataStore) -
|
||||
// compared against the draft above to drive the Save/Cancel buttons'
|
||||
// enabled state, so Save visibly grays out once there's nothing to
|
||||
// save rather than always looking clickable.
|
||||
private val _savedConfig = MutableStateFlow(DeckConfig())
|
||||
|
||||
val isDirty: StateFlow<Boolean> = combine(_config, _savedConfig) { draft, saved -> draft != saved }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val loaded = configRepository.config.first()
|
||||
_config.value = loaded
|
||||
_savedConfig.value = loaded
|
||||
}
|
||||
}
|
||||
|
||||
fun update(transform: (DeckConfig) -> DeckConfig) {
|
||||
_config.value = transform(_config.value)
|
||||
}
|
||||
|
||||
/** Discards the current draft, reverting it to the last persisted value. */
|
||||
fun cancel() {
|
||||
_config.value = _savedConfig.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)
|
||||
_config.value = toSave
|
||||
_savedConfig.value = toSave
|
||||
NotificationScheduler.reschedule(getApplication(), toSave)
|
||||
onSaved()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.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.appStringResource
|
||||
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(appStringResource(R.string.reading_loading), loading = true)
|
||||
is ReadingUiState.NotConfigured -> Column(
|
||||
modifier = Modifier.wrapContentSize(Alignment.Center).padding(24.dp),
|
||||
) {
|
||||
Text(appStringResource(R.string.reading_not_configured))
|
||||
Button(onClick = onNavigateToSettings, modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text(appStringResource(R.string.nav_settings))
|
||||
}
|
||||
}
|
||||
is ReadingUiState.Error -> CenteredMessage(appStringResource(R.string.reading_error))
|
||||
is ReadingUiState.Content -> ReadingContent(state.reading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(text: String, loading: Boolean = false) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(bottom = 16.dp))
|
||||
}
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Single scrollable page, mirroring watch/src/c/ui_report_window.c's
|
||||
* build_content() section-for-section: date; day significance (with its
|
||||
* rank/count, e.g. "Notable (2/4)"); a "Guidance" section that leads with
|
||||
* the Attitude/Outcome card preview (image only, no caption - same
|
||||
* reasoning as that file's own comment) followed by the day's single
|
||||
* top transit and the guidance paragraph; the full list of significant
|
||||
* transits; and finally the full Celtic Cross spread. */
|
||||
@Composable
|
||||
private fun ReadingContent(reading: ReadingDto) {
|
||||
val attitude = reading.spread.firstOrNull { it.positionSlug == "attitude" }
|
||||
val outcome = reading.spread.firstOrNull { it.positionSlug == "outcome" }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(reading.date, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
appStringResource(R.string.reading_day_significance_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
"${reading.daySignificance.levelLabel} (${reading.daySignificance.rank}/${reading.daySignificance.count})",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Text(
|
||||
appStringResource(R.string.reading_guidance_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
if (attitude != null && outcome != null) {
|
||||
item { AttitudeOutcomeRow(attitude, outcome) }
|
||||
}
|
||||
reading.transits.firstOrNull()?.let { topTransit ->
|
||||
item { TransitItem(topTransit) }
|
||||
}
|
||||
item { Text(reading.guidance, style = MaterialTheme.typography.bodyMedium) }
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Text(
|
||||
appStringResource(R.string.reading_transits_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
if (reading.transits.isEmpty()) {
|
||||
item { Text(appStringResource(R.string.reading_no_notable_transits)) }
|
||||
} else {
|
||||
items(reading.transits) { transit -> TransitItem(transit) }
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Text(
|
||||
appStringResource(R.string.reading_spread_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
items(reading.spread) { position -> SpreadItem(position) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttitudeOutcomeRow(attitude: SpreadPositionDto, outcome: SpreadPositionDto) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
AttitudeOutcomeImage(attitude)
|
||||
AttitudeOutcomeImage(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttitudeOutcomeImage(position: SpreadPositionDto) {
|
||||
val context = LocalContext.current
|
||||
Image(
|
||||
painter = painterResource(cardDrawableRes(context, position.cardSlug)),
|
||||
contentDescription = position.cardName,
|
||||
modifier = Modifier
|
||||
.width(140.dp)
|
||||
.graphicsLayer { rotationZ = if (position.reversed) 180f else 0f },
|
||||
)
|
||||
}
|
||||
|
||||
@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} ${appStringResource(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)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.ladkau.deckinadash.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Returns a Context whose string resources are resolved for [lang],
|
||||
* independent of the device's system locale. DeckConfig.lang is a
|
||||
* user-chosen reading language (see ConfigScreen's language dropdown),
|
||||
* separate from whatever locale the phone itself is set to - the same way
|
||||
* the native engine's i18n_get() already renders card/narrative/guidance
|
||||
* text in that language regardless of system locale. Without this, Compose's
|
||||
* own stringResource()/Context.getString() would resolve UI chrome text
|
||||
* (headings, notification text) against the system locale's resources
|
||||
* instead, which for a language with no values-<lang>/ directory at all
|
||||
* would render as English even when the reading itself is in German.
|
||||
*/
|
||||
fun Context.localizedContext(lang: String): Context {
|
||||
val config = Configuration(resources.configuration)
|
||||
config.setLocale(Locale.forLanguageTag(lang))
|
||||
return createConfigurationContext(config)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- German translation of the UI chrome text in ../values/strings.xml -
|
||||
keys must match exactly, same convention as engine/i18n/de.lang.
|
||||
Read either via the system locale (a German-locale device) or,
|
||||
primarily, via LocaleUtils.kt's localizedContext(lang) so the app's
|
||||
own in-Settings reading-language choice (independent of the device's
|
||||
system locale) picks these up - see ui/AppStrings.kt. -->
|
||||
<resources>
|
||||
<string name="app_name">Deck in a Dash</string>
|
||||
|
||||
<string name="nav_reading">Heutige Lesung</string>
|
||||
<string name="nav_settings">Einstellungen</string>
|
||||
<string name="nav_about">Über</string>
|
||||
|
||||
<string name="notification_channel_name">Tägliche Lesung</string>
|
||||
<string name="notification_channel_description">Eine tägliche Benachrichtigung mit deiner Tarot- und Astrologie-Lesung</string>
|
||||
<string name="notification_title">Heutige Lesung: %1$s</string>
|
||||
|
||||
<string name="config_title">Einstellungen</string>
|
||||
<string name="config_birth_date">Geburtsdatum</string>
|
||||
<string name="config_birth_time">Geburtszeit</string>
|
||||
<string name="config_utc_offset">UTC-Versatz (Stunden)</string>
|
||||
<string name="config_latitude">Breitengrad</string>
|
||||
<string name="config_longitude">Längengrad</string>
|
||||
<string name="config_gender">Lesung für</string>
|
||||
<string name="config_gender_unspecified">Nicht angegeben</string>
|
||||
<string name="config_gender_male">Männlich</string>
|
||||
<string name="config_gender_female">Weiblich</string>
|
||||
<string name="config_language">Sprache</string>
|
||||
<string name="config_notify_enabled">Tägliche Benachrichtigung</string>
|
||||
<string name="config_notify_time">Benachrichtigungszeit</string>
|
||||
<string name="config_save">Speichern</string>
|
||||
<string name="config_cancel">Verwerfen</string>
|
||||
|
||||
<string name="reading_not_configured">Trage deine Geburtsdaten in den Einstellungen ein, um die heutige Lesung zu sehen.</string>
|
||||
<string name="reading_loading">Berechne die heutige Lesung…</string>
|
||||
<string name="reading_error">Die heutige Lesung konnte nicht berechnet werden.</string>
|
||||
<string name="reading_day_significance_heading">Bedeutung des Tages</string>
|
||||
<string name="reading_guidance_heading">Rat für heute</string>
|
||||
<string name="reading_transits_heading">Wichtige Transits</string>
|
||||
<string name="reading_no_notable_transits">Heute keine nennenswerten Transits.</string>
|
||||
<string name="reading_spread_heading">Keltisches Kreuz</string>
|
||||
<string name="reading_reversed">(Umgekehrt)</string>
|
||||
|
||||
<string name="about_body">Deck in a Dash zeigt eine tägliche, persönliche Tarot-Lesung (Keltisches Kreuz) und Astrologie-Deutung (Geburtshoroskop + Transite).\n\nKarten- und Legetext basiert auf A. E. Waites The Pictorial Key to the Tarot (1911, gemeinfrei). Der Transit-Text basiert auf Sepharials Transits and Planetary Periods (1920, gemeinfrei).</string>
|
||||
</resources>
|
||||
@@ -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,40 @@
|
||||
<?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="config_cancel">Cancel</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_day_significance_heading">Day Significance</string>
|
||||
<string name="reading_guidance_heading">Guidance</string>
|
||||
<string name="reading_transits_heading">Significant Transits</string>
|
||||
<string name="reading_no_notable_transits">No notable transits today.</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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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%
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
2
|
||||
@@ -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`.
|
||||
- **`engine/src/`** is the core engine. Everything except `main.c`,
|
||||
`reading.c`'s `reading_print_text`/`reading_print_html` functions, and
|
||||
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions,
|
||||
specifically so it can be linked into the Pebble watchapp later without
|
||||
rework.
|
||||
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions, so it
|
||||
links straight into a Pebble watchapp without rework.
|
||||
- `rng.c` — deterministic string-seeded PRNG (FNV-1a + splitmix64).
|
||||
- `tarot.c` / `tarot_data.c` — the Celtic Cross draw and its content.
|
||||
- `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
|
||||
in, and restored from there if `dist/` is ever wiped — see
|
||||
`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
|
||||
|
||||
@@ -64,9 +60,10 @@ computations and combines their results:
|
||||
with position and upright/reversed orientation, fully determined by
|
||||
the seed string alone.
|
||||
|
||||
These three results are combined into one `DailyReading` struct, which is
|
||||
either rendered by `reading_print_text`/`reading_print_html` (used by the
|
||||
CLI) or, in the future, walked directly by watchapp UI code. See
|
||||
These three results are combined into one `DailyReading` struct — the
|
||||
real API surface. `reading_print_text`/`reading_print_html` (used by the
|
||||
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
|
||||
and output formats.
|
||||
|
||||
|
||||
@@ -55,12 +55,6 @@ digraph architecture {
|
||||
{ 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];
|
||||
|
||||
tarot -> rng;
|
||||
@@ -78,6 +72,4 @@ digraph architecture {
|
||||
images -> distimg [label="`make images`\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];
|
||||
|
||||
watch -> reading [label="will call\nreading_generate()\ndirectly", style=dashed, color=firebrick, fontcolor=firebrick];
|
||||
}
|
||||
|
||||
@@ -33,11 +33,9 @@ digraph dataflow {
|
||||
|
||||
print_text [label="reading_print_text()", 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_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;
|
||||
runsh -> main;
|
||||
@@ -59,9 +57,7 @@ digraph dataflow {
|
||||
|
||||
daily_reading -> print_text;
|
||||
daily_reading -> print_html;
|
||||
daily_reading -> watch_fn [style=dashed, color=firebrick];
|
||||
|
||||
print_text -> out_text;
|
||||
print_html -> out_html;
|
||||
watch_fn -> out_watch [style=dashed, color=firebrick];
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 236 KiB |
@@ -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`
|
||||
+ `user.properties`) and three output formats (`text`, `html`, `json`); the
|
||||
`DailyReading` struct is the programmatic form all three are rendered
|
||||
from, and the one the future watchapp will consume directly. `json` is
|
||||
also `interpreter-cli`'s input format — see "The interpreter" at the
|
||||
bottom of this file.
|
||||
from, and the one watch UI code consumes directly. `json` is also
|
||||
`interpreter-cli`'s input format — see "The interpreter" at the bottom
|
||||
of this file, which supports the same three output formats (and the
|
||||
same `--lang`) for its own report, independently of `deck-engine`.
|
||||
|
||||
## Input
|
||||
|
||||
@@ -16,6 +17,7 @@ bottom of this file.
|
||||
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
||||
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
||||
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
|
||||
[--gender male|female|unspecified]
|
||||
```
|
||||
|
||||
| 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. |
|
||||
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
||||
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
|
||||
| `--gender` | no | `male` \| `female` \| `unspecified` | Who the reading is for. Only affects the Celtic Cross position text's pronouns (e.g. "His House" vs. "Her House" vs. "Their House") - never the chart/draw itself. Defaults to `unspecified` (pronoun-neutral phrasing). |
|
||||
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<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). |
|
||||
|
||||
@@ -51,6 +54,7 @@ use:
|
||||
birth_utc_offset=2
|
||||
birth_lat=52.5200
|
||||
birth_lon=13.4050
|
||||
gender=unspecified
|
||||
lang=en
|
||||
```
|
||||
|
||||
@@ -58,8 +62,8 @@ use:
|
||||
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
|
||||
checks every birth field for an empty value or a leftover `YOUR_`
|
||||
placeholder and refuses to run with a clear error until the file is
|
||||
filled in; `lang` has no such check since it always has a usable
|
||||
default (`en`). Since `dist/` itself can be wiped (`make clean`, or
|
||||
filled in; `gender` and `lang` have no such check since they always
|
||||
have a usable default (`unspecified`/`en`). Since `dist/` itself can be wiped (`make clean`, or
|
||||
deleting the directory), the build also keeps a durable backup at
|
||||
`engine/scripts/user.properties.local` (gitignored) once the file
|
||||
looks filled in, and restores from it if `dist/user.properties` is
|
||||
@@ -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
|
||||
key-naming convention and how to add a new language.
|
||||
|
||||
Celtic Cross position text additionally varies with `--gender`: the
|
||||
plain key (e.g. `position.present.desc`) is the pronoun-neutral wording
|
||||
used for `unspecified`, and `.male`/`.female`-suffixed keys (e.g.
|
||||
`position.present.desc.male`) hold the gendered wording where it
|
||||
differs. A position whose wording doesn't vary by gender (e.g. "The
|
||||
Outcome") simply has no `.male`/`.female` keys - the neutral key's
|
||||
translation is used for every `--gender` value in that case.
|
||||
|
||||
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
|
||||
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
|
||||
build, like `dist/img/`). Adding a language is just dropping another
|
||||
@@ -90,8 +102,8 @@ build, like `dist/img/`). Adding a language is just dropping another
|
||||
### `DailyReading` (the real output — `reading.h`)
|
||||
|
||||
`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
|
||||
read directly once it exists.
|
||||
ways of rendering the struct it fills in. This is what watch UI code
|
||||
reads directly.
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
@@ -145,7 +157,7 @@ Transiting Sun Opposition natal Moon (orb 3.7°)
|
||||
|
||||
===== Celtic Cross =====
|
||||
The Present The High Priestess (Reversed)
|
||||
This covers him: the general influence affecting the matter.
|
||||
This covers them: the general influence affecting the matter.
|
||||
Passion, moral or physical ardour, conceit, surface knowledge.
|
||||
...
|
||||
```
|
||||
@@ -170,8 +182,8 @@ cards rendered as an image:
|
||||
|
||||
### `--format json`
|
||||
|
||||
A single JSON object mirroring `DailyReading` exactly — `natal`,
|
||||
`transits`, `spread` — using the **slug** accessors
|
||||
A single JSON object mirroring `DailyReading` exactly — `date`, `gender`,
|
||||
`natal`, `transits`, `spread` — using the **slug** accessors
|
||||
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
|
||||
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
|
||||
rather than the display-name ones, so the output is identical regardless
|
||||
@@ -181,6 +193,8 @@ read.
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-16",
|
||||
"gender": "unspecified",
|
||||
"natal": {
|
||||
"bodies": [
|
||||
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
|
||||
@@ -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
|
||||
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
||||
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
||||
@@ -227,16 +252,39 @@ read.
|
||||
`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s
|
||||
"Interpretation" section for the full architecture — that reads a
|
||||
`--format json` document and scores how significant today's transits
|
||||
are. It never links the engine; it only depends on the JSON shape
|
||||
above.
|
||||
are. It never links `astro.c`/`tarot.c`/`reading.c` (or the vendored
|
||||
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
|
||||
dist/deck-engine ... --format json | dist/interpreter-cli
|
||||
dist/deck-engine ... --format json | dist/interpreter-cli --format html --lang de
|
||||
# or:
|
||||
dist/deck-engine ... --format json > reading.json
|
||||
dist/interpreter-cli reading.json
|
||||
# or, for daily use (OS clock + user.properties, same inputs as run-engine.sh):
|
||||
dist/run-interpreter.sh
|
||||
dist/interpreter-cli --format json reading.json
|
||||
# or, for daily use (OS clock + user.properties, same inputs as run-engine.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
|
||||
@@ -244,30 +292,208 @@ dist/run-interpreter.sh
|
||||
means "no notable transits today"; a missing key is treated as invalid
|
||||
input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are
|
||||
read too, for the sign/house context printed alongside each event below
|
||||
(not for scoring) — an entry naming an unrecognized body, or a missing
|
||||
`house`, is silently skipped rather than failing the load (see
|
||||
`parse_bodies()` in `reading_io.c`); `spread` is always ignored.
|
||||
- **Output**: a plain-text report — the day's overall significance level
|
||||
(`Quiet`/`Notable`/`Significant`/`Major`, plus its rank out of the 4
|
||||
defined levels, e.g. `Notable (2/4)`), a "worth a deeper Celtic Cross
|
||||
look" line on `Major` days only, and 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:
|
||||
(not for scoring), and `"spread"."positions"` is read for the full
|
||||
Celtic Cross readout and the guidance paragraph below — an entry naming
|
||||
an unrecognized body/card/position, a missing `house`, or a missing
|
||||
`"spread"` key entirely is silently skipped/zeroed rather than failing
|
||||
the load (see `parse_bodies()`/`parse_spread()` in `reading_io.c`).
|
||||
- **Input**: same for all three output formats above - only how the
|
||||
parsed data gets rendered differs.
|
||||
|
||||
```
|
||||
Day significance: Significant (3/4)
|
||||
(a major transit today - worth a deeper Celtic Cross look)
|
||||
### `--format text`
|
||||
|
||||
Top 3 significant transits:
|
||||
1. Transiting Saturn in Aries (house 2) Square natal Sun in Taurus (house 3) (orb 0.5°, score 8.10)
|
||||
In matters of money, possessions, and personal values (house 2). Brings depression, stagnation, hindrances and obstacles, and some deprivation of the usual benefits.
|
||||
2. Transiting Pluto in Aquarius (house 12) Opposition natal Moon in Capricorn (house 11) (orb 0.2°, score 7.92)
|
||||
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.
|
||||
3. Transiting Jupiter in Leo (house 5) Trine natal Venus in Aries (house 2) (orb 2.1°, score 3.40)
|
||||
In matters of romance, creativity, and children (house 5). Brings increase and expansion - fullness of fortune and health, and a generally fortunate time.
|
||||
```
|
||||
A plain-text report, printed in a fixed order —
|
||||
1. the day this reading is for (`Reading for: 2026-07-16`), from the
|
||||
input JSON's top-level `date` field (see `--format json` above) —
|
||||
not necessarily the day the report is actually being read on, if
|
||||
`reading.json` was generated earlier and piped into
|
||||
`interpreter-cli` later;
|
||||
2. the day's overall significance level (`Quiet`/`Notable`/
|
||||
`Significant`/`Major`, plus its rank out of the 4 defined levels,
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Put together, one day's reading contains: your natal chart (for
|
||||
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
|
||||
main events driving that), and your 10-card Celtic Cross spread for the
|
||||
day. Right now these sit side by side — the astrology tells you *how
|
||||
eventful* today is and *where* to pay attention, and the tarot spread
|
||||
gives you the actual guidance to sit with. They don't yet get woven
|
||||
into a single narrative (e.g. a spread explained *in light of* today's
|
||||
significant transit) — that combined storytelling is the next piece to
|
||||
build, not something you get today.
|
||||
|
||||
*(This page, like that piece, is still a work in progress — expect it
|
||||
to grow as the reading itself does.)*
|
||||
main events driving that), your full 10-card Celtic Cross spread read
|
||||
out card by card with each card's meaning, and — every day, not just
|
||||
Major ones — a closing paragraph of guidance tying the day's biggest
|
||||
transit to your Attitude and Outcome cards specifically. Attitude and
|
||||
Outcome get that treatment in the closing guidance; the other eight
|
||||
positions are read in light of your natal chart and today's sky, the
|
||||
way the worked example below does by hand.
|
||||
|
||||
## A worked example
|
||||
|
||||
@@ -123,7 +159,7 @@ Sagittarius.
|
||||
are forming aspects back to the natal chart above — see below for which
|
||||
ones actually matter.
|
||||
|
||||
**How big is today.**
|
||||
**How big is today, and the significant transits.**
|
||||
|
||||
```
|
||||
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
|
||||
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.
|
||||
- *The Challenge* — The Devil: force, ravage, an obstacle that isn't
|
||||
easily reasoned with.
|
||||
- *The Near Future* — The World, reversed: stagnation, things staying
|
||||
fixed in place rather than resolving.
|
||||
```
|
||||
Celtic Cross:
|
||||
1. The Present: The Emperor (Reversed)
|
||||
This covers him: the general influence affecting the matter.
|
||||
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
|
||||
2. The Challenge: The Devil
|
||||
This crosses him: the nature of the obstacle in the matter.
|
||||
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 —
|
||||
both point toward something that wants to shift (Pluto's "hidden
|
||||
transformation," The Devil's "force") running into something that
|
||||
feels stuck (a reversed World, an afflicted Moon) — but that's the
|
||||
reader's own synthesis for now, not something the app does for you (see
|
||||
above).
|
||||
feels stuck (a reversed World, an afflicted Moon). The closing guidance
|
||||
picks up part of that thread automatically — an upright Attitude card
|
||||
(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.
|
||||
```
|
||||
|
||||
@@ -22,6 +22,12 @@ ui.transiting=Transit
|
||||
ui.natal=natal
|
||||
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) ---
|
||||
body.sun=Sonne
|
||||
body.moon=Mond
|
||||
@@ -65,25 +71,53 @@ aspect.square=Quadrat
|
||||
aspect.trine=Trigon
|
||||
aspect.opposition=Opposition
|
||||
|
||||
# --- Positionen des Keltischen Kreuzes (tarot_data.c) ---
|
||||
# --- Positionen des Keltischen Kreuzes (tarot_data.c). Waite spricht die
|
||||
# fragende Person durchgehend mit "ihn/ihm/sein" an; der unsuffigierte
|
||||
# Schlüssel ist eine geschlechtsneutrale Umschreibung (über "die fragende
|
||||
# Person"/"die eigene", ohne Pronomen), die bei nicht angegebenem
|
||||
# Geschlecht verwendet wird, ".male" ist Waites eigener Wortlaut
|
||||
# unverändert, ".female" eine für dieses Projekt geschriebene Umschreibung -
|
||||
# siehe tarot_data.c's Kommentar zu Gender. ---
|
||||
position.present.name=Die Gegenwart
|
||||
position.present.desc=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.present.desc=Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.present.desc.male=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.present.desc.female=Dies bedeckt sie: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.challenge.name=Die Herausforderung
|
||||
position.challenge.desc=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
||||
position.challenge.desc=Dies kreuzt die fragende Person: die Art des Hindernisses in der Angelegenheit.
|
||||
position.challenge.desc.male=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
||||
position.challenge.desc.female=Dies kreuzt sie: die Art des Hindernisses in der Angelegenheit.
|
||||
position.crown.name=Die Krone
|
||||
position.crown.desc=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.crown.desc=Dies krönt die fragende Person: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.crown.desc.male=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.crown.desc.female=Dies krönt sie: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.foundation.name=Das Fundament
|
||||
position.foundation.desc=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.foundation.desc=Dies liegt unter der fragenden Person: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.foundation.desc.male=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.foundation.desc.female=Dies liegt unter ihr: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.recent_past.name=Die jüngste Vergangenheit
|
||||
position.recent_past.desc=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
||||
position.recent_past.desc=Dies liegt hinter der fragenden Person: der Einfluss, der gerade vergeht.
|
||||
position.recent_past.desc.male=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
||||
position.recent_past.desc.female=Dies liegt hinter ihr: der Einfluss, der gerade vergeht.
|
||||
position.near_future.name=Die nahe Zukunft
|
||||
position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
||||
position.attitude.name=Er selbst
|
||||
position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.environment.name=Sein Haus
|
||||
position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen.
|
||||
position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird.
|
||||
position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
||||
position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird.
|
||||
position.attitude.name=Die fragende Person selbst
|
||||
position.attitude.name.male=Er selbst
|
||||
position.attitude.name.female=Sie selbst
|
||||
position.attitude.desc=Die eigene Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.attitude.desc.male=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.attitude.desc.female=Ihre Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.environment.name=Das eigene Haus
|
||||
position.environment.name.male=Sein Haus
|
||||
position.environment.name.female=Ihr Haus
|
||||
position.environment.desc=Das eigene Umfeld und die darin wirkenden Tendenzen.
|
||||
position.environment.desc.male=Sein Umfeld und die darin wirkenden Tendenzen.
|
||||
position.environment.desc.female=Ihr Umfeld und die darin wirkenden Tendenzen.
|
||||
position.hopes_and_fears.name=Hoffnungen und Ängste
|
||||
position.hopes_and_fears.desc=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.hopes_and_fears.desc=Die eigenen Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.hopes_and_fears.desc.male=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.hopes_and_fears.desc.female=Ihre Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.outcome.name=Das Ergebnis
|
||||
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
|
||||
|
||||
@@ -154,3 +188,108 @@ card.judgement.reversed=Schwäche, Kleinmut, Einfalt; auch Überlegung, Entschei
|
||||
card.world.name=Die Welt
|
||||
card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Flucht, Ortswechsel.
|
||||
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
|
||||
|
||||
@@ -29,6 +29,12 @@ ui.transiting=Transiting
|
||||
ui.natal=natal
|
||||
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) ---
|
||||
body.sun=Sun
|
||||
body.moon=Moon
|
||||
@@ -73,25 +79,52 @@ aspect.trine=Trine
|
||||
aspect.opposition=Opposition
|
||||
|
||||
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
|
||||
# own wording from "An Ancient Celtic Method of Divination" ---
|
||||
# own wording from "An Ancient Celtic Method of Divination". Waite
|
||||
# addresses the querent as "him" throughout; the unsuffixed key is a
|
||||
# pronoun-neutral paraphrase (singular "they") used when no gender is
|
||||
# configured, the ".male" key is Waite's own wording verbatim, and the
|
||||
# ".female" key is an original paraphrase - see tarot_data.c's own
|
||||
# comment on Gender. ---
|
||||
position.present.name=The Present
|
||||
position.present.desc=This covers him: the general influence affecting the matter.
|
||||
position.present.desc=This covers them: the general influence affecting the matter.
|
||||
position.present.desc.male=This covers him: the general influence affecting the matter.
|
||||
position.present.desc.female=This covers her: the general influence affecting the matter.
|
||||
position.challenge.name=The Challenge
|
||||
position.challenge.desc=This crosses him: the nature of the obstacle in the matter.
|
||||
position.challenge.desc=This crosses them: the nature of the obstacle in the matter.
|
||||
position.challenge.desc.male=This crosses him: the nature of the obstacle in the matter.
|
||||
position.challenge.desc.female=This crosses her: the nature of the obstacle in the matter.
|
||||
position.crown.name=The Crown
|
||||
position.crown.desc=This crowns him: the aim or ideal, the best that can be achieved.
|
||||
position.crown.desc=This crowns them: the aim or ideal, the best that can be achieved.
|
||||
position.crown.desc.male=This crowns him: the aim or ideal, the best that can be achieved.
|
||||
position.crown.desc.female=This crowns her: the aim or ideal, the best that can be achieved.
|
||||
position.foundation.name=The Foundation
|
||||
position.foundation.desc=This is beneath him: the basis of the matter, already actual.
|
||||
position.foundation.desc=This is beneath them: the basis of the matter, already actual.
|
||||
position.foundation.desc.male=This is beneath him: the basis of the matter, already actual.
|
||||
position.foundation.desc.female=This is beneath her: the basis of the matter, already actual.
|
||||
position.recent_past.name=The Recent Past
|
||||
position.recent_past.desc=This is behind him: the influence that is just passing away.
|
||||
position.recent_past.desc=This is behind them: the influence that is just passing away.
|
||||
position.recent_past.desc.male=This is behind him: the influence that is just passing away.
|
||||
position.recent_past.desc.female=This is behind her: the influence that is just passing away.
|
||||
position.near_future.name=The Near Future
|
||||
position.near_future.desc=This is before him: the influence now coming into action.
|
||||
position.attitude.name=Himself
|
||||
position.attitude.desc=His position or attitude in the circumstances.
|
||||
position.environment.name=His House
|
||||
position.environment.desc=His environment and the tendencies at work therein.
|
||||
position.near_future.desc=This is before them: the influence now coming into action.
|
||||
position.near_future.desc.male=This is before him: the influence now coming into action.
|
||||
position.near_future.desc.female=This is before her: the influence now coming into action.
|
||||
position.attitude.name=Themself
|
||||
position.attitude.name.male=Himself
|
||||
position.attitude.name.female=Herself
|
||||
position.attitude.desc=Their position or attitude in the circumstances.
|
||||
position.attitude.desc.male=His position or attitude in the circumstances.
|
||||
position.attitude.desc.female=Her position or attitude in the circumstances.
|
||||
position.environment.name=Their House
|
||||
position.environment.name.male=His House
|
||||
position.environment.name.female=Her House
|
||||
position.environment.desc=Their environment and the tendencies at work therein.
|
||||
position.environment.desc.male=His environment and the tendencies at work therein.
|
||||
position.environment.desc.female=Her environment and the tendencies at work therein.
|
||||
position.hopes_and_fears.name=Hopes and Fears
|
||||
position.hopes_and_fears.desc=His hopes or fears in the matter.
|
||||
position.hopes_and_fears.desc=Their hopes or fears in the matter.
|
||||
position.hopes_and_fears.desc.male=His hopes or fears in the matter.
|
||||
position.hopes_and_fears.desc.female=Her hopes or fears in the matter.
|
||||
position.outcome.name=The Outcome
|
||||
position.outcome.desc=What will come: the final result of the matter.
|
||||
|
||||
@@ -163,3 +196,105 @@ card.judgement.reversed=Weakness, pusillanimity, simplicity; also deliberation,
|
||||
card.world.name=The World
|
||||
card.world.upright=Assured success, recompense, voyage, route, emigration, flight, change of place.
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@ birth_time=""
|
||||
birth_utc_offset=""
|
||||
birth_lat=""
|
||||
birth_lon=""
|
||||
gender="unspecified"
|
||||
lang="en"
|
||||
|
||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
@@ -43,6 +44,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
birth_utc_offset) birth_utc_offset="$value" ;;
|
||||
birth_lat) birth_lat="$value" ;;
|
||||
birth_lon) birth_lon="$value" ;;
|
||||
gender) [ -n "$value" ] && gender="$value" ;;
|
||||
lang) [ -n "$value" ] && lang="$value" ;;
|
||||
esac
|
||||
done <"$PROPERTIES_FILE"
|
||||
@@ -77,5 +79,6 @@ exec "$BINARY" \
|
||||
--birth-lon "$birth_lon" \
|
||||
--date "$transit_date" \
|
||||
--format "$format" \
|
||||
--gender "$gender" \
|
||||
--lang "$lang" \
|
||||
--i18n-dir "$SCRIPT_DIR/i18n"
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
# "today": same OS-clock seed/transit-moment and user.properties birth
|
||||
# data as run-engine.sh, but reports how significant today's transits are
|
||||
# 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
|
||||
# dist/interpreter-cli (built by interpreter/'s own Makefile) to already
|
||||
@@ -35,6 +41,8 @@ birth_time=""
|
||||
birth_utc_offset=""
|
||||
birth_lat=""
|
||||
birth_lon=""
|
||||
gender="unspecified"
|
||||
lang="en"
|
||||
|
||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
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_lat) birth_lat="$value" ;;
|
||||
birth_lon) birth_lon="$value" ;;
|
||||
gender) [ -n "$value" ] && gender="$value" ;;
|
||||
lang) [ -n "$value" ] && lang="$value" ;;
|
||||
esac
|
||||
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
|
||||
# new spread each day), current UTC time drives today's transits. The
|
||||
# tarot seed doesn't matter for --format json (only "transits" is read
|
||||
# downstream) but it's kept identical to run-engine.sh's for consistency.
|
||||
# tarot seed still matters for --format json now that "spread" is read
|
||||
# downstream too (guidance.c's Attitude/Outcome framing) - kept identical
|
||||
# to run-engine.sh's for consistency either way.
|
||||
seed="$(date +%Y-%m-%d)"
|
||||
transit_date="$(date -u +%Y-%m-%dT%H:%M)"
|
||||
format="${1:-text}"
|
||||
lang="${2:-$lang}"
|
||||
|
||||
"$ENGINE_BINARY" \
|
||||
--seed "$seed" \
|
||||
@@ -81,4 +94,5 @@ transit_date="$(date -u +%Y-%m-%dT%H:%M)"
|
||||
--birth-lon "$birth_lon" \
|
||||
--date "$transit_date" \
|
||||
--format json \
|
||||
| exec "$INTERPRETER_BINARY"
|
||||
--gender "$gender" \
|
||||
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
# (e.g. 2 for Central European Summer Time)
|
||||
# birth_lat Birth location latitude, decimal degrees, north positive
|
||||
# birth_lon Birth location longitude, decimal degrees, east positive
|
||||
# gender Who the reading is for - male, female, or unspecified.
|
||||
# Only affects the Celtic Cross position text's
|
||||
# pronouns (e.g. "His House" vs. "Her House" vs.
|
||||
# "Their House"), never the chart/draw itself.
|
||||
# Optional, defaults to "unspecified"; unrecognized
|
||||
# values fall back to "unspecified" too.
|
||||
# lang Reading language code - matches a file named
|
||||
# <code>.lang in dist/i18n/ (see engine/i18n/ for the
|
||||
# source files, currently "en" and "de"). Optional,
|
||||
@@ -22,4 +28,5 @@ birth_time=YOUR_BIRTH_TIME_HERE
|
||||
birth_utc_offset=YOUR_UTC_OFFSET_HERE
|
||||
birth_lat=YOUR_LATITUDE_HERE
|
||||
birth_lon=YOUR_LONGITUDE_HERE
|
||||
gender=unspecified
|
||||
lang=en
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
#define _POSIX_C_SOURCE 200809L /* for gmtime_r */
|
||||
|
||||
#include "astro.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 <math.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
|
||||
/* DEG2RAD / RAD2DEG come from astronomy.h. */
|
||||
typedef astro_time_t AstroTime;
|
||||
|
||||
static const astro_body_t k_astro_body[NUM_BODIES] = {
|
||||
[PLANET_SUN] = BODY_SUN,
|
||||
@@ -22,6 +30,20 @@ static const astro_body_t k_astro_body[NUM_BODIES] = {
|
||||
[PLANET_NEPTUNE] = BODY_NEPTUNE,
|
||||
[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) {
|
||||
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
|
||||
* 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) {
|
||||
double t = t_centuries;
|
||||
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/
|
||||
* latitude identity (Duffett-Smith & Zwart, "Practical Astronomy with
|
||||
* your Calculator or Spreadsheet"). Astronomy Engine has no built-in
|
||||
* Ascendant function. */
|
||||
static double compute_ascendant(astro_time_t *time, double latitude_deg,
|
||||
* your Calculator or Spreadsheet"). Neither ephemeris implementation has
|
||||
* a built-in Ascendant function. */
|
||||
static double compute_ascendant(AstroTime *time, double latitude_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 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 eps = eps_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 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);
|
||||
#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,
|
||||
birth->hour, birth->minute, 0.0);
|
||||
return Astronomy_AddDays(local, -birth->utc_offset_hours / 24.0);
|
||||
#endif
|
||||
}
|
||||
|
||||
static astro_time_t time_from_unix(time_t utc_moment) {
|
||||
struct tm tm_utc;
|
||||
gmtime_r(&utc_moment, &tm_utc);
|
||||
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);
|
||||
/* Plain gmtime() (not the POSIX-only gmtime_r()) - deliberately: this
|
||||
* project is single-threaded everywhere it runs, including on the watch,
|
||||
* where only the non-reentrant classic C library functions are provided
|
||||
* (see astro.h's own include-guard comment). Used immediately, so its
|
||||
* 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
|
||||
* outright rejects BODY_SUN) - wrong for astrology, which needs the
|
||||
* 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);
|
||||
longitude_to_position(eclip.elon, &out[b]);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
astro_time_t time = time_from_birth(birth);
|
||||
AstroTime time = time_from_birth(birth);
|
||||
|
||||
compute_body_positions(time, out->bodies);
|
||||
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,
|
||||
DailyTransits *out) {
|
||||
astro_time_t time = time_from_unix(utc_moment);
|
||||
AstroTime time = time_from_unix(utc_moment);
|
||||
|
||||
compute_body_positions(time, out->bodies);
|
||||
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);
|
||||
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;
|
||||
for (int t = 0; t < NUM_BODIES; t++) {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
#ifndef 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>
|
||||
#endif
|
||||
|
||||
#define NUM_BODIES 10
|
||||
#define MAX_ASPECTS 100
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
#include "i18n.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Fixed-size, no heap allocation - deliberately (see i18n.h): keeps this
|
||||
* usable in a small embedded environment later, not just on desktop.
|
||||
* ~140 keys are shipped today (see engine/i18n/en.lang); this leaves
|
||||
* plenty of headroom for more languages/keys without growing the format. */
|
||||
/* i18n_load_table()'s catalog: just three pointers, always compiled -
|
||||
* negligible RAM regardless of platform. Deliberately zero-copy: the
|
||||
* watch's compiled-in translation tables already live in flash as
|
||||
* `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_KEY 48
|
||||
#define I18N_MAX_VALUE 400
|
||||
@@ -67,10 +88,16 @@ bool i18n_load(const char *path) {
|
||||
fclose(f);
|
||||
return true;
|
||||
}
|
||||
#endif /* PBL_SDK_3 */
|
||||
|
||||
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++) {
|
||||
if (strcmp(g_entries[i].key, key) == 0) return g_entries[i].value;
|
||||
}
|
||||
#endif
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,27 @@
|
||||
* 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
|
||||
* a desktop-only entry point, not something the Pebble watchapp will
|
||||
* call as-is. i18n_get() below is a pure table lookup, so it's fine to
|
||||
* port; the watchapp will just need its own (non-file-based) way to
|
||||
* populate the catalog, e.g. from a compiled-in resource. */
|
||||
* a desktop-only entry point: Pebble's SDK doesn't provide fopen/fgets
|
||||
* at all (a compile-time error, not just a runtime failure), so this
|
||||
* function isn't even compiled when building for the watch (guarded
|
||||
* #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);
|
||||
#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
|
||||
* `fallback` if no catalog is loaded or `key` isn't in it. Every caller
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
|
||||
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
|
||||
" [--gender male|female|unspecified]\n"
|
||||
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
||||
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
||||
" --birth-date/time Birth date/time in local clock time.\n"
|
||||
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
|
||||
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
|
||||
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
||||
" --gender Who the reading is for - only affects the Celtic Cross\n"
|
||||
" position text's pronouns, never the chart/draw itself.\n"
|
||||
" Defaults to unspecified (pronoun-neutral phrasing).\n"
|
||||
" --format Output format, defaults to text. html links card art via\n"
|
||||
" img/<file>, i.e. it expects to be saved next to the img/\n"
|
||||
" directory that `make images` copies into dist/. json uses\n"
|
||||
@@ -63,6 +67,7 @@ int main(int argc, char **argv) {
|
||||
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
|
||||
const char *date_str = NULL, *format_str = "text";
|
||||
const char *lang = "en", *i18n_dir_arg = NULL;
|
||||
const char *gender_str = "unspecified";
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char *arg = argv[i];
|
||||
@@ -75,6 +80,7 @@ int main(int argc, char **argv) {
|
||||
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
|
||||
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
|
||||
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
|
||||
else if (strcmp(arg, "--gender") == 0) rc = require_arg(argc, argv, &i, arg, &gender_str);
|
||||
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
|
||||
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
|
||||
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
||||
@@ -136,6 +142,15 @@ int main(int argc, char **argv) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
Gender gender;
|
||||
if (strcmp(gender_str, "unspecified") == 0) gender = GENDER_UNSPECIFIED;
|
||||
else if (strcmp(gender_str, "male") == 0) gender = GENDER_MALE;
|
||||
else if (strcmp(gender_str, "female") == 0) gender = GENDER_FEMALE;
|
||||
else {
|
||||
fprintf(stderr, "Invalid --gender, expected male, female, or unspecified\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
char i18n_path[512];
|
||||
if (i18n_dir_arg) {
|
||||
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
|
||||
@@ -148,7 +163,7 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
|
||||
DailyReading reading;
|
||||
reading_generate(seed, &birth, utc_moment, &reading);
|
||||
reading_generate(seed, &birth, gender, utc_moment, &reading);
|
||||
|
||||
if (format == FORMAT_HTML) {
|
||||
reading_print_html(&reading, stdout);
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
|
||||
#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
|
||||
* because they're specific to these two renderers, not part of the
|
||||
* portable astro/tarot vocabulary. */
|
||||
@@ -10,13 +24,6 @@ static const char *ui(const char *key, const char *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) {
|
||||
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);
|
||||
@@ -60,10 +67,10 @@ void reading_print_text(const DailyReading *r, FILE *out) {
|
||||
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
const TarotDraw *draw = &r->spread.positions[i];
|
||||
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i),
|
||||
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i, r->gender),
|
||||
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
||||
draw->reversed ? ui("ui.reversed", "(Reversed)") : "");
|
||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
|
||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, r->gender));
|
||||
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||
}
|
||||
}
|
||||
@@ -132,12 +139,12 @@ void reading_print_html(const DailyReading *r, FILE *out) {
|
||||
" <div class=\"desc\">%s</div>\n"
|
||||
" <div class=\"meaning\">%s</div>\n"
|
||||
"</div>\n",
|
||||
tarot_position_name((CelticCrossPosition)i),
|
||||
tarot_position_name((CelticCrossPosition)i, r->gender),
|
||||
draw->reversed ? "reversed" : "",
|
||||
tarot_card_image_file(draw->card), tarot_card_name(draw->card),
|
||||
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
||||
draw->reversed ? ui("ui.reversed", "(Reversed)") : "",
|
||||
tarot_position_description((CelticCrossPosition)i),
|
||||
tarot_position_description((CelticCrossPosition)i, r->gender),
|
||||
tarot_card_meaning(draw->card, draw->reversed));
|
||||
}
|
||||
fprintf(out, "</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) {
|
||||
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");
|
||||
for (int b = 0; b < NUM_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");
|
||||
}
|
||||
|
||||
#endif /* !PBL_SDK_3 */
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
#ifndef 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 <time.h>
|
||||
#endif
|
||||
|
||||
#include "astro.h"
|
||||
#include "tarot.h"
|
||||
|
||||
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;
|
||||
DailyTransits transits;
|
||||
CelticCrossSpread spread;
|
||||
@@ -15,11 +32,14 @@ typedef struct {
|
||||
|
||||
/* The real API: this is the only function the watchapp needs to call.
|
||||
* It walks the returned struct directly to lay out its own screens. */
|
||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
||||
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||
time_t utc_moment, DailyReading *out);
|
||||
|
||||
/* Desktop-only output formats for inspecting a reading before any watch
|
||||
* 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_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
|
||||
* is the interchange format interpreter/ reads - see its json.c. */
|
||||
void reading_print_json(const DailyReading *r, FILE *out);
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
@@ -52,6 +52,17 @@ typedef struct {
|
||||
bool reversed;
|
||||
} TarotDraw;
|
||||
|
||||
/* Who the reading is for - affects only the pronouns in the Celtic
|
||||
* Cross position text below (Waite's original wording addresses the
|
||||
* querent as "him"), never anything about the tarot draw or astrology
|
||||
* math itself. GENDER_UNSPECIFIED is the default and renders
|
||||
* pronoun-neutral phrasing (singular "they" in English). */
|
||||
typedef enum {
|
||||
GENDER_UNSPECIFIED = 0,
|
||||
GENDER_MALE,
|
||||
GENDER_FEMALE
|
||||
} Gender;
|
||||
|
||||
typedef struct {
|
||||
/* Indexed by CelticCrossPosition. */
|
||||
TarotDraw positions[TAROT_SPREAD_SIZE];
|
||||
@@ -67,8 +78,8 @@ const char *tarot_card_name(TarotCard card);
|
||||
const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */
|
||||
const char *tarot_card_meaning(TarotCard card, bool reversed);
|
||||
|
||||
const char *tarot_position_name(CelticCrossPosition position);
|
||||
const char *tarot_position_description(CelticCrossPosition position);
|
||||
const char *tarot_position_name(CelticCrossPosition position, Gender gender);
|
||||
const char *tarot_position_description(CelticCrossPosition position, Gender gender);
|
||||
|
||||
/* Stable, language-independent identifiers (e.g. "high_priestess",
|
||||
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
|
||||
@@ -76,5 +87,6 @@ const char *tarot_position_description(CelticCrossPosition position);
|
||||
* (reading_print_json) that must stay identical regardless of --lang. */
|
||||
const char *tarot_card_slug(TarotCard card);
|
||||
const char *tarot_position_slug(CelticCrossPosition position);
|
||||
const char *tarot_gender_slug(Gender gender); /* "unspecified"/"male"/"female" */
|
||||
|
||||
#endif
|
||||
|
||||
@@ -130,52 +130,97 @@ static const TarotCardInfo k_cards[TAROT_DECK_SIZE] = {
|
||||
|
||||
/* Position names and one-line descriptions follow Waite's own account
|
||||
* of "An Ancient Celtic Method of Divination" (Part III §7), in his
|
||||
* original drawing order. */
|
||||
* original drawing order. Waite addresses the querent as "him"
|
||||
* throughout; that wording is kept verbatim as the GENDER_MALE variant
|
||||
* below, and GENDER_FEMALE/GENDER_UNSPECIFIED are original paraphrases
|
||||
* written for this project (not Waite's own text) so the reading can
|
||||
* address the person it's actually for. Each array is indexed by
|
||||
* Gender (tarot.h): [GENDER_UNSPECIFIED], [GENDER_MALE], [GENDER_FEMALE]. */
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *description;
|
||||
const char *name[3];
|
||||
const char *description[3];
|
||||
} PositionInfo;
|
||||
|
||||
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
|
||||
[POSITION_PRESENT] = {
|
||||
"The Present",
|
||||
"This covers him: the general influence affecting the matter."
|
||||
{ "The Present", "The Present", "The Present" },
|
||||
{
|
||||
"This covers them: the general influence affecting the matter.",
|
||||
"This covers him: the general influence affecting the matter.",
|
||||
"This covers her: the general influence affecting the matter.",
|
||||
}
|
||||
},
|
||||
[POSITION_CHALLENGE] = {
|
||||
"The Challenge",
|
||||
"This crosses him: the nature of the obstacle in the matter."
|
||||
{ "The Challenge", "The Challenge", "The Challenge" },
|
||||
{
|
||||
"This crosses them: the nature of the obstacle in the matter.",
|
||||
"This crosses him: the nature of the obstacle in the matter.",
|
||||
"This crosses her: the nature of the obstacle in the matter.",
|
||||
}
|
||||
},
|
||||
[POSITION_CROWN] = {
|
||||
"The Crown",
|
||||
"This crowns him: the aim or ideal, the best that can be achieved."
|
||||
{ "The Crown", "The Crown", "The Crown" },
|
||||
{
|
||||
"This crowns them: the aim or ideal, the best that can be achieved.",
|
||||
"This crowns him: the aim or ideal, the best that can be achieved.",
|
||||
"This crowns her: the aim or ideal, the best that can be achieved.",
|
||||
}
|
||||
},
|
||||
[POSITION_FOUNDATION] = {
|
||||
"The Foundation",
|
||||
"This is beneath him: the basis of the matter, already actual."
|
||||
{ "The Foundation", "The Foundation", "The Foundation" },
|
||||
{
|
||||
"This is beneath them: the basis of the matter, already actual.",
|
||||
"This is beneath him: the basis of the matter, already actual.",
|
||||
"This is beneath her: the basis of the matter, already actual.",
|
||||
}
|
||||
},
|
||||
[POSITION_RECENT_PAST] = {
|
||||
"The Recent Past",
|
||||
"This is behind him: the influence that is just passing away."
|
||||
{ "The Recent Past", "The Recent Past", "The Recent Past" },
|
||||
{
|
||||
"This is behind them: the influence that is just passing away.",
|
||||
"This is behind him: the influence that is just passing away.",
|
||||
"This is behind her: the influence that is just passing away.",
|
||||
}
|
||||
},
|
||||
[POSITION_NEAR_FUTURE] = {
|
||||
"The Near Future",
|
||||
"This is before him: the influence now coming into action."
|
||||
{ "The Near Future", "The Near Future", "The Near Future" },
|
||||
{
|
||||
"This is before them: the influence now coming into action.",
|
||||
"This is before him: the influence now coming into action.",
|
||||
"This is before her: the influence now coming into action.",
|
||||
}
|
||||
},
|
||||
[POSITION_ATTITUDE] = {
|
||||
"Himself",
|
||||
"His position or attitude in the circumstances."
|
||||
{ "Themself", "Himself", "Herself" },
|
||||
{
|
||||
"Their position or attitude in the circumstances.",
|
||||
"His position or attitude in the circumstances.",
|
||||
"Her position or attitude in the circumstances.",
|
||||
}
|
||||
},
|
||||
[POSITION_ENVIRONMENT] = {
|
||||
"His House",
|
||||
"His environment and the tendencies at work therein."
|
||||
{ "Their House", "His House", "Her House" },
|
||||
{
|
||||
"Their environment and the tendencies at work therein.",
|
||||
"His environment and the tendencies at work therein.",
|
||||
"Her environment and the tendencies at work therein.",
|
||||
}
|
||||
},
|
||||
[POSITION_HOPES_AND_FEARS] = {
|
||||
"Hopes and Fears",
|
||||
"His hopes or fears in the matter."
|
||||
{ "Hopes and Fears", "Hopes and Fears", "Hopes and Fears" },
|
||||
{
|
||||
"Their hopes or fears in the matter.",
|
||||
"His hopes or fears in the matter.",
|
||||
"Her hopes or fears in the matter.",
|
||||
}
|
||||
},
|
||||
[POSITION_OUTCOME] = {
|
||||
"The Outcome",
|
||||
"What will come: the final result of the matter."
|
||||
{ "The Outcome", "The Outcome", "The Outcome" },
|
||||
{
|
||||
"What will come: the final result of the matter.",
|
||||
"What will come: the final result of the matter.",
|
||||
"What will come: the final result of the matter.",
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -229,13 +274,44 @@ static const char *card_field(const char *slug, const char *field, const char *f
|
||||
return i18n_get(key, fallback);
|
||||
}
|
||||
|
||||
static const char *position_field(const char *slug, const char *field, const char *fallback) {
|
||||
char key[64];
|
||||
strcpy(key, "position.");
|
||||
strcat(key, slug);
|
||||
strcat(key, ".");
|
||||
strcat(key, field);
|
||||
return i18n_get(key, fallback);
|
||||
/* Suffix appended to the i18n key for the male/female variants - the
|
||||
* unspecified/neutral variant uses the plain, unsuffixed key so it
|
||||
* matches the field name callers already know (e.g. "position.
|
||||
* attitude.name"), same convention as card_field()'s upright/reversed
|
||||
* field names above. */
|
||||
static const char *const k_gender_suffix[3] = {
|
||||
[GENDER_UNSPECIFIED] = "",
|
||||
[GENDER_MALE] = ".male",
|
||||
[GENDER_FEMALE] = ".female",
|
||||
};
|
||||
|
||||
static const char *const k_gender_slug[3] = {
|
||||
[GENDER_UNSPECIFIED] = "unspecified",
|
||||
[GENDER_MALE] = "male",
|
||||
[GENDER_FEMALE] = "female",
|
||||
};
|
||||
|
||||
static const char *position_field(const char *slug, const char *field, Gender gender,
|
||||
const char *fallback) {
|
||||
char neutral_key[80];
|
||||
strcpy(neutral_key, "position.");
|
||||
strcat(neutral_key, slug);
|
||||
strcat(neutral_key, ".");
|
||||
strcat(neutral_key, field);
|
||||
if (gender == GENDER_UNSPECIFIED) return i18n_get(neutral_key, fallback);
|
||||
|
||||
/* Not every position's text actually varies by gender (e.g. "The
|
||||
* Outcome" has no pronoun) - the .lang files only define a ".male"/
|
||||
* ".female" key where the wording differs. So look up the neutral key
|
||||
* first and use *that* (translated, if a catalog is loaded) as the
|
||||
* fallback for the gendered key, rather than jumping straight past a
|
||||
* loaded translation to the hardcoded English `fallback` whenever the
|
||||
* gendered key happens to be absent. */
|
||||
const char *neutral_text = i18n_get(neutral_key, fallback);
|
||||
char key[88];
|
||||
strcpy(key, neutral_key);
|
||||
strcat(key, k_gender_suffix[gender]);
|
||||
return i18n_get(key, neutral_text);
|
||||
}
|
||||
|
||||
const char *tarot_card_name(TarotCard card) {
|
||||
@@ -252,13 +328,15 @@ const char *tarot_card_meaning(TarotCard card, bool reversed) {
|
||||
reversed ? k_cards[card].reversed : k_cards[card].upright);
|
||||
}
|
||||
|
||||
const char *tarot_position_name(CelticCrossPosition position) {
|
||||
return position_field(k_position_slug[position], "name", k_positions[position].name);
|
||||
const char *tarot_position_name(CelticCrossPosition position, Gender gender) {
|
||||
return position_field(k_position_slug[position], "name", gender, k_positions[position].name[gender]);
|
||||
}
|
||||
|
||||
const char *tarot_position_description(CelticCrossPosition position) {
|
||||
return position_field(k_position_slug[position], "desc", k_positions[position].description);
|
||||
const char *tarot_position_description(CelticCrossPosition position, Gender gender) {
|
||||
return position_field(k_position_slug[position], "desc", gender,
|
||||
k_positions[position].description[gender]);
|
||||
}
|
||||
|
||||
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
|
||||
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
|
||||
const char *tarot_gender_slug(Gender gender) { return k_gender_slug[gender]; }
|
||||
|
||||
@@ -72,8 +72,8 @@ static void test_reading_generate_smoke(void) {
|
||||
};
|
||||
|
||||
DailyReading r1, r2;
|
||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r1);
|
||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r2);
|
||||
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r1);
|
||||
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r2);
|
||||
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
|
||||
@@ -103,7 +103,7 @@ static void test_reading_print_json_shape(void) {
|
||||
};
|
||||
|
||||
DailyReading r;
|
||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r);
|
||||
reading_generate("smoke-test-seed", &birth, GENDER_FEMALE, 1751500000, &r);
|
||||
|
||||
FILE *tmp = tmpfile();
|
||||
assert(tmp);
|
||||
@@ -120,6 +120,8 @@ static void test_reading_print_json_shape(void) {
|
||||
* a stray astro_*_name()/tarot_*_name() call leaking translated text
|
||||
* into what must stay language-independent regardless of --lang). */
|
||||
assert(buf[0] == '{');
|
||||
assert(strstr(buf, "\"date\": \"2025-07-02\"") != NULL); /* UTC date of the fixed 1751500000 seed */
|
||||
assert(strstr(buf, "\"gender\": \"female\"") != NULL);
|
||||
assert(strstr(buf, "\"natal\"") != NULL);
|
||||
assert(strstr(buf, "\"transits\"") != NULL);
|
||||
assert(strstr(buf, "\"spread\"") != NULL);
|
||||
@@ -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_aspect_name(ASPECT_TRINE), "Trigon") == 0);
|
||||
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_OUTCOME), "Das Ergebnis") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_OUTCOME, GENDER_UNSPECIFIED), "Das Ergebnis") == 0);
|
||||
|
||||
/* Loading a file that doesn't exist fails and leaves the previously
|
||||
* loaded catalog in place, rather than silently clearing it. */
|
||||
@@ -169,12 +171,45 @@ static void test_i18n_fallback_and_translation(void) {
|
||||
printf("PASS test_i18n_fallback_and_translation\n");
|
||||
}
|
||||
|
||||
static void test_gendered_position_text(void) {
|
||||
/* English catalog (loaded by the previous test): "Attitude" is the one
|
||||
* position whose name itself carries a pronoun. */
|
||||
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED), "Themself") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Himself") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Herself") == 0);
|
||||
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_UNSPECIFIED), "them") != NULL);
|
||||
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_MALE), "him") != NULL);
|
||||
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_FEMALE), "her") != NULL);
|
||||
|
||||
/* The Outcome position has no pronoun in Waite's own text - identical
|
||||
* across all three genders. */
|
||||
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
|
||||
tarot_position_description(POSITION_OUTCOME, GENDER_FEMALE)) == 0);
|
||||
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
|
||||
tarot_position_description(POSITION_OUTCOME, GENDER_UNSPECIFIED)) == 0);
|
||||
|
||||
bool loaded = i18n_load("engine/i18n/de.lang");
|
||||
assert(loaded);
|
||||
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Er selbst") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Sie selbst") == 0);
|
||||
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED),
|
||||
"Die fragende Person selbst") == 0);
|
||||
assert(strcmp(tarot_position_description(POSITION_ENVIRONMENT, GENDER_MALE),
|
||||
"Sein Umfeld und die darin wirkenden Tendenzen.") == 0);
|
||||
|
||||
loaded = i18n_load("engine/i18n/en.lang"); /* leave the catalog in a known state */
|
||||
assert(loaded);
|
||||
|
||||
printf("PASS test_gendered_position_text\n");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_tarot_determinism();
|
||||
test_natal_sun_sign();
|
||||
test_reading_generate_smoke();
|
||||
test_reading_print_json_shape();
|
||||
test_i18n_fallback_and_translation();
|
||||
test_gendered_position_text();
|
||||
printf("All smoke tests passed.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
@@ -3,23 +3,59 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../../engine/src/i18n.h"
|
||||
#include "guidance.h"
|
||||
#include "narrative.h"
|
||||
#include "reading_io.h"
|
||||
#include "significance.h"
|
||||
|
||||
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
|
||||
|
||||
static void print_usage(const char *prog) {
|
||||
fprintf(stderr,
|
||||
"Usage: %s [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"
|
||||
"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"
|
||||
"aspects driving it, each with a short narrative note.\n\n"
|
||||
"prints a full report: the day's overall significance level, the\n"
|
||||
"top aspects driving it (each with a short narrative note), a full\n"
|
||||
"readout of the Celtic Cross spread with every card's meaning, and\n"
|
||||
"finally guidance tying the day's top transit to the spread's\n"
|
||||
"Attitude/Outcome cards.\n\n"
|
||||
" --format Output format, defaults to text. html links card art via\n"
|
||||
" img/<file>, same convention as deck-engine's own html\n"
|
||||
" output - save/run this next to the img/ directory\n"
|
||||
" `make images` copies into dist/. json embeds the same\n"
|
||||
" narrative/guidance prose as text (in whatever --lang was\n"
|
||||
" loaded) alongside stable, language-independent slugs.\n"
|
||||
" --lang Reading language code, defaults to en. Matches a file\n"
|
||||
" named <code>.lang in --i18n-dir (see engine/i18n/) -\n"
|
||||
" note this is this binary's OWN output language, not\n"
|
||||
" related to whatever --lang deck-engine was run with to\n"
|
||||
" produce its --format json input (which is always\n"
|
||||
" language-independent - see docs/input-output-format.md).\n"
|
||||
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
|
||||
" i18n/ directory next to this binary (`make i18n`\n"
|
||||
" copies engine/i18n/ there as dist/i18n/).\n\n"
|
||||
"Typical use:\n"
|
||||
" dist/deck-engine ... --format json | %s\n"
|
||||
" dist/deck-engine ... --format json > reading.json && %s reading.json\n",
|
||||
prog, prog, prog);
|
||||
}
|
||||
|
||||
/* Same convention as engine/src/main.c's own default_i18n_path() -
|
||||
* duplicated rather than shared, since the two binaries are never
|
||||
* linked together (see CLAUDE.md's "Interpretation" section). */
|
||||
static void default_i18n_path(const char *argv0, const char *lang, char *out, size_t out_size) {
|
||||
const char *slash = strrchr(argv0, '/');
|
||||
if (slash) {
|
||||
int dir_len = (int)(slash - argv0);
|
||||
snprintf(out, out_size, "%.*s/i18n/%s.lang", dir_len, argv0, lang);
|
||||
} else {
|
||||
snprintf(out, out_size, "i18n/%s.lang", lang);
|
||||
}
|
||||
}
|
||||
|
||||
static char *read_all(FILE *f, size_t *out_length) {
|
||||
size_t cap = 4096;
|
||||
size_t len = 0;
|
||||
@@ -39,63 +75,463 @@ static char *read_all(FILE *f, size_t *out_length) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Display names for the report - deliberately a small local table rather
|
||||
* than linking engine/src/astro.c's astro_body_name()/astro_aspect_name()
|
||||
* (which would pull in the vendored Astronomy Engine just for cosmetic
|
||||
* text). Same policy as reading_io.c's slug tables. */
|
||||
static const char *ui(const char *key, const char *fallback) { return i18n_get(key, fallback); }
|
||||
|
||||
/* Slugs for i18n key building (body.<slug>/sign.<slug>/aspect.<slug>,
|
||||
* mirroring astro.c's own tables) and reused as-is for --format json's
|
||||
* language-independent fields - deliberately a small local table rather
|
||||
* than linking astro.c (would pull in the vendored Astronomy Engine just
|
||||
* for cosmetic text/slugs). Same policy as reading_io.c's own tables. */
|
||||
static const char *const k_body_slug[NUM_BODIES] = {
|
||||
"sun", "moon", "mercury", "venus", "mars",
|
||||
"jupiter", "saturn", "uranus", "neptune", "pluto",
|
||||
};
|
||||
static const char *const k_sign_slug[12] = {
|
||||
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
|
||||
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
|
||||
};
|
||||
static const char *const k_aspect_slug[5] = {
|
||||
"conjunction", "sextile", "square", "trine", "opposition",
|
||||
};
|
||||
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
|
||||
"quiet", "notable", "significant", "major",
|
||||
};
|
||||
|
||||
static const char *body_display_name(Body body) {
|
||||
static const char *const names[NUM_BODIES] = {
|
||||
static const char *const fallback[NUM_BODIES] = {
|
||||
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||
"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 *const names[5] = {
|
||||
static const char *const fallback[5] = {
|
||||
"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 *const names[12] = {
|
||||
static const char *const fallback[12] = {
|
||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||
"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
|
||||
* body (see parse_bodies() in reading_io.c) - a valid whole-sign house
|
||||
* is always 1-12, so this is a safe "unknown, say nothing" sentinel. */
|
||||
static void print_body_in_sign(const PlanetPosition *pos) {
|
||||
static void print_body_in_sign_text(FILE *out, const PlanetPosition *pos) {
|
||||
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) {
|
||||
switch (level) {
|
||||
case DAY_QUIET: return "Quiet";
|
||||
case DAY_NOTABLE: return "Notable";
|
||||
case DAY_SIGNIFICANT: return "Significant";
|
||||
case DAY_MAJOR: return "Major";
|
||||
/* Captures narrative_write()/guidance_write()'s output into a heap
|
||||
* string, for embedding into JSON - a stack buffer plus a single
|
||||
* malloc()+memcpy() copy, since main.c doesn't need to stream this
|
||||
* anywhere. Strips a single trailing newline; caller must free() the
|
||||
* result. */
|
||||
static char *capture_narrative(const Aspect *aspect, int house) {
|
||||
char stack_buf[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
|
||||
size_t len = strlen(stack_buf);
|
||||
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
|
||||
char *buf = malloc(len + 1);
|
||||
memcpy(buf, stack_buf, len + 1);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
|
||||
char stack_buf[GUIDANCE_TEXT_MAX];
|
||||
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
|
||||
size_t len = strlen(stack_buf);
|
||||
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
|
||||
char *buf = malloc(len + 1);
|
||||
memcpy(buf, stack_buf, len + 1);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/* Prints one significant-transit item's full detail (title line with
|
||||
* sign/house/orb/score, plus its narrative sentence) - shared between
|
||||
* the "Significant Transits" list and the day's top item, which is
|
||||
* repeated in full just above the guidance paragraph (see
|
||||
* interp_print_text) so the reader has that same detail in view right
|
||||
* next to the guidance text it informs. `index` is 1-based and prefixes
|
||||
* the line with "N. "; pass 0 to omit it, for the repeated top item,
|
||||
* which isn't part of the numbered list. */
|
||||
static void print_transit_item_text(FILE *out, const DailyReading *reading,
|
||||
const SignificantItem *item, int index) {
|
||||
const Aspect *a = &item->aspect;
|
||||
if (index > 0) fprintf(out, " %d. ", index);
|
||||
fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
|
||||
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
|
||||
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
|
||||
body_display_name(a->natal_planet));
|
||||
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
|
||||
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
|
||||
ui("interp.score", "score"), item->score);
|
||||
|
||||
char narrative_buf[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
|
||||
reading->transits.bodies[a->transiting_planet].house);
|
||||
fputs(narrative_buf, out);
|
||||
}
|
||||
|
||||
static void print_top_transits_header(FILE *out, int count) {
|
||||
if (count == 1) {
|
||||
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
|
||||
} else {
|
||||
char buf[64];
|
||||
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count);
|
||||
fprintf(out, "%s\n", buf);
|
||||
}
|
||||
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°, %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) {
|
||||
if (argc > 2 || (argc == 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0))) {
|
||||
print_usage(argv[0]);
|
||||
return argc > 2 ? 1 : 0;
|
||||
const char *format_str = "text";
|
||||
const char *lang = "en", *i18n_dir_arg = NULL;
|
||||
const char *input_path = NULL;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char *arg = argv[i];
|
||||
if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
||||
print_usage(argv[0]);
|
||||
return 0;
|
||||
} else if (strcmp(arg, "--format") == 0) {
|
||||
if (++i >= argc) { print_usage(argv[0]); return 1; }
|
||||
format_str = argv[i];
|
||||
} else if (strcmp(arg, "--lang") == 0) {
|
||||
if (++i >= argc) { print_usage(argv[0]); return 1; }
|
||||
lang = argv[i];
|
||||
} else if (strcmp(arg, "--i18n-dir") == 0) {
|
||||
if (++i >= argc) { print_usage(argv[0]); return 1; }
|
||||
i18n_dir_arg = argv[i];
|
||||
} else if (arg[0] == '-' && strcmp(arg, "-") != 0) {
|
||||
fprintf(stderr, "Unknown argument: %s\n", arg);
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
} else if (input_path == NULL) {
|
||||
input_path = arg;
|
||||
} else {
|
||||
print_usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
OutputFormat format;
|
||||
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
|
||||
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
|
||||
else if (strcmp(format_str, "json") == 0) format = FORMAT_JSON;
|
||||
else {
|
||||
fprintf(stderr, "Invalid --format, expected text, html, or json\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
char i18n_path[512];
|
||||
if (i18n_dir_arg) {
|
||||
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
|
||||
} else {
|
||||
default_i18n_path(argv[0], lang, i18n_path, sizeof i18n_path);
|
||||
}
|
||||
if (!i18n_load(i18n_path) && strcmp(lang, "en") != 0) {
|
||||
fprintf(stderr, "warning: could not load translations for '%s' from %s - "
|
||||
"falling back to built-in English\n", lang, i18n_path);
|
||||
}
|
||||
|
||||
FILE *in = stdin;
|
||||
bool opened_file = false;
|
||||
if (argc == 2 && strcmp(argv[1], "-") != 0) {
|
||||
in = fopen(argv[1], "r");
|
||||
if (input_path && strcmp(input_path, "-") != 0) {
|
||||
in = fopen(input_path, "r");
|
||||
if (!in) {
|
||||
fprintf(stderr, "error: could not open %s\n", argv[1]);
|
||||
fprintf(stderr, "error: could not open %s\n", input_path);
|
||||
return 1;
|
||||
}
|
||||
opened_file = true;
|
||||
@@ -116,30 +552,12 @@ int main(int argc, char **argv) {
|
||||
DailyInterpretation interp;
|
||||
interpret_daily_reading(&reading, &interp);
|
||||
|
||||
printf("Day significance: %s (%d/%d)\n", day_level_name(interp.day_level),
|
||||
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||
if (interpretation_deserves_framing(&interp)) {
|
||||
printf("(a major transit today - worth a deeper Celtic Cross look)\n");
|
||||
}
|
||||
printf("\n");
|
||||
|
||||
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);
|
||||
if (format == FORMAT_HTML) {
|
||||
interp_print_html(stdout, &reading, &interp);
|
||||
} else if (format == FORMAT_JSON) {
|
||||
interp_print_json(stdout, &reading, &interp);
|
||||
} else {
|
||||
interp_print_text(stdout, &reading, &interp);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,86 +1,103 @@
|
||||
#include "narrative.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 {
|
||||
const char *slug; /* builds "narrative.<slug>.*" i18n keys */
|
||||
const char *base; /* always relevant, may be "" (see the Sun) */
|
||||
const char *harmonious; /* shown only under a trine/sextile; may be "" */
|
||||
const char *discordant; /* shown only under a square/opposition; may be "" */
|
||||
} TransitNarrative;
|
||||
|
||||
/* See narrative.h's doc comment for sourcing (Sepharial, "Transits and
|
||||
* Planetary Periods", 1920, Chapter VIII) and the Moon/Pluto exception. */
|
||||
/* Fallback (English) text, also the source of truth when no translation
|
||||
* 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] = {
|
||||
[PLANET_SUN] = {
|
||||
.base = "",
|
||||
.harmonious = "Benefits from superiors and advancement in your sphere of "
|
||||
"life and work - honours, emoluments, and successful new "
|
||||
"associations.",
|
||||
.discordant = "Degradation and dishonour, loss of position, and adverse "
|
||||
"judgement from superiors.",
|
||||
"sun", "",
|
||||
"Benefits from superiors and advancement in your sphere of "
|
||||
"life and work - honours, emoluments, and successful new "
|
||||
"associations.",
|
||||
"Degradation and dishonour, loss of position, and adverse "
|
||||
"judgement from superiors.",
|
||||
},
|
||||
[PLANET_MOON] = {
|
||||
/* Original text, not from Sepharial - see narrative.h. */
|
||||
.base = "Colours the everyday and domestic sphere of life, often "
|
||||
"coinciding with the opening of new avenues.",
|
||||
.harmonious = "These changes tend to be advantageous.",
|
||||
.discordant = "These changes tend to be adverse, with some indisposition "
|
||||
"or domestic friction.",
|
||||
"moon",
|
||||
"Colours the everyday and domestic sphere of life, often "
|
||||
"coinciding with the opening of new avenues.",
|
||||
"These changes tend to be advantageous.",
|
||||
"These changes tend to be adverse, with some indisposition "
|
||||
"or domestic friction.",
|
||||
},
|
||||
[PLANET_MERCURY] = {
|
||||
.base = "Affects writings, journeys, commerce, and everyday activities - "
|
||||
"a neutral messenger whose effect follows the nature of the "
|
||||
"aspect it makes.",
|
||||
.harmonious = "",
|
||||
.discordant = "",
|
||||
"mercury",
|
||||
"Affects writings, journeys, commerce, and everyday activities - "
|
||||
"a neutral messenger whose effect follows the nature of the "
|
||||
"aspect it makes.",
|
||||
"", "",
|
||||
},
|
||||
[PLANET_VENUS] = {
|
||||
.base = "Brings domestic and social affairs to the fore - happiness, "
|
||||
"comforts, and favours.",
|
||||
.harmonious = "Success in love affairs and artistic pursuits is likely.",
|
||||
.discordant = "Grief and disappointment are more likely.",
|
||||
"venus",
|
||||
"Brings domestic and social affairs to the fore - happiness, "
|
||||
"comforts, and favours.",
|
||||
"Success in love affairs and artistic pursuits is likely.",
|
||||
"Grief and disappointment are more likely.",
|
||||
},
|
||||
[PLANET_MARS] = {
|
||||
.base = "A strenuous time of quarrels, contention, strife and anger, with "
|
||||
"some risk of hurts or injuries depending on the sign it "
|
||||
"occupies.",
|
||||
.harmonious = "Can bring benefits from doctors, surgeons, or new projects "
|
||||
"and enterprises.",
|
||||
.discordant = "",
|
||||
"mars",
|
||||
"A strenuous time of quarrels, contention, strife and anger, with "
|
||||
"some risk of hurts or injuries depending on the sign it "
|
||||
"occupies.",
|
||||
"Can bring benefits from doctors, surgeons, or new projects "
|
||||
"and enterprises.",
|
||||
"",
|
||||
},
|
||||
[PLANET_JUPITER] = {
|
||||
.base = "Brings increase and expansion - fullness of fortune and health, "
|
||||
"and a generally fortunate time.",
|
||||
.harmonious = "",
|
||||
.discordant = "",
|
||||
"jupiter",
|
||||
"Brings increase and expansion - fullness of fortune and health, "
|
||||
"and a generally fortunate time.",
|
||||
"", "",
|
||||
},
|
||||
[PLANET_SATURN] = {
|
||||
.base = "Brings depression, stagnation, hindrances and obstacles, and "
|
||||
"some deprivation of the usual benefits.",
|
||||
.harmonious = "Favours from older connections and past associations are "
|
||||
"still possible.",
|
||||
.discordant = "",
|
||||
"saturn",
|
||||
"Brings depression, stagnation, hindrances and obstacles, and "
|
||||
"some deprivation of the usual benefits.",
|
||||
"Favours from older connections and past associations are "
|
||||
"still possible.",
|
||||
"",
|
||||
},
|
||||
[PLANET_URANUS] = {
|
||||
.base = "Brings separations, estrangements, sudden dislocations and "
|
||||
"violent upsets.",
|
||||
.harmonious = "Success through official or civic channels, and "
|
||||
"beneficial changes or appointments, are possible.",
|
||||
.discordant = "",
|
||||
"uranus",
|
||||
"Brings separations, estrangements, sudden dislocations and "
|
||||
"violent upsets.",
|
||||
"Success through official or civic channels, and "
|
||||
"beneficial changes or appointments, are possible.",
|
||||
"",
|
||||
},
|
||||
[PLANET_NEPTUNE] = {
|
||||
.base = "Brings a state of chaos and confusion - an involved, uncertain "
|
||||
"condition of affairs, with plots, subtlety, or unseen "
|
||||
"influences at work.",
|
||||
.harmonious = "",
|
||||
.discordant = "",
|
||||
"neptune",
|
||||
"Brings a state of chaos and confusion - an involved, uncertain "
|
||||
"condition of affairs, with plots, subtlety, or unseen "
|
||||
"influences at work.",
|
||||
"", "",
|
||||
},
|
||||
[PLANET_PLUTO] = {
|
||||
/* Original text, not from Sepharial - see narrative.h. */
|
||||
.base = "Brings deep, often hidden transformation - the surfacing or "
|
||||
"dismantling of something that has outgrown its old form.",
|
||||
.harmonious = "",
|
||||
.discordant = "",
|
||||
"pluto",
|
||||
"Brings deep, often hidden transformation - the surfacing or "
|
||||
"dismantling of something that has outgrown its old form.",
|
||||
"", "",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -96,7 +113,8 @@ static bool aspect_is_discordant(AspectType type) {
|
||||
* house governs. This is centuries-old, uncredited astrological
|
||||
* convention (the same status as the sign/aspect names already baked
|
||||
* 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] = {
|
||||
"your sense of self, identity, and outward appearance",
|
||||
"money, possessions, and personal values",
|
||||
@@ -112,22 +130,53 @@ static const char *const k_house_area[12] = {
|
||||
"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) {
|
||||
if (buf_size == 0) return;
|
||||
buf[0] = '\0';
|
||||
|
||||
const TransitNarrative *n = &k_narratives[aspect->transiting_planet];
|
||||
const char *base = narrative_field(n->slug, "base", n->base);
|
||||
|
||||
const char *extra = "";
|
||||
if (aspect_is_harmonious(aspect->type)) extra = n->harmonious;
|
||||
else if (aspect_is_discordant(aspect->type)) extra = n->discordant;
|
||||
if (aspect_is_harmonious(aspect->type)) extra = narrative_field(n->slug, "harmonious", n->harmonious);
|
||||
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) {
|
||||
fprintf(out, "In matters of %s (house %d). ",
|
||||
k_house_area[transiting_house - 1], transiting_house);
|
||||
char house_key[24];
|
||||
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 (extra[0] != '\0') fprintf(out, "%s%s", n->base[0] != '\0' ? " " : "", extra);
|
||||
fprintf(out, "\n");
|
||||
if (base[0] != '\0') {
|
||||
len += (size_t)snprintf(buf + len, buf_size - len, "%s", base);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
#ifndef DECK_NARRATIVE_H
|
||||
#define DECK_NARRATIVE_H
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* Header-only dependency on the engine, same policy as significance.h -
|
||||
* see its comment for why. */
|
||||
#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
|
||||
* classically signifies, sourced from Sepharial's "Transits and
|
||||
* Planetary Periods" (1920, public domain - res/Transits_and_Planetary_
|
||||
@@ -22,15 +30,19 @@
|
||||
* narrative.c is original, written for this project in a matching
|
||||
* voice, not drawn from Sepharial.
|
||||
*
|
||||
* Prints a narrative sentence for `aspect`'s transiting planet to `out`,
|
||||
* indented with `indent` and followed by a newline - or prints nothing
|
||||
* if the source material has no text applicable to this planet/aspect
|
||||
* Writes a narrative sentence for `aspect`'s transiting planet into
|
||||
* `buf` (a caller-supplied buffer of `buf_size` bytes, always left
|
||||
* 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
|
||||
* only text is a "well/badly aspected" addendum, like the Sun). A
|
||||
* conjunction is treated as neutral - neither the "harmonious" nor the
|
||||
* "discordant" addendum is shown for it - since Sepharial's own text
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* framing and print the planet's narrative on its own. */
|
||||
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
|
||||
* framing and print the planet's narrative on its own.
|
||||
*
|
||||
* 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);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#define _DEFAULT_SOURCE /* for timegm, parsing "date" back into utc_moment */
|
||||
|
||||
#include "reading_io.h"
|
||||
#include "json.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* 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",
|
||||
};
|
||||
|
||||
/* 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) {
|
||||
for (int i = 0; i < NUM_BODIES; i++) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
parse_date(json_object_get(root, "date"), out);
|
||||
parse_gender(json_object_get(root, "gender"), out);
|
||||
|
||||
const JsonValue *natal = json_object_get(root, "natal");
|
||||
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
|
||||
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);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -10,18 +10,26 @@
|
||||
|
||||
/* Parses deck-engine's `--format json` output (text, null-terminated at
|
||||
* text[length]) and fills *out with just enough of a DailyReading for
|
||||
* interpret_daily_reading() to work on and for reporting sign/house
|
||||
* context around each significant event: out->transits.aspects[]/
|
||||
* aspect_count (used for scoring - see significance.c), and
|
||||
* out->natal.bodies[]/out->transits.bodies[] (sign + house per body,
|
||||
* used only for display). out->natal.ascendant_longitude/houses[] and
|
||||
* out->spread are left zeroed - nothing reads them yet.
|
||||
* interpret_daily_reading() to work on, for reporting sign/house context
|
||||
* around each significant event, and for guidance_print()'s tarot
|
||||
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
|
||||
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
|
||||
* house per body, used only for display), out->spread.positions[]
|
||||
* (card + reversed per Celtic Cross position, used only by
|
||||
* guidance.c), 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
|
||||
* present as an array (an empty array is fine - that's a real "no
|
||||
* aspects today" reading, not an error). A missing/unrecognized entry
|
||||
* in "natal"."bodies"/"transits"."bodies" is not an error - see
|
||||
* parse_bodies() in reading_io.c. */
|
||||
* aspects today" reading, not an error). A missing/unrecognized entry in
|
||||
* "natal"."bodies"/"transits"."bodies"/"spread"."positions", or a
|
||||
* 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);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "../src/json.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
|
||||
* --format json shape: "spread" is present with decoy content the loader
|
||||
* must skip over without understanding its schema, to prove it navigates
|
||||
* by key path rather than assuming any position. "natal"/"transits"
|
||||
* bodies are real (if partial) - reading_load_json now parses sign/house
|
||||
* from them for display, alongside the aspects used for scoring. */
|
||||
* --format json shape. "natal"/"transits" bodies are real (if partial) -
|
||||
* reading_load_json parses sign/house from them for display, alongside
|
||||
* the aspects used for scoring. "spread" only fills in the Attitude and
|
||||
* Outcome positions (the two guidance.c reads); the other eight are
|
||||
* absent entirely, to prove partial spread data doesn't fail the load. */
|
||||
static const char *k_fixture =
|
||||
"{"
|
||||
" \"date\": \"2026-07-16\","
|
||||
" \"gender\": \"female\","
|
||||
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
|
||||
" \"transits\": {"
|
||||
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
|
||||
@@ -57,7 +60,10 @@ static const char *k_fixture =
|
||||
" {\"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) {
|
||||
@@ -77,6 +83,62 @@ static void test_reading_load_json_extracts_aspects(void) {
|
||||
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) {
|
||||
DailyReading 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");
|
||||
}
|
||||
|
||||
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) {
|
||||
const char *text =
|
||||
"{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]},"
|
||||
@@ -146,7 +237,14 @@ int main(void) {
|
||||
test_json_parse_basic_shapes();
|
||||
test_json_parse_rejects_malformed();
|
||||
test_reading_load_json_extracts_aspects();
|
||||
test_reading_load_json_extracts_date();
|
||||
test_reading_load_json_missing_date_defaults_to_zero();
|
||||
test_reading_load_json_extracts_gender();
|
||||
test_reading_load_json_missing_gender_defaults_to_unspecified();
|
||||
test_reading_load_json_unrecognized_gender_defaults_to_unspecified();
|
||||
test_reading_load_json_extracts_body_sign_and_house();
|
||||
test_reading_load_json_extracts_spread_positions();
|
||||
test_reading_load_json_missing_spread_is_not_fatal();
|
||||
test_reading_load_json_unknown_body_slug_is_skipped_not_fatal();
|
||||
test_reading_load_json_empty_aspects_is_valid();
|
||||
test_reading_load_json_rejects_missing_transits();
|
||||
|
||||
@@ -1,117 +1,128 @@
|
||||
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "../../engine/src/i18n.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) {
|
||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
||||
.type = ASPECT_SQUARE, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 0);
|
||||
const char *text = slurp(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 0);
|
||||
|
||||
assert(strstr(text, "depression") != NULL);
|
||||
assert(strstr(text, "past associations") == NULL);
|
||||
|
||||
fclose(f);
|
||||
printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n");
|
||||
}
|
||||
|
||||
static void test_soft_aspect_adds_harmonious_bonus(void) {
|
||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
|
||||
.type = ASPECT_TRINE, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 0);
|
||||
const char *text = slurp(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 0);
|
||||
|
||||
assert(strstr(text, "depression") != NULL);
|
||||
assert(strstr(text, "past associations") != NULL);
|
||||
|
||||
fclose(f);
|
||||
printf("PASS test_soft_aspect_adds_harmonious_bonus\n");
|
||||
}
|
||||
|
||||
static void test_conjunction_is_neutral_prints_base_only(void) {
|
||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
|
||||
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 0);
|
||||
const char *text = slurp(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 0);
|
||||
|
||||
assert(strstr(text, "depression") != NULL);
|
||||
assert(strstr(text, "past associations") == NULL);
|
||||
|
||||
fclose(f);
|
||||
printf("PASS test_conjunction_is_neutral_prints_base_only\n");
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
|
||||
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 0);
|
||||
long len = ftell(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 0);
|
||||
|
||||
assert(len == 0);
|
||||
assert(text[0] == '\0');
|
||||
|
||||
fclose(f);
|
||||
printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n");
|
||||
}
|
||||
|
||||
static void test_discordant_aspect_on_empty_base_planet(void) {
|
||||
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
|
||||
.type = ASPECT_OPPOSITION, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 0);
|
||||
const char *text = slurp(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 0);
|
||||
|
||||
assert(strstr(text, "Degradation") != NULL);
|
||||
|
||||
fclose(f);
|
||||
printf("PASS test_discordant_aspect_on_empty_base_planet\n");
|
||||
}
|
||||
|
||||
static void test_known_house_adds_area_framing(void) {
|
||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
||||
.type = ASPECT_SQUARE, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 3);
|
||||
const char *text = slurp(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 3);
|
||||
|
||||
assert(strstr(text, "house 3") != NULL);
|
||||
assert(strstr(text, "communication") != NULL);
|
||||
assert(strstr(text, "depression") != NULL);
|
||||
|
||||
fclose(f);
|
||||
printf("PASS test_known_house_adds_area_framing\n");
|
||||
}
|
||||
|
||||
static void test_unknown_house_omits_area_framing(void) {
|
||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
||||
.type = ASPECT_SQUARE, .orb = 1.0 };
|
||||
FILE *f = tmpfile();
|
||||
narrative_print(f, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
|
||||
const char *text = slurp(f);
|
||||
char text[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(text, sizeof text, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
|
||||
|
||||
assert(strstr(text, "house") == NULL);
|
||||
assert(strstr(text, "depression") != NULL);
|
||||
|
||||
fclose(f);
|
||||
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) {
|
||||
test_hard_aspect_shows_base_but_not_harmonious_bonus();
|
||||
test_soft_aspect_adds_harmonious_bonus();
|
||||
@@ -120,6 +131,7 @@ int main(void) {
|
||||
test_discordant_aspect_on_empty_base_planet();
|
||||
test_known_house_adds_area_framing();
|
||||
test_unknown_house_omits_area_framing();
|
||||
test_translation_catalog_overrides_fallback_text();
|
||||
printf("All narrative tests passed.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||