Compare commits
6 Commits
1a654da3e9
..
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a3e437277f | |||
| ff2b43f1e0 | |||
| 4e83ebbf50 | |||
| 7a77c4a07d | |||
| 1ebbfebe31 | |||
| b94e76931a |
@@ -1,7 +1,11 @@
|
||||
name: build
|
||||
|
||||
# Build a versioned Linux CLI release tarball (see `make package`) on
|
||||
# every push/PR, plus on-demand via the Gitea "Run workflow" button.
|
||||
# Build a versioned Linux CLI release tarball (see `make package`) and the
|
||||
# Pebble watchapp (see `make watchapp`) 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 alongside the C
|
||||
# toolchain.
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
@@ -17,12 +21,18 @@ jobs:
|
||||
# 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`'s version comes from `git describe --tags` (see
|
||||
# the Makefile's `package` target) - needs full history/tags,
|
||||
# not actions/checkout's default shallow single-commit clone.
|
||||
# `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
|
||||
@@ -30,12 +40,14 @@ jobs:
|
||||
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; }
|
||||
|
||||
- name: Build and test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
make test
|
||||
make package
|
||||
make watchapp
|
||||
|
||||
- name: Upload build artifact
|
||||
# v4 uses the newer @actions/artifact backend, which this Gitea
|
||||
@@ -45,14 +57,21 @@ jobs:
|
||||
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: Publish to dl.ladkau.de
|
||||
# Uploads the tarball over SFTP instead of using
|
||||
# Uploads the tarball and .pbw 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
|
||||
FILE="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
|
||||
TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
|
||||
PBW="$(ls dist/deck-in-a-dash-*.pbw)"
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||
chmod 600 ~/.ssh/dl_sftp_key
|
||||
@@ -60,5 +79,6 @@ jobs:
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
uploader@dl.ladkau.de <<EOF
|
||||
-mkdir files/deck-in-a-dash
|
||||
put $FILE files/deck-in-a-dash/$(basename "$FILE")
|
||||
put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL")
|
||||
put $PBW files/deck-in-a-dash/$(basename "$PBW")
|
||||
EOF
|
||||
|
||||
+11
@@ -5,6 +5,13 @@
|
||||
/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
|
||||
|
||||
# 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
|
||||
@@ -16,3 +23,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
|
||||
|
||||
@@ -20,6 +20,9 @@ make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/
|
||||
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
|
||||
@@ -101,6 +104,50 @@ 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. Both the CLI tarball and the
|
||||
`.pbw` are uploaded as build artifacts and published to
|
||||
`dl.ladkau.de/files/deck-in-a-dash/` on push.
|
||||
|
||||
### Build image
|
||||
|
||||
`build-image/Dockerfile` pins the exact toolchain versions (Pebble
|
||||
Tool, Pebble SDK core, Node.js) needed for `make test`, `make package`,
|
||||
and `make watchapp`, 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` 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" above).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -200,6 +247,23 @@ more common (but less original) ordering; don't use it as a reference for
|
||||
the position enum order. Card and position text in `tarot_data.c` is
|
||||
condensed from Waite's own wording in Part III §3 (public domain).
|
||||
|
||||
**Gender** (`Gender` in `tarot.h`: `GENDER_UNSPECIFIED`/`GENDER_MALE`/
|
||||
`GENDER_FEMALE`) is who the reading is for — it only affects the
|
||||
pronouns in the 9 Celtic Cross position names/descriptions that have one
|
||||
(Waite addresses the querent as "him" throughout; every other position
|
||||
text in this codebase — card meanings, narrative, guidance — is already
|
||||
pronoun-neutral or direct "you" address, so nothing else varies with
|
||||
it). `DailyReading.gender` is set by `reading_generate()` the same way
|
||||
`utc_moment` is, `deck-engine`'s `--gender male|female|unspecified`
|
||||
flag/`dist/user.properties`' `gender=` field/the watch config page's
|
||||
Gender field all feed it, and `reading_print_json`/`reading_load_json`
|
||||
carry it across the `deck-engine` → `interpreter-cli` JSON boundary
|
||||
(`tarot_gender_slug()`) the same way `date` does. `GENDER_UNSPECIFIED`
|
||||
is the default and renders pronoun-neutral phrasing (singular "they" in
|
||||
English; German rephrases around a noun like "die fragende Person"
|
||||
rather than a pronoun, since German has no equivalent singular-they
|
||||
construction in common use).
|
||||
|
||||
### Translations (`i18n.c`, `engine/i18n/*.lang`)
|
||||
|
||||
All display text (planet/sign/moon-phase/aspect names, tarot card names
|
||||
@@ -217,7 +281,12 @@ Keys are namespaced by the *slug* tables in `tarot_data.c`/`astro.c`
|
||||
`moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
|
||||
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.
|
||||
@@ -444,10 +513,16 @@ built once and linked into *both* binaries.
|
||||
`VENDORED.md` for the pinned upstream commit.
|
||||
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
|
||||
from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
|
||||
domain.
|
||||
domain. The `GENDER_MALE` variant of the Celtic Cross position text
|
||||
keeps Waite's own "him"/"his" wording verbatim; the
|
||||
`GENDER_UNSPECIFIED`/`GENDER_FEMALE` variants are original paraphrases
|
||||
written for this project, not Waite's own text (see "Tarot" above).
|
||||
- `engine/i18n/de.lang`'s card/spread text is an original translation of
|
||||
that same public-domain English text, made for this project — not
|
||||
taken from any specific published German edition.
|
||||
taken from any specific published German edition. Its `Gender`-varying
|
||||
position text follows the same policy as the English original: `.male`
|
||||
is a direct translation of Waite's own wording, `.female`/unsuffixed
|
||||
are original paraphrases for this project.
|
||||
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
|
||||
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
||||
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
||||
|
||||
@@ -87,7 +87,12 @@ GUIDANCE_TEST_BIN := $(BUILD_DIR)/guidance_test
|
||||
ARCH := $(shell uname -m)
|
||||
PACKAGE_DIR := $(BUILD_DIR)/package
|
||||
|
||||
.PHONY: all test clean images i18n scripts package
|
||||
# ---- watch app (Pebble) ----
|
||||
|
||||
WATCH_DIR := watch
|
||||
WATCH_PBW := $(WATCH_DIR)/build/watch.pbw
|
||||
|
||||
.PHONY: all test clean images i18n scripts package watchapp
|
||||
|
||||
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
|
||||
|
||||
@@ -208,3 +213,32 @@ 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"
|
||||
|
||||
@@ -52,7 +52,18 @@ interpreter/ Separate module + binary (dist/interpreter-cli)
|
||||
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.
|
||||
docs/ Architecture/dataflow diagrams, input/output format reference.
|
||||
build-image/ Dockerfile for the reproducible build environment
|
||||
(C toolchain + Pebble SDK) 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`).
|
||||
```
|
||||
@@ -78,6 +89,29 @@ make package # -> dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz, a
|
||||
|
||||
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`
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
Executable
+34
@@ -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,49 @@
|
||||
# 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`) plus the Pebble SDK for the watch app (`make
|
||||
# watchapp`), 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
|
||||
|
||||
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`).
|
||||
# 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.
|
||||
# git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads,
|
||||
# and `make package`/`make watchapp`'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}"
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
+52
-15
@@ -17,6 +17,7 @@ same `--lang`) for its own report, independently of `deck-engine`.
|
||||
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
||||
--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 |
|
||||
@@ -29,6 +30,7 @@ deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
||||
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
||||
| `--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). |
|
||||
|
||||
@@ -52,6 +54,7 @@ use:
|
||||
birth_utc_offset=2
|
||||
birth_lat=52.5200
|
||||
birth_lon=13.4050
|
||||
gender=unspecified
|
||||
lang=en
|
||||
```
|
||||
|
||||
@@ -59,8 +62,8 @@ use:
|
||||
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
|
||||
checks every birth field for an empty value or a leftover `YOUR_`
|
||||
placeholder and refuses to run with a clear error until the file is
|
||||
filled in; `lang` has no such check since it always has a usable
|
||||
default (`en`). Since `dist/` itself can be wiped (`make clean`, or
|
||||
filled in; `gender` and `lang` have no such check since they always
|
||||
have a usable default (`unspecified`/`en`). Since `dist/` itself can be wiped (`make clean`, or
|
||||
deleting the directory), the build also keeps a durable backup at
|
||||
`engine/scripts/user.properties.local` (gitignored) once the file
|
||||
looks filled in, and restores from it if `dist/user.properties` is
|
||||
@@ -81,6 +84,14 @@ catalog with the built-in English text as the fallback for any key the
|
||||
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
|
||||
key-naming convention and how to add a new language.
|
||||
|
||||
Celtic Cross position text additionally varies with `--gender`: the
|
||||
plain key (e.g. `position.present.desc`) is the pronoun-neutral wording
|
||||
used for `unspecified`, and `.male`/`.female`-suffixed keys (e.g.
|
||||
`position.present.desc.male`) hold the gendered wording where it
|
||||
differs. A position whose wording doesn't vary by gender (e.g. "The
|
||||
Outcome") simply has no `.male`/`.female` keys - the neutral key's
|
||||
translation is used for every `--gender` value in that case.
|
||||
|
||||
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
|
||||
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
|
||||
build, like `dist/img/`). Adding a language is just dropping another
|
||||
@@ -146,7 +157,7 @@ Transiting Sun Opposition natal Moon (orb 3.7°)
|
||||
|
||||
===== Celtic Cross =====
|
||||
The Present The High Priestess (Reversed)
|
||||
This covers him: the general influence affecting the matter.
|
||||
This covers them: the general influence affecting the matter.
|
||||
Passion, moral or physical ardour, conceit, surface knowledge.
|
||||
...
|
||||
```
|
||||
@@ -171,8 +182,8 @@ cards rendered as an image:
|
||||
|
||||
### `--format json`
|
||||
|
||||
A single JSON object mirroring `DailyReading` exactly — `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
|
||||
@@ -182,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},
|
||||
@@ -210,6 +223,17 @@ read.
|
||||
}
|
||||
```
|
||||
|
||||
- `date` is the UTC calendar date of `--date` (or the current system time
|
||||
if `--date` was omitted) — the day this reading is *for*, not
|
||||
necessarily the day it was generated on. `reading_generate()` stores
|
||||
the full moment in `DailyReading.utc_moment`; only the date part is
|
||||
serialized here since that's the only thing the rest of this format
|
||||
(and the interpreter's own reports, which read this field back — see
|
||||
"The interpreter" below) ever display.
|
||||
- `gender` is `--gender`'s value (`male`/`female`/`unspecified`,
|
||||
`tarot_gender_slug()`) — who the reading is for. Only affects the
|
||||
Celtic Cross position text's pronouns, nothing else in this document.
|
||||
The interpreter reads this field back the same way it reads `date`.
|
||||
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
|
||||
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
||||
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
||||
@@ -279,11 +303,16 @@ dist/run-interpreter.sh html de # override format/language for this run
|
||||
### `--format text`
|
||||
|
||||
A plain-text report, printed in a fixed order —
|
||||
1. the day's overall significance level (`Quiet`/`Notable`/
|
||||
1. the day this reading is for (`Reading for: 2026-07-16`), from the
|
||||
input JSON's top-level `date` field (see `--format json` above) —
|
||||
not necessarily the day the report is actually being read on, if
|
||||
`reading.json` was generated earlier and piped into
|
||||
`interpreter-cli` later;
|
||||
2. the day's overall significance level (`Quiet`/`Notable`/
|
||||
`Significant`/`Major`, plus its rank out of the 4 defined levels,
|
||||
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
|
||||
line on `Major` days only;
|
||||
2. up to 5 of today's aspects ranked by score, each naming the sign
|
||||
3. up to 5 of today's aspects ranked by score, each naming the sign
|
||||
and natal house the transiting planet currently occupies and the
|
||||
sign and house of the natal planet it's aspecting, followed by a
|
||||
short narrative sentence about what that transiting planet
|
||||
@@ -291,10 +320,10 @@ A plain-text report, printed in a fixed order —
|
||||
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
|
||||
the source material has nothing to say for that planet/aspect
|
||||
combination;
|
||||
3. the full 10-position Celtic Cross spread, in Waite's own drawing
|
||||
4. the full 10-position Celtic Cross spread, in Waite's own drawing
|
||||
order, each with its card (and orientation), the position's own
|
||||
description, and that card's actual meaning;
|
||||
4. finally, a guidance paragraph tying the day's top transit to the
|
||||
5. finally, a guidance paragraph tying the day's top transit to the
|
||||
spread's Attitude/Outcome cards, with its opening sentence tailored
|
||||
to `day_level` (stronger wording on `Major`, softer on
|
||||
`Quiet`/`Notable`, falling back to a transit-free reading of the
|
||||
@@ -305,6 +334,8 @@ A plain-text report, printed in a fixed order —
|
||||
seen the full spread it references.
|
||||
|
||||
```
|
||||
Reading for: 2026-07-16
|
||||
|
||||
Day significance: Major (4/4)
|
||||
(a major transit today - worth a deeper Celtic Cross look)
|
||||
|
||||
@@ -318,14 +349,14 @@ Top 5 significant transits:
|
||||
|
||||
Celtic Cross:
|
||||
1. The Present: The Emperor (Reversed)
|
||||
This covers him: the general influence affecting the matter.
|
||||
This covers them: the general influence affecting the matter.
|
||||
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
|
||||
2. The Challenge: The Devil
|
||||
This crosses him: the nature of the obstacle in the matter.
|
||||
This crosses them: the nature of the obstacle in the matter.
|
||||
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
|
||||
...
|
||||
7. Himself: The Magician
|
||||
His position or attitude in the circumstances.
|
||||
7. Themself: The Magician
|
||||
Their position or attitude in the circumstances.
|
||||
Skill, diplomacy, address, subtlety; self-confidence, will.
|
||||
...
|
||||
10. The Outcome: Strength
|
||||
@@ -354,6 +385,8 @@ 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)
|
||||
|
||||
@@ -364,7 +397,7 @@ Top 5 wichtige Transits:
|
||||
|
||||
Keltisches Kreuz:
|
||||
1. Die Gegenwart: Der Herrscher (Umgekehrt)
|
||||
Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
|
||||
...
|
||||
|
||||
@@ -390,7 +423,7 @@ HTML output:
|
||||
<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 him: the general influence affecting the matter.</div>
|
||||
<div class="desc">This covers them: the general influence affecting the matter.</div>
|
||||
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
|
||||
</div>
|
||||
```
|
||||
@@ -415,6 +448,7 @@ has the slugs to work with directly.
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-07-16",
|
||||
"day_significance": {
|
||||
"level": "major",
|
||||
"rank": 4,
|
||||
@@ -448,6 +482,9 @@ has the slugs to work with directly.
|
||||
}
|
||||
```
|
||||
|
||||
- `date` is copied straight through from the input JSON's own top-level
|
||||
`date` field (see `--format json` above) - always language-independent
|
||||
regardless of `--lang`, same as every other slug in this document.
|
||||
- `significant_transits` has `interp.top_item_count` entries (0-5, same
|
||||
ranking as `--format text`); `celtic_cross` always has exactly 10, in
|
||||
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
|
||||
|
||||
+41
-12
@@ -71,25 +71,53 @@ aspect.square=Quadrat
|
||||
aspect.trine=Trigon
|
||||
aspect.opposition=Opposition
|
||||
|
||||
# --- Positionen des Keltischen Kreuzes (tarot_data.c) ---
|
||||
# --- Positionen des Keltischen Kreuzes (tarot_data.c). Waite spricht die
|
||||
# fragende Person durchgehend mit "ihn/ihm/sein" an; der unsuffigierte
|
||||
# Schlüssel ist eine geschlechtsneutrale Umschreibung (über "die fragende
|
||||
# Person"/"die eigene", ohne Pronomen), die bei nicht angegebenem
|
||||
# Geschlecht verwendet wird, ".male" ist Waites eigener Wortlaut
|
||||
# unverändert, ".female" eine für dieses Projekt geschriebene Umschreibung -
|
||||
# siehe tarot_data.c's Kommentar zu Gender. ---
|
||||
position.present.name=Die Gegenwart
|
||||
position.present.desc=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.present.desc=Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.present.desc.male=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.present.desc.female=Dies bedeckt sie: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||
position.challenge.name=Die Herausforderung
|
||||
position.challenge.desc=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
||||
position.challenge.desc=Dies kreuzt die fragende Person: die Art des Hindernisses in der Angelegenheit.
|
||||
position.challenge.desc.male=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
||||
position.challenge.desc.female=Dies kreuzt sie: die Art des Hindernisses in der Angelegenheit.
|
||||
position.crown.name=Die Krone
|
||||
position.crown.desc=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.crown.desc=Dies krönt die fragende Person: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.crown.desc.male=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.crown.desc.female=Dies krönt sie: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||
position.foundation.name=Das Fundament
|
||||
position.foundation.desc=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.foundation.desc=Dies liegt unter der fragenden Person: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.foundation.desc.male=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.foundation.desc.female=Dies liegt unter ihr: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||
position.recent_past.name=Die jüngste Vergangenheit
|
||||
position.recent_past.desc=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
||||
position.recent_past.desc=Dies liegt hinter der fragenden Person: der Einfluss, der gerade vergeht.
|
||||
position.recent_past.desc.male=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
||||
position.recent_past.desc.female=Dies liegt hinter ihr: der Einfluss, der gerade vergeht.
|
||||
position.near_future.name=Die nahe Zukunft
|
||||
position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
||||
position.attitude.name=Er selbst
|
||||
position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.environment.name=Sein Haus
|
||||
position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen.
|
||||
position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird.
|
||||
position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
||||
position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird.
|
||||
position.attitude.name=Die fragende Person selbst
|
||||
position.attitude.name.male=Er selbst
|
||||
position.attitude.name.female=Sie selbst
|
||||
position.attitude.desc=Die eigene Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.attitude.desc.male=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.attitude.desc.female=Ihre Haltung oder Einstellung in den gegebenen Umständen.
|
||||
position.environment.name=Das eigene Haus
|
||||
position.environment.name.male=Sein Haus
|
||||
position.environment.name.female=Ihr Haus
|
||||
position.environment.desc=Das eigene Umfeld und die darin wirkenden Tendenzen.
|
||||
position.environment.desc.male=Sein Umfeld und die darin wirkenden Tendenzen.
|
||||
position.environment.desc.female=Ihr Umfeld und die darin wirkenden Tendenzen.
|
||||
position.hopes_and_fears.name=Hoffnungen und Ängste
|
||||
position.hopes_and_fears.desc=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.hopes_and_fears.desc=Die eigenen Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.hopes_and_fears.desc.male=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.hopes_and_fears.desc.female=Ihre Hoffnungen oder Ängste in der Angelegenheit.
|
||||
position.outcome.name=Das Ergebnis
|
||||
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
|
||||
|
||||
@@ -162,6 +190,7 @@ card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Fluc
|
||||
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
|
||||
|
||||
+40
-12
@@ -79,25 +79,52 @@ aspect.trine=Trine
|
||||
aspect.opposition=Opposition
|
||||
|
||||
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
|
||||
# own wording from "An Ancient Celtic Method of Divination" ---
|
||||
# own wording from "An Ancient Celtic Method of Divination". Waite
|
||||
# addresses the querent as "him" throughout; the unsuffixed key is a
|
||||
# pronoun-neutral paraphrase (singular "they") used when no gender is
|
||||
# configured, the ".male" key is Waite's own wording verbatim, and the
|
||||
# ".female" key is an original paraphrase - see tarot_data.c's own
|
||||
# comment on Gender. ---
|
||||
position.present.name=The Present
|
||||
position.present.desc=This covers him: the general influence affecting the matter.
|
||||
position.present.desc=This covers them: the general influence affecting the matter.
|
||||
position.present.desc.male=This covers him: the general influence affecting the matter.
|
||||
position.present.desc.female=This covers her: the general influence affecting the matter.
|
||||
position.challenge.name=The Challenge
|
||||
position.challenge.desc=This crosses him: the nature of the obstacle in the matter.
|
||||
position.challenge.desc=This crosses them: the nature of the obstacle in the matter.
|
||||
position.challenge.desc.male=This crosses him: the nature of the obstacle in the matter.
|
||||
position.challenge.desc.female=This crosses her: the nature of the obstacle in the matter.
|
||||
position.crown.name=The Crown
|
||||
position.crown.desc=This crowns him: the aim or ideal, the best that can be achieved.
|
||||
position.crown.desc=This crowns them: the aim or ideal, the best that can be achieved.
|
||||
position.crown.desc.male=This crowns him: the aim or ideal, the best that can be achieved.
|
||||
position.crown.desc.female=This crowns her: the aim or ideal, the best that can be achieved.
|
||||
position.foundation.name=The Foundation
|
||||
position.foundation.desc=This is beneath him: the basis of the matter, already actual.
|
||||
position.foundation.desc=This is beneath them: the basis of the matter, already actual.
|
||||
position.foundation.desc.male=This is beneath him: the basis of the matter, already actual.
|
||||
position.foundation.desc.female=This is beneath her: the basis of the matter, already actual.
|
||||
position.recent_past.name=The Recent Past
|
||||
position.recent_past.desc=This is behind him: the influence that is just passing away.
|
||||
position.recent_past.desc=This is behind them: the influence that is just passing away.
|
||||
position.recent_past.desc.male=This is behind him: the influence that is just passing away.
|
||||
position.recent_past.desc.female=This is behind her: the influence that is just passing away.
|
||||
position.near_future.name=The Near Future
|
||||
position.near_future.desc=This is before him: the influence now coming into action.
|
||||
position.attitude.name=Himself
|
||||
position.attitude.desc=His position or attitude in the circumstances.
|
||||
position.environment.name=His House
|
||||
position.environment.desc=His environment and the tendencies at work therein.
|
||||
position.near_future.desc=This is before them: the influence now coming into action.
|
||||
position.near_future.desc.male=This is before him: the influence now coming into action.
|
||||
position.near_future.desc.female=This is before her: the influence now coming into action.
|
||||
position.attitude.name=Themself
|
||||
position.attitude.name.male=Himself
|
||||
position.attitude.name.female=Herself
|
||||
position.attitude.desc=Their position or attitude in the circumstances.
|
||||
position.attitude.desc.male=His position or attitude in the circumstances.
|
||||
position.attitude.desc.female=Her position or attitude in the circumstances.
|
||||
position.environment.name=Their House
|
||||
position.environment.name.male=His House
|
||||
position.environment.name.female=Her House
|
||||
position.environment.desc=Their environment and the tendencies at work therein.
|
||||
position.environment.desc.male=His environment and the tendencies at work therein.
|
||||
position.environment.desc.female=Her environment and the tendencies at work therein.
|
||||
position.hopes_and_fears.name=Hopes and Fears
|
||||
position.hopes_and_fears.desc=His hopes or fears in the matter.
|
||||
position.hopes_and_fears.desc=Their hopes or fears in the matter.
|
||||
position.hopes_and_fears.desc.male=His hopes or fears in the matter.
|
||||
position.hopes_and_fears.desc.female=Her hopes or fears in the matter.
|
||||
position.outcome.name=The Outcome
|
||||
position.outcome.desc=What will come: the final result of the matter.
|
||||
|
||||
@@ -171,6 +198,7 @@ card.world.upright=Assured success, recompense, voyage, route, emigration, fligh
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -41,6 +41,7 @@ birth_time=""
|
||||
birth_utc_offset=""
|
||||
birth_lat=""
|
||||
birth_lon=""
|
||||
gender="unspecified"
|
||||
lang="en"
|
||||
|
||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
@@ -55,6 +56,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||
birth_utc_offset) birth_utc_offset="$value" ;;
|
||||
birth_lat) birth_lat="$value" ;;
|
||||
birth_lon) birth_lon="$value" ;;
|
||||
gender) [ -n "$value" ] && gender="$value" ;;
|
||||
lang) [ -n "$value" ] && lang="$value" ;;
|
||||
esac
|
||||
done <"$PROPERTIES_FILE"
|
||||
@@ -92,4 +94,5 @@ lang="${2:-$lang}"
|
||||
--birth-lon "$birth_lon" \
|
||||
--date "$transit_date" \
|
||||
--format json \
|
||||
--gender "$gender" \
|
||||
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-1
@@ -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);
|
||||
|
||||
+11
-5
@@ -3,8 +3,10 @@
|
||||
|
||||
#include <math.h>
|
||||
|
||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
||||
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||
time_t utc_moment, DailyReading *out) {
|
||||
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);
|
||||
@@ -65,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));
|
||||
}
|
||||
}
|
||||
@@ -137,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");
|
||||
@@ -160,6 +162,10 @@ static void print_body_position_json(FILE *out, const char *indent, Body body,
|
||||
void reading_print_json(const DailyReading *r, FILE *out) {
|
||||
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]);
|
||||
|
||||
+11
-1
@@ -15,6 +15,16 @@
|
||||
#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;
|
||||
@@ -22,7 +32,7 @@ typedef struct {
|
||||
|
||||
/* The real API: this is the only function the watchapp needs to call.
|
||||
* It walks the returned struct directly to lay out its own screens. */
|
||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
||||
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||
time_t utc_moment, DailyReading *out);
|
||||
|
||||
/* Desktop-only output formats for inspecting a reading before any watch
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
+112
-34
@@ -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;
|
||||
}
|
||||
|
||||
+148
-83
@@ -126,6 +126,15 @@ static const char *sign_display_name(ZodiacSign 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");
|
||||
@@ -179,48 +188,19 @@ static char *capture_guidance(const DailyInterpretation *interp, const CelticCro
|
||||
return buf;
|
||||
}
|
||||
|
||||
static void print_top_transits_header(FILE *out, int count) {
|
||||
if (count == 1) {
|
||||
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
|
||||
} else {
|
||||
char buf[64];
|
||||
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count);
|
||||
fprintf(out, "%s\n", buf);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== --format text ===== */
|
||||
|
||||
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread) {
|
||||
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
const TarotDraw *draw = &spread->positions[i];
|
||||
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i),
|
||||
tarot_card_name(draw->card), reversed_marker(draw->reversed));
|
||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
|
||||
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||
}
|
||||
}
|
||||
|
||||
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
|
||||
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||
if (interpretation_deserves_framing(interp)) {
|
||||
fprintf(out, "%s\n", ui("interp.major_framing",
|
||||
"(a major transit today - worth a deeper Celtic Cross look)"));
|
||||
}
|
||||
fprintf(out, "\n");
|
||||
|
||||
if (interp->top_item_count == 0) {
|
||||
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
|
||||
} else {
|
||||
print_top_transits_header(out, interp->top_item_count);
|
||||
for (int i = 0; i < interp->top_item_count; i++) {
|
||||
const SignificantItem *item = &interp->top_items[i];
|
||||
/* 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;
|
||||
|
||||
fprintf(out, " %d. %s %s", i + 1, ui("ui.transiting", "Transiting"),
|
||||
body_display_name(a->transiting_planet));
|
||||
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));
|
||||
@@ -233,19 +213,106 @@ static void interp_print_text(FILE *out, const DailyReading *reading, const Dail
|
||||
reading->transits.bodies[a->transiting_planet].house);
|
||||
fputs(narrative_buf, out);
|
||||
}
|
||||
|
||||
static void print_top_transits_header(FILE *out, int count) {
|
||||
if (count == 1) {
|
||||
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
|
||||
} else {
|
||||
char buf[64];
|
||||
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count);
|
||||
fprintf(out, "%s\n", buf);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== --format text ===== */
|
||||
|
||||
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread, Gender gender) {
|
||||
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
const TarotDraw *draw = &spread->positions[i];
|
||||
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i, gender),
|
||||
tarot_card_name(draw->card), reversed_marker(draw->reversed));
|
||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender));
|
||||
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||
}
|
||||
}
|
||||
|
||||
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||
char date_buf[32];
|
||||
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
|
||||
|
||||
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
|
||||
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||
if (interpretation_deserves_framing(interp)) {
|
||||
fprintf(out, "%s\n", ui("interp.major_framing",
|
||||
"(a major transit today - worth a deeper Celtic Cross look)"));
|
||||
}
|
||||
fprintf(out, "\n");
|
||||
|
||||
print_celtic_cross_text(out, &reading->spread);
|
||||
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"
|
||||
@@ -267,6 +334,11 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
|
||||
|
||||
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
|
||||
|
||||
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),
|
||||
@@ -277,48 +349,20 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
|
||||
}
|
||||
fprintf(out, "</p>\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++) {
|
||||
const SignificantItem *item = &interp->top_items[i];
|
||||
const Aspect *a = &item->aspect;
|
||||
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
|
||||
char *guidance = capture_guidance(interp, &reading->spread);
|
||||
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
|
||||
|
||||
fprintf(out, "<tr><td>%d.</td><td>%s %s %s %s %s</td>"
|
||||
"<td>%s %.1f°, %s %.2f</td></tr>\n",
|
||||
i + 1, 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);
|
||||
}
|
||||
fprintf(out, "</table>\n");
|
||||
}
|
||||
|
||||
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
const TarotDraw *draw = &reading->spread.positions[i];
|
||||
fprintf(out,
|
||||
"<div class=\"card\">\n"
|
||||
" <div class=\"position\">%s</div>\n"
|
||||
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
|
||||
" <div class=\"name\">%s%s</div>\n"
|
||||
" <div class=\"desc\">%s</div>\n"
|
||||
" <div class=\"meaning\">%s</div>\n"
|
||||
"</div>\n",
|
||||
tarot_position_name((CelticCrossPosition)i), draw->reversed ? "reversed" : "",
|
||||
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
|
||||
reversed_marker(draw->reversed), tarot_position_description((CelticCrossPosition)i),
|
||||
tarot_card_meaning(draw->card, draw->reversed));
|
||||
}
|
||||
fprintf(out, "<div 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");
|
||||
|
||||
char *guidance = capture_guidance(interp, &reading->spread);
|
||||
fprintf(out, "<h2>%s</h2>\n<div class=\"guidance\">\n", ui("interp.heading_guidance", "Guidance"));
|
||||
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') {
|
||||
@@ -330,6 +374,23 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -356,6 +417,10 @@ static void json_string(FILE *out, const char *s) {
|
||||
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
@@ -36,6 +39,12 @@ static const char *const k_position_slug[TAROT_SPREAD_SIZE] = {
|
||||
"near_future", "attitude", "environment", "hopes_and_fears", "outcome",
|
||||
};
|
||||
|
||||
/* Mirrors tarot_data.c's own k_gender_slug, same duplication policy as
|
||||
* k_card_slug/k_position_slug above (no reverse lookup exposed there). */
|
||||
static const char *const k_gender_slug[3] = {
|
||||
"unspecified", "male", "female",
|
||||
};
|
||||
|
||||
static bool body_from_slug(const char *slug, Body *out) {
|
||||
for (int i = 0; i < NUM_BODIES; i++) {
|
||||
if (strcmp(slug, k_body_slug[i]) == 0) {
|
||||
@@ -101,6 +110,16 @@ static bool card_from_slug(const char *slug, TarotCard *out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool gender_from_slug(const char *slug, Gender *out) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (strcmp(slug, k_gender_slug[i]) == 0) {
|
||||
*out = (Gender)i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
if (strcmp(slug, k_position_slug[i]) == 0) {
|
||||
@@ -133,6 +152,37 @@ static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Parses the top-level "date" field ("YYYY-MM-DD", see reading_print_json
|
||||
* in engine/src/reading.c) back into out->utc_moment, as midnight UTC of
|
||||
* that date - reading_print_json only ever wrote the calendar date, not a
|
||||
* time of day, so that's the only value that round-trips through
|
||||
* gmtime() to the same date. Lenient like parse_bodies()/parse_spread():
|
||||
* a missing or malformed "date" just leaves out->utc_moment at 0 (from
|
||||
* reading_load_json's own memset), not a load failure - nothing here
|
||||
* depends on it for scoring, only for display. */
|
||||
static void parse_date(const JsonValue *date, DailyReading *out) {
|
||||
const char *date_str = date ? json_as_string(date) : NULL;
|
||||
if (!date_str) return;
|
||||
|
||||
struct tm tm_date = {0};
|
||||
if (sscanf(date_str, "%d-%d-%d", &tm_date.tm_year, &tm_date.tm_mon, &tm_date.tm_mday) != 3) return;
|
||||
tm_date.tm_year -= 1900;
|
||||
tm_date.tm_mon -= 1;
|
||||
out->utc_moment = timegm(&tm_date);
|
||||
}
|
||||
|
||||
/* Parses the top-level "gender" field (see reading_print_json in
|
||||
* engine/src/reading.c) into out->gender. Lenient like parse_date(): a
|
||||
* missing or unrecognized value just leaves out->gender at
|
||||
* GENDER_UNSPECIFIED (0, from reading_load_json's own memset), not a
|
||||
* load failure - it only affects Celtic Cross position text pronouns,
|
||||
* nothing scoring depends on it. */
|
||||
static void parse_gender(const JsonValue *gender, DailyReading *out) {
|
||||
const char *gender_str = gender ? json_as_string(gender) : NULL;
|
||||
if (!gender_str) return;
|
||||
gender_from_slug(gender_str, &out->gender);
|
||||
}
|
||||
|
||||
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
@@ -174,6 +224,9 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
||||
}
|
||||
out->transits.aspect_count = filled;
|
||||
|
||||
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);
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
* 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), and out->spread.positions[]
|
||||
* house per body, used only for display), out->spread.positions[]
|
||||
* (card + reversed per Celtic Cross position, used only by
|
||||
* guidance.c). out->natal.ascendant_longitude/houses[] are left zeroed -
|
||||
* nothing reads them yet.
|
||||
* 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
|
||||
|
||||
@@ -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"
|
||||
@@ -48,6 +49,8 @@ static void test_json_parse_rejects_malformed(void) {
|
||||
* 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}],"
|
||||
@@ -80,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);
|
||||
@@ -178,6 +237,11 @@ 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();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to registry.env (gitignored, never commit the real values) and
|
||||
# fill in your cr.ladkau.de credentials. Used by build-image.sh,
|
||||
# run-image.sh, and upload-image.sh.
|
||||
REGISTRY=cr.ladkau.de
|
||||
REGISTRY_IMAGE=cr.ladkau.de/deck-in-a-dash/builder
|
||||
REGISTRY_USER=
|
||||
REGISTRY_PASSWORD=
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs `make test package watchapp` 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` 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, and the generated watch resources below)
|
||||
# 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"
|
||||
|
||||
echo "== Running make test package watchapp 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, 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
|
||||
'
|
||||
|
||||
echo "== Done — artifacts in dist/ =="
|
||||
ls -la "$ROOT/dist"
|
||||
Executable
+41
@@ -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"
|
||||
+123
-23
@@ -2,7 +2,9 @@
|
||||
"name": "deck_in_a_dash_watch",
|
||||
"author": "Matthias Ladkau",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["pebble-app"],
|
||||
"keywords": [
|
||||
"pebble-app"
|
||||
],
|
||||
"private": true,
|
||||
"dependencies": {},
|
||||
"pebble": {
|
||||
@@ -10,6 +12,9 @@
|
||||
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
|
||||
"sdkVersion": "3",
|
||||
"enableMultiJS": true,
|
||||
"capabilities": [
|
||||
"configurable"
|
||||
],
|
||||
"targetPlatforms": [
|
||||
"basalt",
|
||||
"chalk",
|
||||
@@ -30,6 +35,7 @@
|
||||
"BIRTH_UTC_OFFSET_MINUTES",
|
||||
"BIRTH_LAT_MICRODEG",
|
||||
"BIRTH_LON_MICRODEG",
|
||||
"GENDER",
|
||||
"LANG",
|
||||
"NOTIFY_ENABLED",
|
||||
"NOTIFY_HOUR",
|
||||
@@ -37,28 +43,122 @@
|
||||
],
|
||||
"resources": {
|
||||
"media": [
|
||||
{ "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" }
|
||||
{
|
||||
"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()
|
||||
@@ -3,21 +3,20 @@
|
||||
|
||||
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 (256KB
|
||||
total on basalt-class platforms, shared with every other resource this
|
||||
app ships). 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.
|
||||
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) was picked empirically: a real `pebble build`
|
||||
of two sample cards at this size measured ~5KB/card on color platforms
|
||||
(basalt/chalk/emery/gabbro) and ~2.9KB/card on the black-and-white
|
||||
diorite/flint - so all 22 cards together cost roughly 111KB/64KB
|
||||
respectively, comfortably inside the 256KB resource budget with room
|
||||
left for other future resources (fonts, icons), while still rendering
|
||||
at close to a full basalt-class screen's height (120px of 168px).
|
||||
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
|
||||
|
||||
@@ -44,6 +44,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) {
|
||||
cfg.birth.longitude = v / 1000000.0;
|
||||
got_birth = true;
|
||||
}
|
||||
if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v;
|
||||
|
||||
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
|
||||
if (lang_tuple) {
|
||||
@@ -60,6 +61,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) {
|
||||
* of the settings page) must not un-configure an already-set-up watch. */
|
||||
if (got_birth) cfg.configured = true;
|
||||
|
||||
config_log(&cfg); /* confirms what was actually received/parsed */
|
||||
config_save(&cfg);
|
||||
if (s_callback) s_callback(&cfg);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ enum {
|
||||
PKEY_BIRTH_UTC_OFFSET_MINUTES,
|
||||
PKEY_BIRTH_LAT_MICRODEG,
|
||||
PKEY_BIRTH_LON_MICRODEG,
|
||||
PKEY_GENDER,
|
||||
PKEY_LANG,
|
||||
PKEY_NOTIFY_ENABLED,
|
||||
PKEY_NOTIFY_HOUR,
|
||||
@@ -40,6 +41,7 @@ void config_load(WatchConfig *out) {
|
||||
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
|
||||
out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0;
|
||||
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
|
||||
out->gender = (Gender)persist_read_int(PKEY_GENDER); /* defaults to 0 = GENDER_UNSPECIFIED */
|
||||
|
||||
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
|
||||
strcpy(out->lang, "en");
|
||||
@@ -61,6 +63,7 @@ void config_save(const WatchConfig *cfg) {
|
||||
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
|
||||
persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0));
|
||||
persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
|
||||
persist_write_int(PKEY_GENDER, (int32_t)cfg->gender);
|
||||
|
||||
persist_write_string(PKEY_LANG, cfg->lang);
|
||||
|
||||
@@ -68,3 +71,20 @@ void config_save(const WatchConfig *cfg) {
|
||||
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
|
||||
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
|
||||
}
|
||||
|
||||
/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support
|
||||
* floating-point format specifiers. */
|
||||
void config_log(const WatchConfig *cfg) {
|
||||
if (!cfg->configured) {
|
||||
APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet");
|
||||
return;
|
||||
}
|
||||
APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin",
|
||||
cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
|
||||
(int)(cfg->birth.utc_offset_hours * 60));
|
||||
APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 gender=%d lang=%s",
|
||||
(long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000),
|
||||
(int)cfg->gender, cfg->lang);
|
||||
APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
|
||||
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <pebble.h>
|
||||
|
||||
#include "../../../engine/src/astro.h"
|
||||
#include "../../../engine/src/tarot.h" /* Gender */
|
||||
|
||||
/* Everything the watch needs that isn't computed fresh each day: birth
|
||||
* data (the same fields as dist/user.properties), display language, and
|
||||
@@ -13,6 +14,9 @@
|
||||
typedef struct {
|
||||
bool configured; /* false until the config page has been submitted once */
|
||||
BirthData birth;
|
||||
Gender gender; /* who the reading is for - only affects Celtic Cross
|
||||
* position text pronouns, see engine/src/tarot.h.
|
||||
* Defaults to GENDER_UNSPECIFIED (0). */
|
||||
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
|
||||
bool notify_enabled;
|
||||
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
|
||||
@@ -28,4 +32,10 @@ void config_load(WatchConfig *out);
|
||||
|
||||
void config_save(const WatchConfig *cfg);
|
||||
|
||||
/* Prints `cfg` via APP_LOG - the same fields a reading is computed from,
|
||||
* so callers can confirm on-device what values a shown reading is
|
||||
* actually based on (there's no way to read them back from the watch
|
||||
* otherwise). Prints "not configured yet" instead if !cfg->configured. */
|
||||
void config_log(const WatchConfig *cfg);
|
||||
|
||||
#endif
|
||||
|
||||
+32
-7
@@ -4,8 +4,9 @@
|
||||
#include "app_message.h"
|
||||
#include "config.h"
|
||||
#include "i18n_tables.h"
|
||||
#include "notify.h"
|
||||
#include "reading_state.h"
|
||||
#include "ui_menu_window.h"
|
||||
#include "ui_report_window.h"
|
||||
#include "ui_text_window.h"
|
||||
|
||||
static WatchConfig s_config;
|
||||
@@ -24,27 +25,51 @@ static void on_config_updated(const WatchConfig *cfg) {
|
||||
|
||||
if (!s_config.configured) return;
|
||||
|
||||
reading_state_recompute(&s_config.birth);
|
||||
config_log(&s_config); /* the values this newly-recomputed reading is based on */
|
||||
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||
notify_reschedule(&s_config); /* settings may have changed notify_enabled/hour/minute */
|
||||
if (was_configured) {
|
||||
menu_window_reload();
|
||||
report_window_reload();
|
||||
} else {
|
||||
menu_window_push();
|
||||
report_window_push();
|
||||
}
|
||||
}
|
||||
|
||||
/* Fires only if the scheduled wakeup goes off while this app is already
|
||||
* open (notify_was_wakeup_launch() below covers the far more common
|
||||
* case: the wakeup launching the app fresh). Refreshes the same way a
|
||||
* new day's config submission would, since the report window is already
|
||||
* on screen and the reading may now be for a new day. */
|
||||
static void on_wakeup(WakeupId wakeup_id, int32_t cookie) {
|
||||
(void)wakeup_id;
|
||||
(void)cookie;
|
||||
vibes_double_pulse();
|
||||
if (!s_config.configured) return;
|
||||
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||
report_window_reload();
|
||||
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm for tomorrow */
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
config_load(&s_config);
|
||||
watch_i18n_load(s_config.lang);
|
||||
app_message_init(on_config_updated);
|
||||
notify_subscribe(on_wakeup);
|
||||
|
||||
config_log(&s_config); /* the values this session's reading (if any) is based on */
|
||||
|
||||
if (s_config.configured) {
|
||||
reading_state_recompute(&s_config.birth);
|
||||
menu_window_push();
|
||||
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||
report_window_push();
|
||||
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm on every launch */
|
||||
if (notify_was_wakeup_launch()) {
|
||||
vibes_double_pulse();
|
||||
}
|
||||
} else {
|
||||
show_setup_required();
|
||||
}
|
||||
|
||||
app_event_loop();
|
||||
|
||||
menu_window_deinit();
|
||||
report_window_deinit();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "notify.h"
|
||||
|
||||
/* Only one kind of wakeup event exists in this app, so the cookie value
|
||||
* itself carries no information - it exists only because
|
||||
* wakeup_schedule()/wakeup_get_launch_event() require one. */
|
||||
#define NOTIFY_WAKEUP_COOKIE 0
|
||||
|
||||
void notify_reschedule(const WatchConfig *cfg) {
|
||||
wakeup_cancel_all();
|
||||
if (!cfg->notify_enabled) return;
|
||||
|
||||
time_t now = time(NULL);
|
||||
struct tm target_tm = *localtime(&now);
|
||||
target_tm.tm_hour = cfg->notify_hour;
|
||||
target_tm.tm_min = cfg->notify_minute;
|
||||
target_tm.tm_sec = 0;
|
||||
|
||||
time_t target = mktime(&target_tm);
|
||||
if (target <= now) target += SECONDS_PER_DAY; /* today's time already passed */
|
||||
|
||||
WakeupId id = wakeup_schedule(target, NOTIFY_WAKEUP_COOKIE, true);
|
||||
if (id < 0) {
|
||||
APP_LOG(APP_LOG_LEVEL_ERROR, "notify: wakeup_schedule failed: %d", (int)id);
|
||||
}
|
||||
}
|
||||
|
||||
bool notify_was_wakeup_launch(void) {
|
||||
return launch_reason() == APP_LAUNCH_WAKEUP;
|
||||
}
|
||||
|
||||
void notify_subscribe(WakeupHandler handler) {
|
||||
wakeup_service_subscribe(handler);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#ifndef WATCH_NOTIFY_H
|
||||
#define WATCH_NOTIFY_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
/* (Re)schedules the daily notification wakeup from `cfg`'s notify_*
|
||||
* fields: cancels any wakeup this app previously scheduled, then - if
|
||||
* cfg->notify_enabled - schedules a new one-shot wakeup_schedule() for
|
||||
* the next occurrence of notify_hour:notify_minute in the watch's own
|
||||
* local time (today if that time hasn't passed yet, tomorrow otherwise).
|
||||
* If !cfg->notify_enabled, this only cancels - no new wakeup is
|
||||
* scheduled.
|
||||
*
|
||||
* Safe to call any time, and needs to be: once per app launch (Pebble's
|
||||
* wakeup_schedule() is one-shot - each firing consumes itself, so the
|
||||
* next day's has to be scheduled again after it fires or after a wakeup
|
||||
* that arrives while the app happens to already be open - see
|
||||
* notify_subscribe()) and on every config submission (settings may have
|
||||
* changed notify_enabled/notify_hour/notify_minute). */
|
||||
void notify_reschedule(const WatchConfig *cfg);
|
||||
|
||||
/* True if this app launch was triggered by the daily notification wakeup
|
||||
* firing rather than the user/phone opening it normally - main.c uses
|
||||
* this to vibrate on launch, since the report window it pushes either
|
||||
* way already leads with day significance and the top transit (the
|
||||
* "quick digest" this notification promises), so no separate screen is
|
||||
* needed for it. */
|
||||
bool notify_was_wakeup_launch(void);
|
||||
|
||||
/* Subscribes `handler` to fire if the scheduled wakeup goes off while
|
||||
* this app is already running in the foreground - the only case
|
||||
* notify_was_wakeup_launch() can't see, since no fresh launch happens
|
||||
* then (see wakeup_service_subscribe()'s own doc comment in pebble.h).
|
||||
* Call once, early in main(). */
|
||||
void notify_subscribe(WakeupHandler handler);
|
||||
|
||||
#endif
|
||||
@@ -5,7 +5,7 @@
|
||||
static DailyReading s_reading;
|
||||
static DailyInterpretation s_interp;
|
||||
|
||||
void reading_state_recompute(const BirthData *birth) {
|
||||
void reading_state_recompute(const BirthData *birth, Gender gender) {
|
||||
time_t now = time(NULL);
|
||||
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
|
||||
* same justification as astro.c's own time_from_unix(). */
|
||||
@@ -14,7 +14,7 @@ void reading_state_recompute(const BirthData *birth) {
|
||||
char seed[16];
|
||||
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||
|
||||
reading_generate(seed, birth, now, &s_reading);
|
||||
reading_generate(seed, birth, gender, now, &s_reading);
|
||||
interpret_daily_reading(&s_reading, &s_interp);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
#include "../../../interpreter/src/significance.h"
|
||||
|
||||
/* Recomputes today's reading (astro + tarot + interpretation) from
|
||||
* `birth`, using the watch's current clock: today's UTC date as the
|
||||
* tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
|
||||
* `birth`/`gender`, using the watch's current clock: today's UTC date as
|
||||
* the tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
|
||||
* "Build & run") and the current moment for the transit snapshot. Safe
|
||||
* to call again later (e.g. the day rolled over while the app stayed
|
||||
* open) - overwrites the previous result in place. */
|
||||
void reading_state_recompute(const BirthData *birth);
|
||||
void reading_state_recompute(const BirthData *birth, Gender gender);
|
||||
|
||||
/* Valid only after at least one reading_state_recompute() call. */
|
||||
const DailyReading *reading_state_get_reading(void);
|
||||
|
||||
@@ -29,3 +29,7 @@ uint32_t card_resource_id(TarotCard card) {
|
||||
}
|
||||
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
|
||||
}
|
||||
|
||||
int16_t card_image_height_for_width(int16_t width) {
|
||||
return (int16_t)((width * 1810 + 543) / 1086); /* +543 = round, not truncate */
|
||||
}
|
||||
|
||||
@@ -15,4 +15,10 @@
|
||||
* place in the app that switches over. */
|
||||
uint32_t card_resource_id(TarotCard card);
|
||||
|
||||
/* Height in pixels of a card image resized to `width` wide, preserving
|
||||
* the source art's fixed 1086x1810 aspect ratio - matches
|
||||
* gen_card_images.py's own resize math, so this stays correct for
|
||||
* whichever per-platform width variant package.json resolves to. */
|
||||
int16_t card_image_height_for_width(int16_t width);
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
#include "ui_card_window.h"
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
#include "resource_map.h"
|
||||
|
||||
/* Matches the fixed size gen_card_images.py resizes every card to. */
|
||||
#define IMAGE_WIDTH 72
|
||||
#define IMAGE_HEIGHT 120
|
||||
|
||||
typedef struct {
|
||||
TarotCard card;
|
||||
char *text; /* title + "\n" + body, combined into one scrollable block */
|
||||
GBitmap *bitmap;
|
||||
BitmapLayer *bitmap_layer;
|
||||
ScrollLayer *scroll_layer;
|
||||
TextLayer *text_layer;
|
||||
} CardWindowState;
|
||||
|
||||
static void window_load(Window *window) {
|
||||
CardWindowState *state = window_get_user_data(window);
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
state->bitmap = gbitmap_create_with_resource(card_resource_id(state->card));
|
||||
int image_x = (bounds.size.w - IMAGE_WIDTH) / 2;
|
||||
state->bitmap_layer = bitmap_layer_create(GRect(image_x, 0, IMAGE_WIDTH, IMAGE_HEIGHT));
|
||||
bitmap_layer_set_bitmap(state->bitmap_layer, state->bitmap);
|
||||
bitmap_layer_set_alignment(state->bitmap_layer, GAlignCenter);
|
||||
layer_add_child(window_layer, bitmap_layer_get_layer(state->bitmap_layer));
|
||||
|
||||
GRect scroll_bounds = GRect(0, IMAGE_HEIGHT, bounds.size.w, bounds.size.h - IMAGE_HEIGHT);
|
||||
state->scroll_layer = scroll_layer_create(scroll_bounds);
|
||||
scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
|
||||
|
||||
state->text_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
|
||||
text_layer_set_font(state->text_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18));
|
||||
text_layer_set_text(state->text_layer, state->text);
|
||||
text_layer_set_overflow_mode(state->text_layer, GTextOverflowModeWordWrap);
|
||||
|
||||
GSize content_size = text_layer_get_content_size(state->text_layer);
|
||||
content_size.w = scroll_bounds.size.w;
|
||||
content_size.h += 4;
|
||||
text_layer_set_size(state->text_layer, content_size);
|
||||
scroll_layer_set_content_size(state->scroll_layer, content_size);
|
||||
|
||||
scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->text_layer));
|
||||
layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
|
||||
}
|
||||
|
||||
static void window_unload(Window *window) {
|
||||
CardWindowState *state = window_get_user_data(window);
|
||||
bitmap_layer_destroy(state->bitmap_layer);
|
||||
gbitmap_destroy(state->bitmap);
|
||||
text_layer_destroy(state->text_layer);
|
||||
scroll_layer_destroy(state->scroll_layer);
|
||||
free(state->text);
|
||||
free(state);
|
||||
window_destroy(window);
|
||||
}
|
||||
|
||||
void card_window_push(TarotCard card, const char *title, const char *body) {
|
||||
CardWindowState *state = malloc(sizeof(CardWindowState));
|
||||
memset(state, 0, sizeof *state);
|
||||
state->card = card;
|
||||
|
||||
size_t len = strlen(title) + 1 + strlen(body) + 1;
|
||||
state->text = malloc(len);
|
||||
snprintf(state->text, len, "%s\n%s", title, body);
|
||||
|
||||
Window *window = window_create();
|
||||
window_set_user_data(window, state);
|
||||
window_set_window_handlers(window, (WindowHandlers){
|
||||
.load = window_load,
|
||||
.unload = window_unload,
|
||||
});
|
||||
window_stack_push(window, true);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#ifndef WATCH_UI_CARD_WINDOW_H
|
||||
#define WATCH_UI_CARD_WINDOW_H
|
||||
|
||||
#include "../../../engine/src/tarot.h"
|
||||
|
||||
/* Pushes a window showing `card`'s art (via resource_map.h) pinned at
|
||||
* the top, with `title` and `body` below it as scrollable text (e.g. the
|
||||
* Celtic Cross position name + orientation, and the position description
|
||||
* + card meaning). Copies both strings, same as text_window_push(). */
|
||||
void card_window_push(TarotCard card, const char *title, const char *body);
|
||||
|
||||
#endif
|
||||
@@ -1,191 +0,0 @@
|
||||
#include "ui_menu_window.h"
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
#include "../../../engine/src/i18n.h"
|
||||
#include "../../../interpreter/src/guidance.h"
|
||||
#include "../../../interpreter/src/narrative.h"
|
||||
#include "reading_state.h"
|
||||
#include "ui_card_window.h"
|
||||
#include "ui_text_window.h"
|
||||
|
||||
typedef enum {
|
||||
SECTION_TODAY = 0,
|
||||
SECTION_TRANSITS,
|
||||
SECTION_CELTIC_CROSS,
|
||||
NUM_SECTIONS
|
||||
} MenuSection;
|
||||
|
||||
static Window *s_window;
|
||||
static MenuLayer *s_menu_layer;
|
||||
|
||||
/* Small local day-level name table, same duplication policy as
|
||||
* interpreter/src/main.c's own day_level_name() - see significance.c's
|
||||
* comment on why each independently-testable/linkable module keeps its
|
||||
* own copy rather than sharing one across translation units that aren't
|
||||
* otherwise coupled. */
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
static const char *reversed_marker(bool reversed) {
|
||||
return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
|
||||
}
|
||||
|
||||
static uint16_t get_num_sections_callback(MenuLayer *menu_layer, void *data) {
|
||||
return NUM_SECTIONS;
|
||||
}
|
||||
|
||||
static uint16_t get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) {
|
||||
const DailyInterpretation *interp = reading_state_get_interpretation();
|
||||
switch (section_index) {
|
||||
case SECTION_TODAY: return 1;
|
||||
case SECTION_TRANSITS: return interp->top_item_count > 0 ? interp->top_item_count : 1;
|
||||
case SECTION_CELTIC_CROSS: return TAROT_SPREAD_SIZE;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static int16_t get_header_height_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) {
|
||||
return MENU_CELL_BASIC_HEADER_HEIGHT;
|
||||
}
|
||||
|
||||
static void draw_header_callback(GContext *ctx, const Layer *cell_layer, uint16_t section_index, void *data) {
|
||||
const char *title = "";
|
||||
switch (section_index) {
|
||||
case SECTION_TODAY: title = i18n_get("interp.heading_day_significance", "Day Significance"); break;
|
||||
case SECTION_TRANSITS: title = i18n_get("interp.heading_transits", "Significant Transits"); break;
|
||||
case SECTION_CELTIC_CROSS: title = i18n_get("ui.celtic_cross", "Celtic Cross"); break;
|
||||
}
|
||||
menu_cell_basic_header_draw(ctx, cell_layer, title);
|
||||
}
|
||||
|
||||
static void draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *data) {
|
||||
const DailyReading *reading = reading_state_get_reading();
|
||||
const DailyInterpretation *interp = reading_state_get_interpretation();
|
||||
|
||||
char title[48];
|
||||
char subtitle[64];
|
||||
|
||||
switch (cell_index->section) {
|
||||
case SECTION_TODAY:
|
||||
snprintf(title, sizeof title, "%s (%d/%d)", day_level_name(interp->day_level),
|
||||
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||
menu_cell_basic_draw(ctx, cell_layer, title, NULL, NULL);
|
||||
break;
|
||||
|
||||
case SECTION_TRANSITS:
|
||||
if (interp->top_item_count == 0) {
|
||||
menu_cell_basic_draw(ctx, cell_layer,
|
||||
i18n_get("interp.no_notable_transits", "No notable transits today."),
|
||||
NULL, NULL);
|
||||
break;
|
||||
}
|
||||
{
|
||||
const Aspect *a = &interp->top_items[cell_index->row].aspect;
|
||||
snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"),
|
||||
astro_body_name(a->transiting_planet));
|
||||
snprintf(subtitle, sizeof subtitle, "%s %s %s", astro_aspect_name(a->type),
|
||||
i18n_get("ui.natal", "natal"), astro_body_name(a->natal_planet));
|
||||
menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL);
|
||||
}
|
||||
break;
|
||||
|
||||
case SECTION_CELTIC_CROSS: {
|
||||
const TarotDraw *draw = &reading->spread.positions[cell_index->row];
|
||||
snprintf(title, sizeof title, "%s", tarot_position_name((CelticCrossPosition)cell_index->row));
|
||||
snprintf(subtitle, sizeof subtitle, "%s %s", tarot_card_name(draw->card),
|
||||
reversed_marker(draw->reversed));
|
||||
menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *data) {
|
||||
const DailyReading *reading = reading_state_get_reading();
|
||||
const DailyInterpretation *interp = reading_state_get_interpretation();
|
||||
|
||||
switch (cell_index->section) {
|
||||
case SECTION_TODAY: {
|
||||
char body[GUIDANCE_TEXT_MAX];
|
||||
guidance_write(body, sizeof body, "", interp, &reading->spread);
|
||||
text_window_push(i18n_get("interp.heading_day_significance", "Day Significance"), body);
|
||||
break;
|
||||
}
|
||||
|
||||
case SECTION_TRANSITS: {
|
||||
if (interp->top_item_count == 0) break;
|
||||
const Aspect *a = &interp->top_items[cell_index->row].aspect;
|
||||
char title[48];
|
||||
snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"),
|
||||
astro_body_name(a->transiting_planet));
|
||||
char body[NARRATIVE_TEXT_MAX];
|
||||
narrative_write(body, sizeof body, "", a, reading->transits.bodies[a->transiting_planet].house);
|
||||
text_window_push(title, body);
|
||||
break;
|
||||
}
|
||||
|
||||
case SECTION_CELTIC_CROSS: {
|
||||
const TarotDraw *draw = &reading->spread.positions[cell_index->row];
|
||||
char title[64];
|
||||
snprintf(title, sizeof title, "%s: %s %s",
|
||||
tarot_position_name((CelticCrossPosition)cell_index->row),
|
||||
tarot_card_name(draw->card), reversed_marker(draw->reversed));
|
||||
char body[600];
|
||||
snprintf(body, sizeof body, "%s\n\n%s",
|
||||
tarot_position_description((CelticCrossPosition)cell_index->row),
|
||||
tarot_card_meaning(draw->card, draw->reversed));
|
||||
card_window_push(draw->card, title, body);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void window_load(Window *window) {
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
s_menu_layer = menu_layer_create(bounds);
|
||||
menu_layer_set_callbacks(s_menu_layer, NULL, (MenuLayerCallbacks){
|
||||
.get_num_sections = get_num_sections_callback,
|
||||
.get_num_rows = get_num_rows_callback,
|
||||
.get_header_height = get_header_height_callback,
|
||||
.draw_header = draw_header_callback,
|
||||
.draw_row = draw_row_callback,
|
||||
.select_click = select_callback,
|
||||
});
|
||||
menu_layer_set_click_config_onto_window(s_menu_layer, window);
|
||||
layer_add_child(window_layer, menu_layer_get_layer(s_menu_layer));
|
||||
}
|
||||
|
||||
static void window_unload(Window *window) {
|
||||
menu_layer_destroy(s_menu_layer);
|
||||
s_menu_layer = NULL;
|
||||
}
|
||||
|
||||
void menu_window_push(void) {
|
||||
if (!s_window) {
|
||||
s_window = window_create();
|
||||
window_set_window_handlers(s_window, (WindowHandlers){
|
||||
.load = window_load,
|
||||
.unload = window_unload,
|
||||
});
|
||||
}
|
||||
window_stack_push(s_window, true);
|
||||
}
|
||||
|
||||
void menu_window_reload(void) {
|
||||
if (s_menu_layer) menu_layer_reload_data(s_menu_layer);
|
||||
}
|
||||
|
||||
void menu_window_deinit(void) {
|
||||
if (s_window) window_destroy(s_window);
|
||||
s_window = NULL;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
#ifndef WATCH_UI_MENU_WINDOW_H
|
||||
#define WATCH_UI_MENU_WINDOW_H
|
||||
|
||||
/* The app's root screen: a 3-section MenuLayer (Day Significance /
|
||||
* Significant Transits / Celtic Cross) reading directly from
|
||||
* reading_state.h's current reading - see reading.h's own doc comment
|
||||
* ("walks the returned struct directly to lay out its own screens").
|
||||
* Selecting a row pushes a detail screen (ui_text_window.h for prose,
|
||||
* ui_card_window.h for a Celtic Cross position). A persistent singleton -
|
||||
* created once and left at the bottom of the window stack; call
|
||||
* menu_window_push() once at startup (and again if the day rolls over
|
||||
* and the underlying reading changes - the MenuLayer re-reads
|
||||
* reading_state.h on every redraw, so no explicit refresh call is
|
||||
* needed beyond menu_layer_reload_data()). */
|
||||
void menu_window_push(void);
|
||||
void menu_window_reload(void);
|
||||
void menu_window_deinit(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,331 @@
|
||||
#include "ui_report_window.h"
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
#include "../../../engine/src/i18n.h"
|
||||
#include "../../../interpreter/src/guidance.h"
|
||||
#include "../../../interpreter/src/narrative.h"
|
||||
#include "reading_state.h"
|
||||
#include "resource_map.h"
|
||||
|
||||
#define MARGIN 6
|
||||
|
||||
/* Matches gen_card_images.py's own CARD_WIDTH - a hardware-verified
|
||||
* ceiling, not a design choice: full-display-width images reliably
|
||||
* crashed the real PNG decoder ("PNG memory allocation failed", a
|
||||
* firmware-level constraint independent of this app's own heap budget,
|
||||
* confirmed unaffected by lazy-loading only one image at a time). */
|
||||
#define IMAGE_WIDTH 72
|
||||
|
||||
/* fonts_get_system_font() needs Pebble's runtime initialized, so these
|
||||
* are resolved once in window_load() rather than at static-init time. */
|
||||
static GFont s_font_section;
|
||||
static GFont s_font_subhead;
|
||||
static GFont s_font_body;
|
||||
|
||||
/* Static buffers for every dynamically-built string this window shows -
|
||||
* tarot_position_description()/tarot_card_meaning()/i18n_get() fallback
|
||||
* text all point straight at flash/ROM data (see i18n.h's own comment on
|
||||
* i18n_load_table()), so only the pieces actually assembled at runtime
|
||||
* (via snprintf/narrative_write/guidance_write) need their own storage.
|
||||
* Static rather than malloc'd - fixed, known-in-advance sizes, and it
|
||||
* keeps report_window_reload() from having to reason about freeing the
|
||||
* previous run's buffers before reusing them. */
|
||||
static char s_reading_date[48];
|
||||
static char s_day_title[64];
|
||||
static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96];
|
||||
static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX];
|
||||
static char s_position_titles[TAROT_SPREAD_SIZE][96];
|
||||
static char s_guidance[GUIDANCE_TEXT_MAX];
|
||||
|
||||
/* Loads/frees each position's GBitmap as scroll_offset_changed() sees it
|
||||
* enter/leave the visible viewport, rather than decoding all ten Celtic
|
||||
* Cross images up front - a defensive habit left over from an earlier,
|
||||
* larger IMAGE_WIDTH (see that constant's own comment); at 72px this
|
||||
* struct's images are small enough that it's no longer strictly
|
||||
* necessary, but it's harmless to keep. */
|
||||
typedef struct {
|
||||
TarotCard card;
|
||||
int16_t y;
|
||||
int16_t height;
|
||||
BitmapLayer *bitmap_layer;
|
||||
GBitmap *bitmap;
|
||||
} ImageSlot;
|
||||
|
||||
static ImageSlot s_slots[TAROT_SPREAD_SIZE];
|
||||
|
||||
/* Attitude (0) and Outcome (1) preview slots, drawn at half screen
|
||||
* width each so the two sit side by side - the BitmapLayer's own bounds
|
||||
* (narrower than IMAGE_WIDTH on 144px-wide platforms) clip the centered
|
||||
* image automatically, same as GAlignCenter already does for the
|
||||
* full-width slots above, no separate scaling logic needed. */
|
||||
#define REPEAT_SLOT_COUNT 2
|
||||
static ImageSlot s_repeat_slots[REPEAT_SLOT_COUNT];
|
||||
|
||||
#define MAX_TEXT_LAYERS 50
|
||||
static TextLayer *s_text_layers[MAX_TEXT_LAYERS];
|
||||
static int s_text_layer_count;
|
||||
|
||||
static Window *s_window;
|
||||
static ScrollLayer *s_scroll_layer;
|
||||
static int16_t s_screen_height;
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
static const char *reversed_marker(bool reversed) {
|
||||
return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
|
||||
}
|
||||
|
||||
static int16_t add_text_at(int16_t x, int16_t width, int16_t y, const char *text, GFont font) {
|
||||
TextLayer *tl = text_layer_create(GRect(x, y, width, 4000));
|
||||
text_layer_set_font(tl, font);
|
||||
text_layer_set_text(tl, text);
|
||||
text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap);
|
||||
|
||||
GSize size = text_layer_get_content_size(tl);
|
||||
size.w = width;
|
||||
size.h += 4;
|
||||
text_layer_set_size(tl, size);
|
||||
|
||||
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(tl));
|
||||
s_text_layers[s_text_layer_count++] = tl;
|
||||
return y + size.h + MARGIN;
|
||||
}
|
||||
|
||||
static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
|
||||
return add_text_at(0, width, y, text, font);
|
||||
}
|
||||
|
||||
static void teardown_content(void) {
|
||||
for (int i = 0; i < s_text_layer_count; i++) {
|
||||
text_layer_destroy(s_text_layers[i]);
|
||||
}
|
||||
s_text_layer_count = 0;
|
||||
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
if (s_slots[i].bitmap) {
|
||||
gbitmap_destroy(s_slots[i].bitmap);
|
||||
s_slots[i].bitmap = NULL;
|
||||
}
|
||||
if (s_slots[i].bitmap_layer) {
|
||||
bitmap_layer_destroy(s_slots[i].bitmap_layer);
|
||||
s_slots[i].bitmap_layer = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
|
||||
if (s_repeat_slots[i].bitmap) {
|
||||
gbitmap_destroy(s_repeat_slots[i].bitmap);
|
||||
s_repeat_slots[i].bitmap = NULL;
|
||||
}
|
||||
if (s_repeat_slots[i].bitmap_layer) {
|
||||
bitmap_layer_destroy(s_repeat_slots[i].bitmap_layer);
|
||||
s_repeat_slots[i].bitmap_layer = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void update_slot_visibility(ImageSlot *slot, int16_t visible_top, int16_t visible_bottom) {
|
||||
bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top;
|
||||
|
||||
if (visible && !slot->bitmap) {
|
||||
slot->bitmap = gbitmap_create_with_resource(card_resource_id(slot->card));
|
||||
bitmap_layer_set_bitmap(slot->bitmap_layer, slot->bitmap);
|
||||
} else if (!visible && slot->bitmap) {
|
||||
bitmap_layer_set_bitmap(slot->bitmap_layer, NULL);
|
||||
gbitmap_destroy(slot->bitmap);
|
||||
slot->bitmap = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static void update_visible_images(void) {
|
||||
GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
|
||||
int16_t visible_top = -offset.y;
|
||||
int16_t visible_bottom = visible_top + s_screen_height;
|
||||
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
update_slot_visibility(&s_slots[i], visible_top, visible_bottom);
|
||||
}
|
||||
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
|
||||
update_slot_visibility(&s_repeat_slots[i], visible_top, visible_bottom);
|
||||
}
|
||||
}
|
||||
|
||||
static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) {
|
||||
update_visible_images();
|
||||
}
|
||||
|
||||
static void build_content(int16_t width) {
|
||||
const DailyReading *reading = reading_state_get_reading();
|
||||
const DailyInterpretation *interp = reading_state_get_interpretation();
|
||||
|
||||
int16_t y = MARGIN;
|
||||
|
||||
/* The day this reading is for (reading->utc_moment, set by
|
||||
* reading_generate() - see reading_state_recompute()), not necessarily
|
||||
* "today" if the report is viewed right at a UTC-midnight boundary
|
||||
* before the next recompute. Plain gmtime(), same justification as
|
||||
* reading_state.c's own use of it. */
|
||||
struct tm *utc = gmtime(&reading->utc_moment);
|
||||
snprintf(s_reading_date, sizeof s_reading_date, "%s %04d-%02d-%02d",
|
||||
i18n_get("interp.reading_date", "Reading for:"), utc->tm_year + 1900,
|
||||
utc->tm_mon + 1, utc->tm_mday);
|
||||
y = add_text(width, y, s_reading_date, s_font_body);
|
||||
|
||||
y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section);
|
||||
snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level),
|
||||
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||
y = add_text(width, y, s_day_title, s_font_body);
|
||||
|
||||
/* Build every transit's title + narrative up front (used both for the
|
||||
* repeated top-item line below and the full list further down), so
|
||||
* neither buffer is computed twice for item 0. */
|
||||
if (interp->top_item_count > 0) {
|
||||
for (int i = 0; i < interp->top_item_count; i++) {
|
||||
const Aspect *a = &interp->top_items[i].aspect;
|
||||
snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s",
|
||||
i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet),
|
||||
astro_aspect_name(a->type), i18n_get("ui.natal", "natal"),
|
||||
astro_body_name(a->natal_planet));
|
||||
narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
|
||||
reading->transits.bodies[a->transiting_planet].house);
|
||||
}
|
||||
}
|
||||
|
||||
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
|
||||
|
||||
/* Attitude/Outcome preview, side by side, image only - no per-card
|
||||
* caption text. A "Position: Card Name (Reversed)" label doesn't fit
|
||||
* a half-width column without wrapping into 3+ short lines, and with
|
||||
* both columns wrapping independently the reader ends up reading
|
||||
* fragments across columns rather than down one column at a time
|
||||
* (confirmed on-device: it reads as one garbled interleaved sentence,
|
||||
* not two). Captions are unnecessary anyway - the guidance text
|
||||
* printed immediately below already names and quotes both cards in
|
||||
* full ("Deine Haltungskarte ist ..." / "Das wahrscheinliche Ergebnis
|
||||
* ... ist ..."), and both reappear with their full position/name/
|
||||
* meaning again in the Celtic Cross walkthrough further down. */
|
||||
{
|
||||
int16_t half_w = width / 2;
|
||||
const TarotDraw *attitude = &reading->spread.positions[POSITION_ATTITUDE];
|
||||
const TarotDraw *outcome = &reading->spread.positions[POSITION_OUTCOME];
|
||||
int16_t img_height = card_image_height_for_width(IMAGE_WIDTH);
|
||||
|
||||
s_repeat_slots[0].card = attitude->card;
|
||||
s_repeat_slots[0].y = y;
|
||||
s_repeat_slots[0].height = img_height;
|
||||
s_repeat_slots[0].bitmap_layer = bitmap_layer_create(GRect(0, y, half_w, img_height));
|
||||
bitmap_layer_set_alignment(s_repeat_slots[0].bitmap_layer, GAlignCenter);
|
||||
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[0].bitmap_layer));
|
||||
|
||||
s_repeat_slots[1].card = outcome->card;
|
||||
s_repeat_slots[1].y = y;
|
||||
s_repeat_slots[1].height = img_height;
|
||||
s_repeat_slots[1].bitmap_layer =
|
||||
bitmap_layer_create(GRect(half_w, y, width - half_w, img_height));
|
||||
bitmap_layer_set_alignment(s_repeat_slots[1].bitmap_layer, GAlignCenter);
|
||||
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[1].bitmap_layer));
|
||||
|
||||
y += img_height + MARGIN;
|
||||
}
|
||||
|
||||
if (interp->top_item_count > 0) {
|
||||
y = add_text(width, y, s_transit_titles[0], s_font_subhead);
|
||||
y = add_text(width, y, s_narratives[0], s_font_body);
|
||||
}
|
||||
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
|
||||
y = add_text(width, y, s_guidance, s_font_body);
|
||||
|
||||
y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section);
|
||||
if (interp->top_item_count == 0) {
|
||||
y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body);
|
||||
} else {
|
||||
for (int i = 0; i < interp->top_item_count; i++) {
|
||||
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
|
||||
y = add_text(width, y, s_narratives[i], s_font_body);
|
||||
}
|
||||
}
|
||||
|
||||
y = add_text(width, y, i18n_get("ui.celtic_cross", "Celtic Cross"), s_font_section);
|
||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||
const TarotDraw *draw = &reading->spread.positions[i];
|
||||
|
||||
snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s",
|
||||
tarot_position_name((CelticCrossPosition)i, reading->gender), tarot_card_name(draw->card),
|
||||
reversed_marker(draw->reversed));
|
||||
y = add_text(width, y, s_position_titles[i], s_font_subhead);
|
||||
|
||||
s_slots[i].card = draw->card;
|
||||
s_slots[i].y = y;
|
||||
s_slots[i].height = card_image_height_for_width(IMAGE_WIDTH);
|
||||
int16_t image_x = (width - IMAGE_WIDTH) / 2;
|
||||
s_slots[i].bitmap_layer = bitmap_layer_create(GRect(image_x, y, IMAGE_WIDTH, s_slots[i].height));
|
||||
bitmap_layer_set_alignment(s_slots[i].bitmap_layer, GAlignCenter);
|
||||
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer));
|
||||
y += s_slots[i].height + MARGIN;
|
||||
|
||||
y = add_text(width, y, tarot_position_description((CelticCrossPosition)i, reading->gender), s_font_body);
|
||||
y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body);
|
||||
}
|
||||
|
||||
scroll_layer_set_content_size(s_scroll_layer, GSize(width, y));
|
||||
}
|
||||
|
||||
static void window_load(Window *window) {
|
||||
s_font_section = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD);
|
||||
s_font_subhead = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
|
||||
s_font_body = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
|
||||
|
||||
Layer *window_layer = window_get_root_layer(window);
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
s_screen_height = bounds.size.h;
|
||||
|
||||
s_scroll_layer = scroll_layer_create(bounds);
|
||||
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
|
||||
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks){
|
||||
.content_offset_changed_handler = scroll_offset_changed,
|
||||
});
|
||||
layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
|
||||
|
||||
build_content(bounds.size.w);
|
||||
update_visible_images();
|
||||
}
|
||||
|
||||
static void window_unload(Window *window) {
|
||||
teardown_content();
|
||||
scroll_layer_destroy(s_scroll_layer);
|
||||
s_scroll_layer = NULL;
|
||||
}
|
||||
|
||||
void report_window_push(void) {
|
||||
if (!s_window) {
|
||||
s_window = window_create();
|
||||
window_set_window_handlers(s_window, (WindowHandlers){
|
||||
.load = window_load,
|
||||
.unload = window_unload,
|
||||
});
|
||||
}
|
||||
window_stack_push(s_window, true);
|
||||
}
|
||||
|
||||
void report_window_reload(void) {
|
||||
if (!s_window || !s_scroll_layer) return;
|
||||
Layer *window_layer = window_get_root_layer(s_window);
|
||||
int16_t width = layer_get_bounds(window_layer).size.w;
|
||||
teardown_content();
|
||||
build_content(width);
|
||||
update_visible_images();
|
||||
}
|
||||
|
||||
void report_window_deinit(void) {
|
||||
if (s_window) window_destroy(s_window);
|
||||
s_window = NULL;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#ifndef WATCH_UI_REPORT_WINDOW_H
|
||||
#define WATCH_UI_REPORT_WINDOW_H
|
||||
|
||||
/* The app's only real screen: one continuously scrollable page mirroring
|
||||
* the interpreter's own --format text/html report - day significance,
|
||||
* each significant transit's narrative, the full Celtic Cross spread
|
||||
* (each position's card art at the watch's own display width, name,
|
||||
* description, and meaning), and the guidance paragraph last - see
|
||||
* reading_state.h for where all of this data comes from.
|
||||
*
|
||||
* Card art is loaded lazily as it scrolls into view and freed once it
|
||||
* scrolls back out - a full-display-width card image is too large to
|
||||
* keep all ten resident at once on this app's RAM budget (see
|
||||
* ui_report_window.c's own comment) - so unlike a plain ScrollLayer of
|
||||
* static content, this needs report_window_push()'s window to actually
|
||||
* be on-screen to drive that loading; call report_window_reload() (not
|
||||
* push() again) to refresh already-built content after the day rolls
|
||||
* over or new birth data is submitted. */
|
||||
void report_window_push(void);
|
||||
void report_window_reload(void);
|
||||
void report_window_deinit(void);
|
||||
|
||||
#endif
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include <pebble.h>
|
||||
|
||||
#define TITLE_HEIGHT 24
|
||||
#define TITLE_HEIGHT 30
|
||||
|
||||
typedef struct {
|
||||
char *title;
|
||||
@@ -18,7 +18,7 @@ static void window_load(Window *window) {
|
||||
GRect bounds = layer_get_bounds(window_layer);
|
||||
|
||||
state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT));
|
||||
text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD));
|
||||
text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
|
||||
text_layer_set_text(state->title_layer, state->title);
|
||||
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
|
||||
layer_add_child(window_layer, text_layer_get_layer(state->title_layer));
|
||||
@@ -31,7 +31,7 @@ static void window_load(Window *window) {
|
||||
* measure the real wrapped height - standard Pebble ScrollLayer
|
||||
* pattern (a TextLayer taller than its content just clips nothing). */
|
||||
state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
|
||||
text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18));
|
||||
text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
|
||||
text_layer_set_text(state->body_layer, state->body);
|
||||
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
|
||||
|
||||
|
||||
+256
-5
File diff suppressed because one or more lines are too long
+15
-9
@@ -61,17 +61,23 @@ def configure(ctx):
|
||||
|
||||
|
||||
def build(ctx):
|
||||
ctx.load('pebble_sdk')
|
||||
|
||||
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang, and
|
||||
# resources/img/*.png from res/img/*.jpeg, before compiling/bundling
|
||||
# anything - see each script's own doc comment. Both run eagerly (not
|
||||
# as waf Tasks) since the bundle step below depends on their output
|
||||
# existing; gen_card_images.py skips any card whose PNG is already
|
||||
# up to date, so this stays cheap on repeat builds. Requires Pillow
|
||||
# (`pip install Pillow`) - see watch/README.md.
|
||||
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang,
|
||||
# resources/img/*.png from res/img/*.jpeg, and resources/app_icon.png
|
||||
# from res/app_icon_50x50_64_color.png, before compiling/bundling
|
||||
# anything - see each script's own doc comment. All three run eagerly
|
||||
# (not as waf Tasks) and must run *before* ctx.load('pebble_sdk')
|
||||
# below, since loading that tool in a build context immediately packs
|
||||
# resources/ into the app bundle - on a fresh checkout with no
|
||||
# generated resources/img/ yet, that packing step would crash before
|
||||
# these scripts ever got a chance to create it. gen_card_images.py/
|
||||
# gen_app_icon.py both skip regenerating anything already up to date,
|
||||
# so this stays cheap on repeat builds. Requires Pillow (`pip install
|
||||
# Pillow`) - see watch/README.md.
|
||||
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
|
||||
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
|
||||
subprocess.check_call([sys.executable, 'scripts/gen_app_icon.py'])
|
||||
|
||||
ctx.load('pebble_sdk')
|
||||
|
||||
engine_dir = ctx.path.parent.find_dir('engine/src')
|
||||
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]
|
||||
|
||||
Reference in New Issue
Block a user