mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0efc56312 | ||
|
|
93b597302a | ||
|
|
dde50192c7 | ||
|
|
5f26563334 | ||
|
|
33441113ce | ||
|
|
464e37c35b | ||
|
|
364dd0afdf | ||
|
|
f68f8a7c02 | ||
|
|
899d9d9b00 | ||
|
|
69907eb186 | ||
|
|
d90d4c171f |
@@ -46,9 +46,27 @@ jobs:
|
|||||||
# The binding code only exists under the feature, so a bare clippy
|
# The binding code only exists under the feature, so a bare clippy
|
||||||
# run never looks at src/python.rs.
|
# run never looks at src/python.rs.
|
||||||
cargo clippy --features python --all-targets -- -D warnings
|
cargo clippy --features python --all-targets -- -D warnings
|
||||||
|
cargo clippy --features capi --all-targets -- -D warnings
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
cargo clippy --lib --features wasm --target wasm32-unknown-unknown -- -D warnings
|
||||||
|
|
||||||
- name: Rust tests
|
- name: Rust tests
|
||||||
run: cargo test
|
run: |
|
||||||
|
cargo test
|
||||||
|
cargo test --features capi
|
||||||
|
|
||||||
|
# The header and the LuaJIT cdef are checked against the library by the
|
||||||
|
# Rust tests; this checks that a C compiler agrees, which they cannot.
|
||||||
|
- name: C ABI
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends gcc luajit
|
||||||
|
cargo build --release --features capi
|
||||||
|
cc -Wall -Wextra -Werror -Iinclude examples/c/demo.c \
|
||||||
|
-Ltarget/release -ltypikon -o /tmp/demo
|
||||||
|
LD_LIBRARY_PATH=target/release /tmp/demo rocor
|
||||||
|
LD_LIBRARY_PATH=target/release /tmp/demo --list
|
||||||
|
TYPIKON_LIB=target/release/libtypikon.so \
|
||||||
|
luajit examples/lua/statusline.lua rocor
|
||||||
|
|
||||||
# The CLI and the bindings are the same engine, so they have to agree. This
|
# The CLI and the bindings are the same engine, so they have to agree. This
|
||||||
# is the check that caught the bindings resolving their own default
|
# is the check that caught the bindings resolving their own default
|
||||||
@@ -81,7 +99,38 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
/tmp/venv/bin/python -m mypy --strict /tmp/typecheck.py
|
/tmp/venv/bin/python -m mypy --strict /tmp/typecheck.py
|
||||||
|
|
||||||
|
# The npm package is the same engine again, so it has to agree with the
|
||||||
|
# command line again. The test compares a year of days field by field
|
||||||
|
# for four jurisdictions, and type-checks the published .d.ts the way the
|
||||||
|
# Python bindings are type-checked.
|
||||||
|
- name: JavaScript package
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends nodejs npm
|
||||||
|
version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock)
|
||||||
|
curl -sSfL "https://github.com/rustwasm/wasm-bindgen/releases/download/$version/wasm-bindgen-$version-x86_64-unknown-linux-musl.tar.gz" \
|
||||||
|
| tar xz -C /tmp
|
||||||
|
export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH"
|
||||||
|
npm install --global typescript@5
|
||||||
|
cargo build --release
|
||||||
|
npm/build.sh
|
||||||
|
node npm/test.mjs
|
||||||
|
|
||||||
- name: README examples
|
- name: README examples
|
||||||
run: |
|
run: |
|
||||||
cargo build --release
|
cargo build --release
|
||||||
python3 tests/readme-examples.py
|
python3 tests/readme-examples.py
|
||||||
|
|
||||||
|
# Every binding is the same engine reached by a different door, so the
|
||||||
|
# answers have to be identical, and the only way to know is to ask them
|
||||||
|
# all the same question. Twelve days chosen where they could plausibly
|
||||||
|
# differ, twenty-two fields each, against what the command line says.
|
||||||
|
#
|
||||||
|
# Chromium is installed for this: the web build is the one artifact whose
|
||||||
|
# real home is somewhere Node cannot stand in for, and loading it with
|
||||||
|
# Node's bytes proves the glue, not the fetch.
|
||||||
|
- name: Every binding agrees
|
||||||
|
run: |
|
||||||
|
apt-get install -y --no-install-recommends chromium
|
||||||
|
version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock)
|
||||||
|
export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH"
|
||||||
|
tests/parity.sh
|
||||||
|
|||||||
@@ -32,8 +32,20 @@ jobs:
|
|||||||
echo "tag=$tag crate=$crate"
|
echo "tag=$tag crate=$crate"
|
||||||
[ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; }
|
[ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; }
|
||||||
|
|
||||||
|
# What CI runs on every push, run again here. A release is the one build
|
||||||
|
# nobody gets to re-run after the fact: PyPI will not reuse a version and
|
||||||
|
# neither will npm, so the gate belongs before the upload rather than
|
||||||
|
# only on the branch that led to it.
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: cargo test
|
run: |
|
||||||
|
rustup component add clippy
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo clippy --features python --all-targets -- -D warnings
|
||||||
|
cargo clippy --features capi --all-targets -- -D warnings
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
cargo clippy --lib --features wasm --target wasm32-unknown-unknown -- -D warnings
|
||||||
|
cargo test
|
||||||
|
cargo test --features capi
|
||||||
|
|
||||||
- name: Build wheels and sdist
|
- name: Build wheels and sdist
|
||||||
run: |
|
run: |
|
||||||
@@ -59,6 +71,34 @@ jobs:
|
|||||||
maturin sdist --out dist
|
maturin sdist --out dist
|
||||||
ls -l dist
|
ls -l dist
|
||||||
|
|
||||||
|
- name: Build the npm package
|
||||||
|
run: |
|
||||||
|
# Node from nodesource rather than apt. `npm stage` below wants npm
|
||||||
|
# 11.15 and Node 22.14, and bookworm ships Node 18.
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -y --no-install-recommends nodejs
|
||||||
|
npm install --global npm@^11.15
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock)
|
||||||
|
curl -sSfL "https://github.com/rustwasm/wasm-bindgen/releases/download/$version/wasm-bindgen-$version-x86_64-unknown-linux-musl.tar.gz" \
|
||||||
|
| tar xz -C /tmp
|
||||||
|
export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH"
|
||||||
|
npm install --global typescript@5
|
||||||
|
cargo build --release
|
||||||
|
npm/build.sh
|
||||||
|
node npm/test.mjs
|
||||||
|
|
||||||
|
# The whole point of a release is that every door leads to the same
|
||||||
|
# engine. Checked here against what is about to be uploaded, and again
|
||||||
|
# after the fact with --published against what actually was.
|
||||||
|
- name: Every binding agrees
|
||||||
|
run: |
|
||||||
|
apt-get install -y --no-install-recommends chromium luajit
|
||||||
|
version=$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock)
|
||||||
|
export PATH="/tmp/wasm-bindgen-$version-x86_64-unknown-linux-musl:$PATH"
|
||||||
|
tests/parity.sh
|
||||||
|
python3 tests/readme-examples.py
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
env:
|
env:
|
||||||
# A project-scoped token, not the account-wide one. PyPI's trusted
|
# A project-scoped token, not the account-wide one. PyPI's trusted
|
||||||
@@ -77,3 +117,29 @@ jobs:
|
|||||||
# instead of dying on the first file. A version cannot be reused,
|
# instead of dying on the first file. A version cannot be reused,
|
||||||
# so a half-published release would otherwise burn it.
|
# so a half-published release would otherwise burn it.
|
||||||
/tmp/venv/bin/twine upload --non-interactive --skip-existing dist/*
|
/tmp/venv/bin/twine upload --non-interactive --skip-existing dist/*
|
||||||
|
|
||||||
|
# Staged, not published. npm is withdrawing direct publish from any token
|
||||||
|
# that bypasses 2FA, and its trusted publishing is OIDC against GitHub
|
||||||
|
# Actions and GitLab, which this forge is neither. So the runner does the
|
||||||
|
# part a token may do and stages the tarball, and a maintainer approves it
|
||||||
|
# with 2FA: `npm stage list`, then `npm stage approve <id>`, or the Staged
|
||||||
|
# Packages tab on npmjs.com. Nothing reaches the registry until then.
|
||||||
|
- name: Stage the npm release
|
||||||
|
env:
|
||||||
|
# Granular, write on typikon alone, and it does not need to bypass
|
||||||
|
# 2FA, because staging is exactly what a token that cannot bypass it
|
||||||
|
# is still allowed to do.
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$NPM_TOKEN" ]; then
|
||||||
|
echo "::warning::NPM_TOKEN is not set; nothing staged on npm"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc
|
||||||
|
version="${GITHUB_REF_NAME#v}"
|
||||||
|
if npm view "typikon@$version" version >/dev/null 2>&1; then
|
||||||
|
echo "::notice::typikon@$version is already published"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
cd npm/dist && npm stage publish --access public
|
||||||
|
echo "::notice::staged typikon@$version; approve it with 2FA to publish"
|
||||||
|
|||||||
@@ -5,3 +5,5 @@
|
|||||||
/dist
|
/dist
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
/npm/dist
|
||||||
|
node_modules/
|
||||||
|
|||||||
+129
@@ -4,6 +4,135 @@ Notable changes, in the terms [docs/versioning.md](docs/versioning.md) sets out.
|
|||||||
**Calendar** entries change what a day reports, whatever the version number
|
**Calendar** entries change what a day reports, whatever the version number
|
||||||
does.
|
does.
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Calendar
|
||||||
|
|
||||||
|
- **The Pentecostarion keeps all of its Sundays.** The Sunday of the Paralytic,
|
||||||
|
the Sunday of the Samaritan Woman and the Sunday of the Blind Man were absent
|
||||||
|
from the movable cycle altogether, so the third, fourth and fifth Sundays
|
||||||
|
after Thomas Sunday reported only the saint of the fixed date. Every
|
||||||
|
jurisdiction was affected. In 2027 that is 18 and 25 April and 2 May by the
|
||||||
|
Gregorian paschalion, 23 and 30 May and 6 June by the Julian one. They rank
|
||||||
|
with Thomas Sunday and the Myrrh-bearing Women, so a fixed feast of vigil
|
||||||
|
rank still names the day and the Sunday is commemorated on it: 25 April 2027
|
||||||
|
stays St Mark and gains the Samaritan Woman.
|
||||||
|
|
||||||
|
- **The Sundays reckoned from a fixed feast are kept.** Six more that the
|
||||||
|
calendar never named: the Sundays before and after the Exaltation, the Sunday
|
||||||
|
of the Holy Forefathers, the Sundays before and after the Nativity, and the
|
||||||
|
Sundays before and after the Theophany. They are found in ecclesiastical
|
||||||
|
space, so the old calendar and the new both get them on the right morning,
|
||||||
|
and they join the day's commemorations rather than displacing its saint.
|
||||||
|
|
||||||
|
Two years in the cycle leave a window without a Sunday in it, and the Typikon
|
||||||
|
says where those go: a Nativity on a Sunday puts the Sunday after it on 26
|
||||||
|
December, and a 1 January on a Sunday keeps the Sunday before the Theophany
|
||||||
|
with the Circumcision. The two remaining cases, the Theophany itself or the
|
||||||
|
Exaltation on a Sunday, go unmarked, because the books disagree about where
|
||||||
|
what belongs in them should land.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `week`: where the day falls in the count from Pentecost, worded as the books
|
||||||
|
head it — `the 32nd Sunday after Pentecost`, or `the 32nd week after
|
||||||
|
Pentecost` on the days between. Null through the Triodion and the
|
||||||
|
Pentecostarion, whose Sundays carry names instead of numbers. The count runs
|
||||||
|
on across the new year from the Pentecost that opened it, and stops at the
|
||||||
|
next Sunday of the Publican and the Pharisee. It is in the JSON, a column in
|
||||||
|
`csv` and `tsv`, a line in the single-day view, and a getter on every
|
||||||
|
binding.
|
||||||
|
|
||||||
|
- `--every-day`: one event per day in `--format ics`, ferias included. The
|
||||||
|
default still thins the calendar to the days carrying a feast or a fast,
|
||||||
|
which is right for a subscription and wrong for a reader who wants the saint
|
||||||
|
of each morning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The iCalendar description leads with the day's commemorations, one to a line,
|
||||||
|
then the fast and its reason, then the season, the week and the church date.
|
||||||
|
They were last before, after the church date, and a client showing three
|
||||||
|
lines of a popover showed everything except what the day commemorates. The
|
||||||
|
summary now carries the rank's mark beside the fasting glyph, as the text
|
||||||
|
view has always done, and the fast is stated on every event rather than only
|
||||||
|
on the days that keep one.
|
||||||
|
|
||||||
|
- `typikon::export::to_ics_with` and `Ics` are the way to reach the new option
|
||||||
|
from the library. `to_ics` is unchanged and still writes the thinned
|
||||||
|
calendar.
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The published description says what the package covers, and says the same
|
||||||
|
thing in both registries. PyPI takes its summary from `pyproject.toml`, which
|
||||||
|
1.1.1 did not update, so it went out with the old one-line text while npm got
|
||||||
|
the new one. The two READMEs had drifted apart as well: npm's opened without
|
||||||
|
mentioning the Byzantine Catholic churches at all. All four now carry one
|
||||||
|
sentence, and a test fails if any of them moves without the others.
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The npm package's files are reachable through its exports map. A bundler
|
||||||
|
asked for `typikon/web/typikon_bg.wasm` was told no such path is exported,
|
||||||
|
so a web app that wants to hand the WebAssembly to `init()` as an asset had
|
||||||
|
to reach around the package into `node_modules`. The documented call, `await
|
||||||
|
init()` with no argument, was never affected.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `tests/parity.sh`: every binding asked the same twelve days and compared
|
||||||
|
field by field against the command line. The days are the ones where the
|
||||||
|
bindings could plausibly differ, and the surfaces are the command line, the
|
||||||
|
Python wheel, Node by import and by require, TypeScript under `--strict`, a
|
||||||
|
real browser, Deno, Bun, the C ABI and LuaJIT over it. A surface whose
|
||||||
|
toolchain is absent is named as skipped rather than silently counted.
|
||||||
|
`--published` runs the same checks against PyPI and npm instead of the
|
||||||
|
checkout.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The README no longer explains how the project tests or releases itself. That
|
||||||
|
belongs to whoever works on it, and it is in `docs/` where they will look;
|
||||||
|
a reader who came to find out what a day of the Great Fast permits does not
|
||||||
|
need it.
|
||||||
|
- A release runs what a push runs. The release workflow had been gated on
|
||||||
|
`cargo test` alone, so the C ABI, the Lua binding, the Python parity check
|
||||||
|
and the README examples were checked on the branch that led to a release but
|
||||||
|
not on the release itself. A version cannot be reused on either registry, so
|
||||||
|
the gate belongs before the upload.
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
New ways in, and no change to any day: nothing here moves a feast, a fast
|
||||||
|
or a commemoration.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A C ABI, behind the `capi` feature, with a header in `include/typikon.h`.
|
||||||
|
Dates cross it as Julian Day Numbers, so no struct layout is part of the
|
||||||
|
contract. A day is opaque and owns its strings: an accessor returns the same
|
||||||
|
borrowed pointer every time and allocates nothing, so reading one in a render
|
||||||
|
loop is free. The accessors carry every field of the JSON, so a statusline
|
||||||
|
does not need a parser. Errors go to a thread-local `typikon_last_error()`.
|
||||||
|
- A LuaJIT FFI binding in `examples/lua/typikon.lua`, for AwesomeWM, WezTerm
|
||||||
|
and Neovim, and a worked C example in `examples/c/demo.c`.
|
||||||
|
- `jurisdictions::resolve_full`, which resolves a jurisdiction together with
|
||||||
|
the usage and the discipline. The Python bindings and the C ABI now share it
|
||||||
|
rather than each keeping a copy, and it interns the combinations instead of
|
||||||
|
leaking one per call.
|
||||||
|
- A JavaScript and TypeScript package, behind the `wasm` feature and published
|
||||||
|
to npm as `typikon`. One WebAssembly build serves Node, Deno, Bun and the
|
||||||
|
browser. A day comes back as a plain object rather than a handle: the same
|
||||||
|
document `--json` emits, with nothing to free. The types are hand-written
|
||||||
|
rather than inferred, and `tsc --strict` is a release gate the way `mypy
|
||||||
|
--strict` already is. There is no remembered default jurisdiction there,
|
||||||
|
because a browser has nowhere to keep one; the default is the New Calendar.
|
||||||
|
|
||||||
## 1.0.0
|
## 1.0.0
|
||||||
|
|
||||||
First stable release. The public surface is now the one described in
|
First stable release. The public surface is now the one described in
|
||||||
|
|||||||
Generated
+120
-2
@@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
@@ -58,6 +58,18 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -104,6 +116,30 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-core"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-task"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-util"
|
||||||
|
version = "0.3.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"pin-project-lite",
|
||||||
|
"slab",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -131,6 +167,17 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.105"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"futures-util",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
@@ -164,6 +211,12 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-lite"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -265,6 +318,17 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde-wasm-bindgen"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -298,6 +362,12 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slab"
|
||||||
|
version = "0.4.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -334,12 +404,15 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.0.0"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"js-sys",
|
||||||
"pyo3",
|
"pyo3",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde-wasm-bindgen",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -360,6 +433,51 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.128"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
+14
-2
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.0.0"
|
version = "1.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched"
|
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
repository = "https://git.pumpin.online/funkpower/typikon"
|
repository = "https://git.pumpin.online/funkpower/typikon"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -16,6 +16,12 @@ serde_json = "1"
|
|||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
pyo3 = { version = "0.27.2", features = ["extension-module", "abi3-py39"], optional = true }
|
pyo3 = { version = "0.27.2", features = ["extension-module", "abi3-py39"], optional = true }
|
||||||
|
|
||||||
|
# The JavaScript build. `js-sys` is here only for the clock: WebAssembly has no
|
||||||
|
# time of its own, so `today()` has to ask the host what day it is.
|
||||||
|
wasm-bindgen = { version = "0.2", optional = true }
|
||||||
|
serde-wasm-bindgen = { version = "0.6", optional = true }
|
||||||
|
js-sys = { version = "0.3", optional = true }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
lto = true
|
lto = true
|
||||||
@@ -35,3 +41,9 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
# symbols to be resolved by the interpreter at load time, so a normal `cargo
|
# symbols to be resolved by the interpreter at load time, so a normal `cargo
|
||||||
# build` of the CLI would fail to link if it were on by default.
|
# build` of the CLI would fail to link if it were on by default.
|
||||||
python = ["dep:pyo3"]
|
python = ["dep:pyo3"]
|
||||||
|
# The C ABI. Off by default so an ordinary `cargo build` of the CLI does not
|
||||||
|
# also compile and link a shared library nobody asked for.
|
||||||
|
capi = []
|
||||||
|
# The JavaScript and TypeScript build, via WebAssembly. Only ever compiled for
|
||||||
|
# a wasm32 target.
|
||||||
|
wasm = ["dep:wasm-bindgen", "dep:serde-wasm-bindgen", "dep:js-sys"]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# typikon
|
# typikon
|
||||||
|
|
||||||
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
than fetched. The Byzantine Catholic churches are here too, on their own
|
than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic
|
||||||
calendars and, where the law has been sourced, under their own particular law.
|
churches, across the Julian, Revised Julian and Gregorian reckonings.
|
||||||
|
|
||||||
Everything derives from the paschalion and the fixed calendar, so it needs no
|
Everything derives from the paschalion and the fixed calendar, so it needs no
|
||||||
network, cannot go stale, and answers for any year you ask about. No API key,
|
network, cannot go stale, and answers for any year you ask about. No API key,
|
||||||
@@ -71,6 +71,15 @@ no network either. Wheels are built for Linux on x86-64 and aarch64; everywhere
|
|||||||
else pip falls back to the source distribution, which needs a Rust toolchain of
|
else pip falls back to the source distribution, which needs a Rust toolchain of
|
||||||
1.85 or newer on the installing machine.
|
1.85 or newer on the installing machine.
|
||||||
|
|
||||||
|
For JavaScript and TypeScript, there is a package:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ npm install typikon
|
||||||
|
```
|
||||||
|
|
||||||
|
WebAssembly, so the one package serves Node, Deno, Bun and the browser with no
|
||||||
|
native build to go wrong.
|
||||||
|
|
||||||
## Which calendar?
|
## Which calendar?
|
||||||
|
|
||||||
Three things get conflated constantly, and keeping them apart is most of what
|
Three things get conflated constantly, and keeping them apart is most of what
|
||||||
@@ -268,6 +277,16 @@ $ typikon --format json --days 7 # for a bar, a widget, a script
|
|||||||
$ typikon --format ics --days 365 > orthodox.ics
|
$ typikon --format ics --days 365 > orthodox.ics
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The calendar is thinned to the days worth marking: a subscription that fills
|
||||||
|
all 365 squares is one nobody keeps. `--every-day` is for the other kind of
|
||||||
|
reader, who wants the saint of each morning and does not mind a full year.
|
||||||
|
Either way the description carries the day's commemorations, the fast and its
|
||||||
|
reason, and where the day sits in the year.
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ typikon -j ruthenian --days 467 --ics --every-day > byzantine.ics
|
||||||
|
```
|
||||||
|
|
||||||
Delimited output, for a spreadsheet or a pipeline. RFC 4180 quoting, one header
|
Delimited output, for a spreadsheet or a pipeline. RFC 4180 quoting, one header
|
||||||
row, one row per day:
|
row, one row per day:
|
||||||
|
|
||||||
@@ -300,6 +319,11 @@ $ typikon --year 2027 -j goarch
|
|||||||
…
|
…
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Outside the Triodion and the Pentecostarion, whose Sundays carry names, `week`
|
||||||
|
says where the day falls in the count from Pentecost: `the 32nd Sunday after
|
||||||
|
Pentecost`, or `the 32nd week after Pentecost` on the days between. It is a
|
||||||
|
column in the delimited formats and a line in the single-day view.
|
||||||
|
|
||||||
Every day of vigil rank or above carries the mark its books print beside it:
|
Every day of vigil rank or above carries the mark its books print beside it:
|
||||||
⊕ for a great feast, ✚ for a vigil, ☩ for a polyeleos. It is the `sign`
|
⊕ for a great feast, ✚ for a vigil, ☩ for a polyeleos. It is the `sign`
|
||||||
field in the JSON and a column in the delimited formats.
|
field in the JSON and a column in the delimited formats.
|
||||||
@@ -323,6 +347,7 @@ $ typikon -j rocor --date 2026-03-18 --json --indent 2
|
|||||||
"churchDate": "5 March",
|
"churchDate": "5 March",
|
||||||
"season": "Great Fast",
|
"season": "Great Fast",
|
||||||
"cycle": null,
|
"cycle": null,
|
||||||
|
"week": null,
|
||||||
"feast": null,
|
"feast": null,
|
||||||
"rank": null,
|
"rank": null,
|
||||||
"sign": null,
|
"sign": null,
|
||||||
@@ -404,6 +429,96 @@ the kind of disagreement nobody notices until a fast day is wrong.
|
|||||||
The wheel is abi3, so one build serves every Python from 3.9 up. It ships
|
The wheel is abi3, so one build serves every Python from 3.9 up. It ships
|
||||||
`py.typed` and a stub file, and `mypy --strict` is clean against it.
|
`py.typed` and a stub file, and `mypy --strict` is clean against it.
|
||||||
|
|
||||||
|
## From C, and anything with an FFI
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --features capi # target/release/libtypikon.so
|
||||||
|
cc -Iinclude yours.c -ltypikon
|
||||||
|
```
|
||||||
|
|
||||||
|
```c
|
||||||
|
#include <typikon.h>
|
||||||
|
|
||||||
|
typikon_day *d = typikon_describe(typikon_today(), "rocor");
|
||||||
|
if (!d) { fputs(typikon_last_error(), stderr); return 1; }
|
||||||
|
|
||||||
|
typikon_day_church_date(d); /* "5 September" */
|
||||||
|
typikon_day_fast(d); /* "xerophagy" */
|
||||||
|
typikon_day_emoji(d); /* the glyph for the level */
|
||||||
|
typikon_day_title(d); /* the feast, else the principal commemoration */
|
||||||
|
typikon_day_is_obligatory(d); /* 0 */
|
||||||
|
|
||||||
|
typikon_day_free(d);
|
||||||
|
```
|
||||||
|
|
||||||
|
Dates cross the boundary as **Julian Day Numbers**, so no struct layout is part
|
||||||
|
of the ABI and there is nothing for a caller to keep in step;
|
||||||
|
`typikon_jdn_from_ymd` converts. A day is opaque and owns its strings: an
|
||||||
|
accessor returns the same borrowed pointer every time, so reading one in a
|
||||||
|
render loop allocates nothing, and nothing it returns should be freed. NULL
|
||||||
|
from an accessor means the day has no such field, which is not an error. A call
|
||||||
|
that can fail returns NULL or 0 and leaves a message in `typikon_last_error()`.
|
||||||
|
|
||||||
|
[`include/typikon.h`](https://git.pumpin.online/funkpower/typikon/src/branch/main/include/typikon.h)
|
||||||
|
is the whole surface: it carries every field of the JSON, so a statusline does
|
||||||
|
not need a parser. [`examples/c/demo.c`](https://git.pumpin.online/funkpower/typikon/src/branch/main/examples/c/demo.c)
|
||||||
|
is a working one.
|
||||||
|
|
||||||
|
### From Lua
|
||||||
|
|
||||||
|
[`examples/lua/typikon.lua`](https://git.pumpin.online/funkpower/typikon/src/branch/main/examples/lua/typikon.lua)
|
||||||
|
is a LuaJIT FFI binding, for AwesomeWM, WezTerm, Neovim and anything else that
|
||||||
|
embeds LuaJIT and should not be spawning a process on every redraw.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local typikon = require("typikon")
|
||||||
|
|
||||||
|
local day = assert(typikon.today({ jurisdiction = "rocor" }))
|
||||||
|
day.church_date -- "5 September"
|
||||||
|
day.fast -- "xerophagy"
|
||||||
|
day.title -- "The Holy Prophet Zachariah, father of the Forerunner"
|
||||||
|
day.commemorations
|
||||||
|
```
|
||||||
|
|
||||||
|
## From JavaScript and TypeScript
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install typikon
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { today } from "typikon";
|
||||||
|
|
||||||
|
const day = today({ jurisdiction: "rocor" });
|
||||||
|
|
||||||
|
day.churchDate; // "5 September"
|
||||||
|
day.fast; // "xerophagy"
|
||||||
|
day.emoji; // the glyph for the level
|
||||||
|
day.title; // "The Holy Prophet Zachariah, father of the Forerunner"
|
||||||
|
day.season; // null in ordinary time
|
||||||
|
day.daysToPascha;
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { describe, describeIso, week, pascha, jurisdictions } from "typikon";
|
||||||
|
|
||||||
|
describeIso("2026-04-12", { jurisdiction: "moscow" });
|
||||||
|
describe(new Date(), { jurisdiction: "goarch", tradition: "greek" });
|
||||||
|
week(new Date(), 7, { jurisdiction: "oca" }); // seven consecutive days
|
||||||
|
pascha(2027, "old"); // 2027-05-02
|
||||||
|
```
|
||||||
|
|
||||||
|
It is WebAssembly, so it is one package for Node, Deno, Bun, a browser and
|
||||||
|
anything built on them, with no native build to go wrong. Node loads it itself;
|
||||||
|
in a browser or a bundler, `await init()` first. A day comes back as a plain
|
||||||
|
object, not a handle: the same document `--json` emits, with nothing to free
|
||||||
|
and `JSON.stringify` working on it. TypeScript types ship with the package and
|
||||||
|
`tsc --strict` is clean against them.
|
||||||
|
|
||||||
|
There is no remembered default here, because a browser has nowhere to keep one:
|
||||||
|
the default is the New Calendar, and anything else is passed. That is the only
|
||||||
|
place this binding differs from the others.
|
||||||
|
|
||||||
## The menologion
|
## The menologion
|
||||||
|
|
||||||
The fixed calendar ships as data and is indexed by **ecclesiastical** date, so
|
The fixed calendar ships as data and is indexed by **ecclesiastical** date, so
|
||||||
@@ -456,12 +571,6 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Releasing
|
|
||||||
|
|
||||||
`main` is always releasable; a `vX.Y.Z` tag on it builds the wheels and publishes
|
|
||||||
to PyPI. [docs/releasing.md](https://git.pumpin.online/funkpower/typikon/src/branch/main/docs/releasing.md) has the workflow, and what has to
|
|
||||||
be set up before the first one.
|
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
GPL-2.0-only. The source for any published release is the source distribution
|
GPL-2.0-only. The source for any published release is the source distribution
|
||||||
|
|||||||
@@ -91,6 +91,27 @@ every package the account owns.
|
|||||||
|
|
||||||
Rotate it at `pypi.org/manage/account/token/` if the forge is ever suspect.
|
Rotate it at `pypi.org/manage/account/token/` if the forge is ever suspect.
|
||||||
|
|
||||||
|
### 4. An npm token
|
||||||
|
|
||||||
|
Also under **funkpower**, also scoped to the one package, added as the
|
||||||
|
repository secret `NPM_TOKEN`. Make it a **granular access token** with write
|
||||||
|
on `typikon` alone; a classic automation token is account-wide.
|
||||||
|
|
||||||
|
It does **not** need "bypass 2FA", and should not have it. npm is withdrawing
|
||||||
|
direct publish from every token that bypasses 2FA: management operations went
|
||||||
|
in July 2026, publishing goes in January 2027, after which such a token may
|
||||||
|
only read private packages and stage a publish. Staging is what this workflow
|
||||||
|
does, so an ordinary granular token is both sufficient and the one that
|
||||||
|
survives.
|
||||||
|
|
||||||
|
npm has trusted publishing too, and like PyPI's it is OIDC against GitHub
|
||||||
|
Actions and GitLab, which this forge is neither. So the forge holds a
|
||||||
|
long-lived credential and the scoping is the whole of the mitigation.
|
||||||
|
|
||||||
|
Without the secret the release still succeeds and the npm step warns and
|
||||||
|
skips, so the first release after the token lapses is a PyPI release and
|
||||||
|
nothing else. Check the run.
|
||||||
|
|
||||||
## What gets published
|
## What gets published
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
@@ -102,6 +123,31 @@ Rotate it at `pypi.org/manage/account/token/` if the forge is ever suspect.
|
|||||||
abi3, so one wheel per platform covers every Python from 3.9 up rather than one
|
abi3, so one wheel per platform covers every Python from 3.9 up rather than one
|
||||||
per interpreter.
|
per interpreter.
|
||||||
|
|
||||||
|
And on npm, one package with two WebAssembly builds in it: `node/` for Node,
|
||||||
|
which loads the module itself, and `web/` for browsers and bundlers, which
|
||||||
|
cannot compile WebAssembly synchronously on the main thread and so has to be
|
||||||
|
awaited. The exports map picks between them. There are no per-platform
|
||||||
|
artefacts, because WebAssembly has no platforms.
|
||||||
|
|
||||||
|
### The npm half needs a hand
|
||||||
|
|
||||||
|
The tag does not finish the npm release. The runner **stages** the tarball; it
|
||||||
|
is not on the registry and `npm install typikon` will not see it until someone
|
||||||
|
approves it with 2FA:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ npm stage list
|
||||||
|
$ npm stage view <stage-id> # what is in it
|
||||||
|
$ npm stage approve <stage-id> # asks for the code
|
||||||
|
```
|
||||||
|
|
||||||
|
Or the **Staged Packages** tab on npmjs.com. Either way a code is asked for,
|
||||||
|
which is the point: the forge holds a credential that can propose a release and
|
||||||
|
cannot make one.
|
||||||
|
|
||||||
|
`npm stage` wants npm 11.15 or newer on Node 22.14 or newer, on the approving
|
||||||
|
machine as well as the runner.
|
||||||
|
|
||||||
**No macOS or Windows wheels.** Forgejo has no hosted runners, so those need a
|
**No macOS or Windows wheels.** Forgejo has no hosted runners, so those need a
|
||||||
Mac and a Windows box registered as runners. Until then both fall back to the
|
Mac and a Windows box registered as runners. Until then both fall back to the
|
||||||
sdist, which needs a Rust toolchain on the installing machine — fine for
|
sdist, which needs a Rust toolchain on the installing machine — fine for
|
||||||
@@ -119,3 +165,36 @@ $ /tmp/v/bin/python -c 'import typikon, datetime; print(typikon.today())'
|
|||||||
|
|
||||||
Installing from the sdist with `--no-binary` is the one that catches a missing
|
Installing from the sdist with `--no-binary` is the one that catches a missing
|
||||||
file, because it builds the crate from exactly what PyPI would hand someone.
|
file, because it builds the crate from exactly what PyPI would hand someone.
|
||||||
|
|
||||||
|
### Every binding agrees
|
||||||
|
|
||||||
|
`tests/parity.sh` asks every binding for the same twelve days and compares all
|
||||||
|
twenty-two fields against what the command line says. It runs in CI on every
|
||||||
|
push and again in the release job before anything is uploaded.
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ tests/parity.sh # against this checkout
|
||||||
|
$ tests/parity.sh --published # against what is on PyPI and npm
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the second one after a release. It is the only check that exercises the
|
||||||
|
artefacts people will actually install rather than the ones the build produced.
|
||||||
|
|
||||||
|
A surface whose toolchain is missing is skipped by name, and the script refuses
|
||||||
|
to report a pass if fewer than three ran, so a container with nothing installed
|
||||||
|
cannot look like a green run. Everything it wants: `python3` with `venv`, `node`
|
||||||
|
and `npm`, `cc`, `luajit`, `chromium`, and optionally `deno` and `bun`.
|
||||||
|
|
||||||
|
For the npm package:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ npm/build.sh && node npm/test.mjs
|
||||||
|
$ npm pack --dry-run npm/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm/test.mjs` resolves `typikon` through a staged `node_modules`, so it tests
|
||||||
|
the exports map and the built files rather than a lucky relative import. It
|
||||||
|
compares a year of days field by field against the command line, runs the web
|
||||||
|
build the way a browser would, and type-checks the published `.d.ts` under
|
||||||
|
`tsc --strict`. It needs `wasm-bindgen` on PATH at the version `Cargo.lock`
|
||||||
|
pins, and `tsc` for the last of those.
|
||||||
|
|||||||
+27
-3
@@ -21,13 +21,16 @@ tradition title commemorations fast fastLabel emoji reason obligation binds
|
|||||||
pascha daysToPascha julianOffset
|
pascha daysToPascha julianOffset
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`week` was added in 1.2.0.
|
||||||
|
|
||||||
`fastLabel`, `emoji` and `reason` are **presentation**: the fields are always
|
`fastLabel`, `emoji` and `reason` are **presentation**: the fields are always
|
||||||
there, and their wording is not stable. Key on `fast`, `season`, `rank`,
|
there, and their wording is not stable. Key on `fast`, `season`, `rank`,
|
||||||
`obligation` and `binds`.
|
`obligation` and `binds`.
|
||||||
|
|
||||||
Adding a field is a **minor** change, so read the object by key and ignore what
|
Adding a field is a **minor** change, so read the object by key and ignore what
|
||||||
you do not recognise. `season`, `cycle`, `feast`, `rank`, `sign` and
|
you do not recognise. `season`, `cycle`, `feast`, `rank`, `sign` and
|
||||||
`obligation` are null on ordinary days.
|
`obligation` are null on ordinary days, and `week` is null through the Triodion
|
||||||
|
and the Pentecostarion.
|
||||||
|
|
||||||
Two semantics worth stating because the names do not carry them:
|
Two semantics worth stating because the names do not carry them:
|
||||||
|
|
||||||
@@ -72,6 +75,27 @@ and in Python, the module's functions and the attributes of `Day`.
|
|||||||
`Day.to_dict()` in Python carries the same fields as the JSON, in snake_case
|
`Day.to_dict()` in Python carries the same fields as the JSON, in snake_case
|
||||||
rather than camelCase, which is the one deliberate difference between them.
|
rather than camelCase, which is the one deliberate difference between them.
|
||||||
|
|
||||||
|
### The C ABI
|
||||||
|
|
||||||
|
Every `typikon_*` symbol declared in `include/typikon.h`, and the meaning of
|
||||||
|
what it returns. `typikon_abi_version()` is bumped when any of that changes;
|
||||||
|
additions leave it alone, so a caller can require a minimum and get everything
|
||||||
|
after it.
|
||||||
|
|
||||||
|
Dates cross as Julian Day Numbers and a day is opaque, so the ABI carries no
|
||||||
|
struct layout and a rebuilt library stays loadable against a header that has
|
||||||
|
not moved.
|
||||||
|
|
||||||
|
### The JavaScript package
|
||||||
|
|
||||||
|
The functions the npm package exports, and the `Day`, `Options` and
|
||||||
|
`JurisdictionInfo` types in its `.d.ts`. A day is the JSON above, with the same
|
||||||
|
field names; `undefined` never appears where the JSON has `null`.
|
||||||
|
|
||||||
|
The one deliberate difference from every other binding: there is no remembered
|
||||||
|
default jurisdiction, because a browser has nowhere to keep one. The default is
|
||||||
|
the New Calendar.
|
||||||
|
|
||||||
## What is not stable
|
## What is not stable
|
||||||
|
|
||||||
- **The text output.** It is for a person to read, and it changes when a better
|
- **The text output.** It is for a person to read, and it changes when a better
|
||||||
@@ -98,8 +122,8 @@ So: **every change to what a day reports is called out in the changelog under
|
|||||||
"Calendar"**, with the days affected, whatever the version number does. If a
|
"Calendar"**, with the days affected, whatever the version number does. If a
|
||||||
correction changes a day you care about, the changelog is where it will say so.
|
correction changes a day you care about, the changelog is where it will say so.
|
||||||
|
|
||||||
Adding a jurisdiction, a menaion or a tradition is a minor version. Correcting
|
Adding a jurisdiction, a menaion, a tradition or a binding is a minor version.
|
||||||
an existing one is a patch, and is announced.
|
Correcting an existing one is a patch, and is announced.
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* Build:
|
||||||
|
* cargo build --release --features capi
|
||||||
|
* cc -Iinclude examples/c/demo.c -Ltarget/release -ltypikon -o /tmp/demo
|
||||||
|
* LD_LIBRARY_PATH=target/release /tmp/demo rocor
|
||||||
|
*
|
||||||
|
* With no arguments it uses the remembered default; with --list it prints
|
||||||
|
* every jurisdiction it knows.
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <typikon.h>
|
||||||
|
|
||||||
|
static void show(int32_t jdn, const char *jurisdiction) {
|
||||||
|
typikon_day *d = typikon_describe(jdn, jurisdiction);
|
||||||
|
if (!d) {
|
||||||
|
fprintf(stderr, " %s\n", typikon_last_error());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const char *season = typikon_day_season(d);
|
||||||
|
const char *sign = typikon_day_sign(d);
|
||||||
|
printf(" %-12s %-14s %-4s %-28s %s%s%s\n",
|
||||||
|
jurisdiction ? jurisdiction : "(default)",
|
||||||
|
typikon_day_church_date(d),
|
||||||
|
typikon_day_emoji(d),
|
||||||
|
typikon_day_fast_label(d),
|
||||||
|
sign ? sign : "",
|
||||||
|
typikon_day_title(d),
|
||||||
|
typikon_day_is_obligatory(d) ? " [obligatory]" : "");
|
||||||
|
if (season) printf(" season: %s\n", season);
|
||||||
|
/* The title is the feast, or the first commemoration where there is no
|
||||||
|
feast, so start past it rather than printing it twice. */
|
||||||
|
uint32_t start = typikon_day_feast(d) ? 0 : 1;
|
||||||
|
for (uint32_t i = start; i < typikon_day_commemoration_count(d); i++)
|
||||||
|
printf(" also %s\n", typikon_day_commemoration(d, i));
|
||||||
|
typikon_day_free(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void list(void) {
|
||||||
|
for (uint32_t i = 0; i < typikon_jurisdiction_count(); i++)
|
||||||
|
printf(" %-18s %s\n", typikon_jurisdiction_key(i),
|
||||||
|
typikon_jurisdiction_name(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
printf("typikon %s, ABI %u\n\n", typikon_version(), typikon_abi_version());
|
||||||
|
|
||||||
|
if (argc == 2 && strcmp(argv[1], "--list") == 0) {
|
||||||
|
list();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t today = typikon_today();
|
||||||
|
for (int i = 1; i < argc; i++) show(today, argv[i]);
|
||||||
|
if (argc == 1) show(today, NULL);
|
||||||
|
|
||||||
|
/* A day with something to say: the first day of the Great Fast, 2026,
|
||||||
|
under Byzantine Catholic law and then at the Typikon's discipline. */
|
||||||
|
int32_t clean_monday = typikon_jdn_from_ymd(2026, 2, 16);
|
||||||
|
printf("\n Clean Monday 2026:\n");
|
||||||
|
typikon_day *law = typikon_describe_full(clean_monday, "ruthenian", NULL, 0);
|
||||||
|
typikon_day *fast = typikon_describe_full(clean_monday, "ruthenian", NULL, 1);
|
||||||
|
printf(" law %-30s binds %s\n",
|
||||||
|
typikon_day_fast_label(law),
|
||||||
|
typikon_day_binds(law) ? typikon_day_binds(law) : "nothing");
|
||||||
|
printf(" typikon %-30s binds %s\n",
|
||||||
|
typikon_day_fast_label(fast),
|
||||||
|
typikon_day_binds(fast) ? typikon_day_binds(fast) : "nothing");
|
||||||
|
typikon_day_free(law);
|
||||||
|
typikon_day_free(fast);
|
||||||
|
|
||||||
|
/* Pascha without reckoning a day for it. */
|
||||||
|
int32_t p = typikon_pascha(2026, "old");
|
||||||
|
int32_t y, dd;
|
||||||
|
uint32_t m, d2;
|
||||||
|
typikon_ymd_from_jdn(p, &y, &m, &d2);
|
||||||
|
dd = p - typikon_today();
|
||||||
|
printf("\n Pascha 2026 (Julian paschalion): %04d-%02u-%02u, %d days from today\n",
|
||||||
|
y, m, d2, dd);
|
||||||
|
|
||||||
|
/* Errors do not crash. */
|
||||||
|
if (!typikon_describe(today, "atlantis"))
|
||||||
|
printf("\n bad jurisdiction: %s\n", typikon_last_error());
|
||||||
|
if (!typikon_jdn_from_ymd(2026, 2, 30))
|
||||||
|
printf(" bad date : %s\n", typikon_last_error());
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- What a statusline would do with it: one line, no subprocess, no JSON.
|
||||||
|
--
|
||||||
|
-- cargo build --release --features capi
|
||||||
|
-- TYPIKON_LIB=target/release/libtypikon.so \
|
||||||
|
-- luajit examples/lua/statusline.lua rocor
|
||||||
|
|
||||||
|
package.path = (arg[0]:match("(.*/)") or "./") .. "?.lua;" .. package.path
|
||||||
|
|
||||||
|
local typikon = require("typikon")
|
||||||
|
|
||||||
|
local day, e = typikon.today({ jurisdiction = arg[1] })
|
||||||
|
if not day then
|
||||||
|
io.stderr:write(e, "\n")
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The one line.
|
||||||
|
print(("%s %s %s%s"):format(
|
||||||
|
day.emoji,
|
||||||
|
day.church_date,
|
||||||
|
day.sign and (day.sign .. " ") or "",
|
||||||
|
day.title))
|
||||||
|
|
||||||
|
-- And the detail a tooltip would carry.
|
||||||
|
print()
|
||||||
|
print((" %-14s %s (%s, %s)"):format("reckoning", day.jurisdiction, day.calendar, day.tradition))
|
||||||
|
print((" %-14s %s%s"):format("fast", day.fast_label,
|
||||||
|
day.is_obligatory and " [obligatory]" or ""))
|
||||||
|
if day.binds then
|
||||||
|
print((" %-14s %s"):format("law requires", day.binds))
|
||||||
|
end
|
||||||
|
print((" %-14s %s"):format("because", day.reason))
|
||||||
|
if day.season then print((" %-14s %s"):format("season", day.season)) end
|
||||||
|
if day.cycle then print((" %-14s %s"):format("cycle", day.cycle)) end
|
||||||
|
for i = 2, #day.commemorations do
|
||||||
|
print((" %-14s %s"):format(i == 2 and "also" or "", day.commemorations[i]))
|
||||||
|
end
|
||||||
|
|
||||||
|
print((" %-14s %d days (%s)"):format("to Pascha", day.days_to_pascha,
|
||||||
|
("%04d-%02d-%02d"):format(typikon.ymd(day.pascha_jdn))))
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
-- typikon, through LuaJIT's FFI.
|
||||||
|
--
|
||||||
|
-- Drop this beside your config and `local typikon = require("typikon")`. It
|
||||||
|
-- needs libtypikon.so on the loader's path, or TYPIKON_LIB set to it:
|
||||||
|
--
|
||||||
|
-- cargo build --release --features capi
|
||||||
|
-- TYPIKON_LIB=target/release/libtypikon.so luajit examples/lua/statusline.lua
|
||||||
|
--
|
||||||
|
-- Written for AwesomeWM, WezTerm and Neovim, which all embed LuaJIT and none
|
||||||
|
-- of which should be spawning a process on every redraw.
|
||||||
|
|
||||||
|
local ffi = require("ffi")
|
||||||
|
|
||||||
|
ffi.cdef [[
|
||||||
|
const char *typikon_version(void);
|
||||||
|
uint32_t typikon_abi_version(void);
|
||||||
|
const char *typikon_last_error(void);
|
||||||
|
|
||||||
|
int32_t typikon_jdn_from_ymd(int32_t year, uint32_t month, uint32_t day);
|
||||||
|
int typikon_ymd_from_jdn(int32_t jdn, int32_t *year, uint32_t *month, uint32_t *day);
|
||||||
|
int32_t typikon_today(void);
|
||||||
|
|
||||||
|
typedef struct typikon_day typikon_day;
|
||||||
|
typikon_day *typikon_describe(int32_t jdn, const char *jurisdiction);
|
||||||
|
typikon_day *typikon_describe_full(int32_t jdn, const char *jurisdiction,
|
||||||
|
const char *tradition, int discipline);
|
||||||
|
void typikon_day_free(typikon_day *day);
|
||||||
|
|
||||||
|
const char *typikon_day_json(const typikon_day *day);
|
||||||
|
const char *typikon_day_iso(const typikon_day *day);
|
||||||
|
const char *typikon_day_weekday(const typikon_day *day);
|
||||||
|
const char *typikon_day_jurisdiction(const typikon_day *day);
|
||||||
|
const char *typikon_day_calendar(const typikon_day *day);
|
||||||
|
const char *typikon_day_tradition(const typikon_day *day);
|
||||||
|
const char *typikon_day_church_date(const typikon_day *day);
|
||||||
|
const char *typikon_day_title(const typikon_day *day);
|
||||||
|
const char *typikon_day_fast(const typikon_day *day);
|
||||||
|
const char *typikon_day_fast_label(const typikon_day *day);
|
||||||
|
const char *typikon_day_emoji(const typikon_day *day);
|
||||||
|
const char *typikon_day_reason(const typikon_day *day);
|
||||||
|
const char *typikon_day_season(const typikon_day *day);
|
||||||
|
const char *typikon_day_cycle(const typikon_day *day);
|
||||||
|
const char *typikon_day_week(const typikon_day *day);
|
||||||
|
const char *typikon_day_feast(const typikon_day *day);
|
||||||
|
const char *typikon_day_rank(const typikon_day *day);
|
||||||
|
const char *typikon_day_sign(const typikon_day *day);
|
||||||
|
const char *typikon_day_obligation(const typikon_day *day);
|
||||||
|
const char *typikon_day_binds(const typikon_day *day);
|
||||||
|
uint32_t typikon_day_commemoration_count(const typikon_day *day);
|
||||||
|
const char *typikon_day_commemoration(const typikon_day *day, uint32_t i);
|
||||||
|
int typikon_day_is_obligatory(const typikon_day *day);
|
||||||
|
int32_t typikon_day_days_to_pascha(const typikon_day *day);
|
||||||
|
int32_t typikon_day_pascha(const typikon_day *day);
|
||||||
|
int32_t typikon_day_julian_offset(const typikon_day *day);
|
||||||
|
|
||||||
|
int32_t typikon_pascha(int32_t year, const char *jurisdiction);
|
||||||
|
uint32_t typikon_jurisdiction_count(void);
|
||||||
|
const char *typikon_jurisdiction_key(uint32_t i);
|
||||||
|
const char *typikon_jurisdiction_name(uint32_t i);
|
||||||
|
const char *typikon_jurisdiction_note(uint32_t i);
|
||||||
|
]]
|
||||||
|
|
||||||
|
local C = ffi.load(os.getenv("TYPIKON_LIB") or "typikon")
|
||||||
|
|
||||||
|
local M = { ABI = 1 }
|
||||||
|
|
||||||
|
-- A string field, or nil where the library returned NULL. Every accessor may,
|
||||||
|
-- and NULL means "the day has no such thing" rather than an error.
|
||||||
|
local function str(p)
|
||||||
|
if p == nil then return nil end
|
||||||
|
return ffi.string(p)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function err()
|
||||||
|
return str(C.typikon_last_error()) or "unknown error"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The fields worth reading without a JSON parser. Order matters only for the
|
||||||
|
-- table's own readability.
|
||||||
|
local FIELDS = {
|
||||||
|
"iso", "weekday", "jurisdiction", "calendar", "tradition", "church_date",
|
||||||
|
"title", "fast", "fast_label", "emoji", "reason",
|
||||||
|
"season", "cycle", "week", "feast", "rank", "sign", "obligation", "binds",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Copy the day into a plain Lua table and free it. The C day owns its strings
|
||||||
|
-- and they die with it, so nothing here may hold on to a pointer.
|
||||||
|
local function harvest(d)
|
||||||
|
local t = {}
|
||||||
|
for _, f in ipairs(FIELDS) do
|
||||||
|
t[f] = str(C["typikon_day_" .. f](d))
|
||||||
|
end
|
||||||
|
t.is_obligatory = C.typikon_day_is_obligatory(d) ~= 0
|
||||||
|
t.days_to_pascha = C.typikon_day_days_to_pascha(d)
|
||||||
|
t.pascha_jdn = C.typikon_day_pascha(d)
|
||||||
|
t.julian_offset = C.typikon_day_julian_offset(d)
|
||||||
|
t.commemorations = {}
|
||||||
|
for i = 0, C.typikon_day_commemoration_count(d) - 1 do
|
||||||
|
t.commemorations[i + 1] = str(C.typikon_day_commemoration(d, i))
|
||||||
|
end
|
||||||
|
t.json = str(C.typikon_day_json(d))
|
||||||
|
C.typikon_day_free(d)
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
--- One day, as a table. `opts` may carry `jurisdiction`, `tradition` and
|
||||||
|
--- `typikon` (true to show the day at the Typikon's full discipline, false for
|
||||||
|
--- only what particular law requires, nil for whatever was remembered).
|
||||||
|
--- Returns nil and a message on error.
|
||||||
|
function M.describe(jdn, opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local discipline = -1
|
||||||
|
if opts.typikon == true then discipline = 1
|
||||||
|
elseif opts.typikon == false then discipline = 0 end
|
||||||
|
local d = C.typikon_describe_full(jdn, opts.jurisdiction, opts.tradition, discipline)
|
||||||
|
if d == nil then return nil, err() end
|
||||||
|
return harvest(d)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Today, by local time.
|
||||||
|
function M.today(opts)
|
||||||
|
return M.describe(C.typikon_today(), opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- The Julian Day Number for a civil date, or nil and a message.
|
||||||
|
function M.jdn(year, month, day)
|
||||||
|
local n = C.typikon_jdn_from_ymd(year, month, day)
|
||||||
|
if n == 0 then return nil, err() end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
--- The civil year, month and day for a Julian Day Number.
|
||||||
|
function M.ymd(jdn)
|
||||||
|
local y = ffi.new("int32_t[1]")
|
||||||
|
local m = ffi.new("uint32_t[1]")
|
||||||
|
local d = ffi.new("uint32_t[1]")
|
||||||
|
if C.typikon_ymd_from_jdn(jdn, y, m, d) ~= 0 then return nil, err() end
|
||||||
|
return y[0], tonumber(m[0]), tonumber(d[0])
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Pascha in a civil year, as a Julian Day Number.
|
||||||
|
function M.pascha(year, jurisdiction)
|
||||||
|
local n = C.typikon_pascha(year, jurisdiction)
|
||||||
|
if n == 0 then return nil, err() end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Every jurisdiction the library knows: { key, name, note }.
|
||||||
|
function M.jurisdictions()
|
||||||
|
local out = {}
|
||||||
|
for i = 0, C.typikon_jurisdiction_count() - 1 do
|
||||||
|
out[i + 1] = {
|
||||||
|
key = str(C.typikon_jurisdiction_key(i)),
|
||||||
|
name = str(C.typikon_jurisdiction_name(i)),
|
||||||
|
note = str(C.typikon_jurisdiction_note(i)),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
M.version = str(C.typikon_version())
|
||||||
|
|
||||||
|
-- A library newer in a way that changed meaning, not one merely newer.
|
||||||
|
if C.typikon_abi_version() ~= M.ABI then
|
||||||
|
error(("typikon ABI %d, this binding wants %d")
|
||||||
|
:format(tonumber(C.typikon_abi_version()), M.ABI))
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* typikon - the Orthodox liturgical day, computed rather than fetched.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
* https://git.pumpin.online/funkpower/typikon
|
||||||
|
*
|
||||||
|
* Link against libtypikon.so, built with:
|
||||||
|
*
|
||||||
|
* cargo build --release --features capi
|
||||||
|
*
|
||||||
|
* Dates cross this boundary as Julian Day Numbers, so no struct layout is part
|
||||||
|
* of the ABI. typikon_jdn_from_ymd converts.
|
||||||
|
*
|
||||||
|
* A typikon_day is opaque and owns its strings. They are built once when the
|
||||||
|
* day is reckoned, so an accessor returns the same borrowed, NUL-terminated
|
||||||
|
* pointer every time and allocates nothing: calling one in a render loop is
|
||||||
|
* free. A pointer is valid until typikon_day_free, or NULL where the day has
|
||||||
|
* no such field. Nothing an accessor returns should be freed.
|
||||||
|
*
|
||||||
|
* A call that can fail returns NULL or 0 and leaves a message in
|
||||||
|
* typikon_last_error(), which is thread-local.
|
||||||
|
*
|
||||||
|
* Every function is safe to call from any thread. A typikon_day is not
|
||||||
|
* synchronised; do not share one between threads without your own lock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef TYPIKON_H
|
||||||
|
#define TYPIKON_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ---- version ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* A dotted version string. Static storage; never freed. */
|
||||||
|
const char *typikon_version(void);
|
||||||
|
|
||||||
|
/* Bumped when anything here changes meaning or signature. Additions do not
|
||||||
|
* bump it. Compare against TYPIKON_ABI_VERSION if you load at runtime. */
|
||||||
|
uint32_t typikon_abi_version(void);
|
||||||
|
#define TYPIKON_ABI_VERSION 1u
|
||||||
|
|
||||||
|
/* The message from the last failing call on this thread, or NULL if the last
|
||||||
|
* call succeeded. Valid until the next failing call on this thread. */
|
||||||
|
const char *typikon_last_error(void);
|
||||||
|
|
||||||
|
/* ---- dates ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Julian Day Number for a civil (Gregorian) date, or 0 if there is no such
|
||||||
|
* date or it falls outside the years 1 to 9999. */
|
||||||
|
int32_t typikon_jdn_from_ymd(int32_t year, uint32_t month, uint32_t day);
|
||||||
|
|
||||||
|
/* Civil date for a Julian Day Number. Returns 0 on success, -1 if the number
|
||||||
|
* is outside the supported years. Any out-param may be NULL. */
|
||||||
|
int typikon_ymd_from_jdn(int32_t jdn, int32_t *year, uint32_t *month,
|
||||||
|
uint32_t *day);
|
||||||
|
|
||||||
|
/* Today's Julian Day Number, in local time. */
|
||||||
|
int32_t typikon_today(void);
|
||||||
|
|
||||||
|
/* ---- a day ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
typedef struct typikon_day typikon_day;
|
||||||
|
|
||||||
|
/* Reckon a day. jurisdiction may be NULL to use whatever
|
||||||
|
* `typikon --set-default` remembered. NULL on error. Free with
|
||||||
|
* typikon_day_free. */
|
||||||
|
typikon_day *typikon_describe(int32_t jdn, const char *jurisdiction);
|
||||||
|
|
||||||
|
/* As typikon_describe, overriding the usage and the discipline.
|
||||||
|
*
|
||||||
|
* tradition "slavic", "greek", or NULL for the jurisdiction's own.
|
||||||
|
* discipline 1 show the day at the Typikon's full discipline
|
||||||
|
* 0 show only what the church's particular law requires
|
||||||
|
* -1 use whatever was remembered
|
||||||
|
*
|
||||||
|
* The distinction matters for the Byzantine Catholic churches, whose law
|
||||||
|
* requires very much less than the tradition they keep. */
|
||||||
|
typikon_day *typikon_describe_full(int32_t jdn, const char *jurisdiction,
|
||||||
|
const char *tradition, int discipline);
|
||||||
|
|
||||||
|
/* NULL is accepted and ignored. */
|
||||||
|
void typikon_day_free(typikon_day *day);
|
||||||
|
|
||||||
|
/* ---- reading a day ---------------------------------------------------- */
|
||||||
|
|
||||||
|
/* The whole day as JSON: the same document the command line emits, under the
|
||||||
|
* same stability promise. Use this if you have a JSON parser to hand; the
|
||||||
|
* accessors below carry every field of it, so that a statusline does not need
|
||||||
|
* one. */
|
||||||
|
const char *typikon_day_json(const typikon_day *day);
|
||||||
|
|
||||||
|
/* Never NULL for a live day. */
|
||||||
|
const char *typikon_day_iso(const typikon_day *day); /* "2026-04-12" */
|
||||||
|
const char *typikon_day_weekday(const typikon_day *day); /* in English */
|
||||||
|
const char *typikon_day_jurisdiction(const typikon_day *day); /* key of the church whose reckoning this is */
|
||||||
|
const char *typikon_day_calendar(const typikon_day *day); /* old|new|gregorian */
|
||||||
|
const char *typikon_day_tradition(const typikon_day *day); /* slavic|greek */
|
||||||
|
const char *typikon_day_church_date(const typikon_day *day); /* "25 December" */
|
||||||
|
const char *typikon_day_title(const typikon_day *day); /* feast, else principal commemoration */
|
||||||
|
const char *typikon_day_fast(const typikon_day *day); /* strict|xerophagy|wine_oil|fish|dairy|none */
|
||||||
|
const char *typikon_day_fast_label(const typikon_day *day); /* as this tradition words it */
|
||||||
|
const char *typikon_day_emoji(const typikon_day *day); /* glyph for the level */
|
||||||
|
const char *typikon_day_reason(const typikon_day *day); /* why the day fasts as it does */
|
||||||
|
|
||||||
|
/* NULL where the day has no such thing, which is not an error. */
|
||||||
|
const char *typikon_day_season(const typikon_day *day); /* NULL in ordinary time */
|
||||||
|
const char *typikon_day_cycle(const typikon_day *day); /* NULL outside the movable cycle */
|
||||||
|
const char *typikon_day_week(const typikon_day *day); /* NULL outside the count from Pentecost */
|
||||||
|
const char *typikon_day_feast(const typikon_day *day); /* NULL if the day has none */
|
||||||
|
const char *typikon_day_rank(const typikon_day *day); /* NULL if the day has no feast */
|
||||||
|
const char *typikon_day_sign(const typikon_day *day); /* the mark the books print, else NULL */
|
||||||
|
const char *typikon_day_obligation(const typikon_day *day); /* obligatory|customary, else NULL */
|
||||||
|
const char *typikon_day_binds(const typikon_day *day); /* NULL where nothing binds */
|
||||||
|
|
||||||
|
/* The saints and events kept on the day, in the order the books give them. */
|
||||||
|
uint32_t typikon_day_commemoration_count(const typikon_day *day);
|
||||||
|
const char *typikon_day_commemoration(const typikon_day *day, uint32_t i);
|
||||||
|
|
||||||
|
/* Whether the day binds under the church's own particular law. */
|
||||||
|
int typikon_day_is_obligatory(const typikon_day *day);
|
||||||
|
|
||||||
|
/* Signed, to the Pascha of this day's own civil year. Negative once it has
|
||||||
|
* passed: 31 December reports about -263, not the days until the next one. */
|
||||||
|
int32_t typikon_day_days_to_pascha(const typikon_day *day);
|
||||||
|
|
||||||
|
/* Julian Day Number of Pascha in this day's civil year. */
|
||||||
|
int32_t typikon_day_pascha(const typikon_day *day);
|
||||||
|
|
||||||
|
/* How far the Julian reckoning lags the Gregorian: 13 until 14 March 2100. */
|
||||||
|
int32_t typikon_day_julian_offset(const typikon_day *day);
|
||||||
|
|
||||||
|
/* ---- without a day ---------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Julian Day Number of Pascha in a civil year, by a jurisdiction's
|
||||||
|
* paschalion. jurisdiction may be NULL. 0 on error. */
|
||||||
|
int32_t typikon_pascha(int32_t year, const char *jurisdiction);
|
||||||
|
|
||||||
|
/* Every jurisdiction this build knows: the named churches first, then the
|
||||||
|
* three bare calendars. The key is what typikon_describe takes. Static
|
||||||
|
* storage; NULL past the end. */
|
||||||
|
uint32_t typikon_jurisdiction_count(void);
|
||||||
|
const char *typikon_jurisdiction_key(uint32_t i);
|
||||||
|
const char *typikon_jurisdiction_name(uint32_t i);
|
||||||
|
const char *typikon_jurisdiction_note(uint32_t i);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* TYPIKON_H */
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# typikon
|
||||||
|
|
||||||
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
|
than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic
|
||||||
|
churches, across the Julian, Revised Julian and Gregorian reckonings.
|
||||||
|
|
||||||
|
No network, no data files, no service to be up.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install typikon
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { today } from "typikon";
|
||||||
|
|
||||||
|
const day = today({ jurisdiction: "rocor" });
|
||||||
|
|
||||||
|
day.churchDate; // "5 September"
|
||||||
|
day.fast; // "xerophagy"
|
||||||
|
day.fastLabel; // "xerophagy — no oil or wine"
|
||||||
|
day.emoji; // the glyph for the level
|
||||||
|
day.title; // "The Holy Prophet Zachariah, father of the Forerunner"
|
||||||
|
day.season; // null in ordinary time
|
||||||
|
day.daysToPascha;
|
||||||
|
```
|
||||||
|
|
||||||
|
In a browser or a bundler, load the module first:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import init, { today } from "typikon";
|
||||||
|
|
||||||
|
await init();
|
||||||
|
const day = today({ jurisdiction: "rocor" });
|
||||||
|
```
|
||||||
|
|
||||||
|
Node needs no `init`, because it can read the WebAssembly itself. The exports
|
||||||
|
map picks the right build; `typikon/node` and `typikon/web` force one.
|
||||||
|
|
||||||
|
## What comes back
|
||||||
|
|
||||||
|
A plain object, not a handle: the same document `typikon --json` emits, with
|
||||||
|
the same field names and the same stability promise. Nothing has to be freed
|
||||||
|
and `JSON.stringify` works on it. TypeScript types ship with the package.
|
||||||
|
|
||||||
|
## The rest of it
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { describe, describeIso, week, pascha, jurisdictions } from "typikon";
|
||||||
|
|
||||||
|
describe(new Date(2026, 3, 12), { jurisdiction: "moscow" });
|
||||||
|
describeIso("2026-04-12", { jurisdiction: "moscow" });
|
||||||
|
|
||||||
|
week(new Date(), 7, { jurisdiction: "goarch" }); // seven consecutive days
|
||||||
|
pascha(2027, "old"); // 2027-05-02
|
||||||
|
jurisdictions(); // [{ key, name, note }, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
`week` reckons the year's movable cycle once rather than once a day, so it is
|
||||||
|
the thing to call for a calendar grid.
|
||||||
|
|
||||||
|
## Which calendar
|
||||||
|
|
||||||
|
`jurisdiction` takes a church key, or a bare calendar where the church does not
|
||||||
|
matter: `"old"` (Julian throughout), `"new"` (Revised Julian fixed feasts with
|
||||||
|
the Julian Paschalion, the default) or `"western"` (Gregorian throughout,
|
||||||
|
Pascha included). `jurisdictions()` lists the rest. Two more options:
|
||||||
|
|
||||||
|
- `tradition`, `"slavic"` or `"greek"`, overriding the church's own usage. It
|
||||||
|
moves fish in the Nativity Fast and the rank of the Protection.
|
||||||
|
- `typikon: true`, to show a day at the Typikon's full discipline rather than
|
||||||
|
at the minimum a church's own particular law requires. What binds is reported
|
||||||
|
either way, in `binds` and `obligation`. This is for the Byzantine Catholic
|
||||||
|
churches, whose law asks very much less than the tradition they keep.
|
||||||
|
|
||||||
|
Unlike the command line and the Python bindings, there is no remembered
|
||||||
|
default: a browser has nowhere to keep one, so the default is the New Calendar
|
||||||
|
and anything else is passed.
|
||||||
|
|
||||||
|
## Accuracy
|
||||||
|
|
||||||
|
The Paschalion is exact. The fast follows the Typikon as the standard printed
|
||||||
|
calendars apply it, which is the part where careful people differ; a parish
|
||||||
|
calendar or a confessor overrides this, and neither is a bug report.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-2.0-only. Source: https://git.pumpin.online/funkpower/typikon
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the npm package into npm/dist.
|
||||||
|
#
|
||||||
|
# npm/build.sh # needs wasm-bindgen-cli at the version below
|
||||||
|
# WASM_BINDGEN=/path/to/wasm-bindgen npm/build.sh
|
||||||
|
#
|
||||||
|
# Two builds go in: `node/` for Node, which loads the module itself, and
|
||||||
|
# `web/` for browsers and bundlers, which cannot compile WebAssembly
|
||||||
|
# synchronously on the main thread and so has to be awaited. The exports map
|
||||||
|
# in package.json picks between them.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$root"
|
||||||
|
|
||||||
|
version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
|
||||||
|
bindgen="${WASM_BINDGEN:-wasm-bindgen}"
|
||||||
|
dist="npm/dist"
|
||||||
|
|
||||||
|
# The generator has to be the exact version the crate was built against, or it
|
||||||
|
# refuses the module. Cargo.lock is where that version actually is.
|
||||||
|
want="$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/[",]/,""); print $3; exit}' Cargo.lock)"
|
||||||
|
command -v "$bindgen" >/dev/null || {
|
||||||
|
echo "wasm-bindgen is not on PATH." >&2
|
||||||
|
echo " cargo install wasm-bindgen-cli --version $want" >&2
|
||||||
|
echo "or take the prebuilt one from" >&2
|
||||||
|
echo " https://github.com/rustwasm/wasm-bindgen/releases/tag/$want" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
have="$("$bindgen" --version | awk '{print $2}')"
|
||||||
|
[ "$have" = "$want" ] || {
|
||||||
|
echo "wasm-bindgen $have, but Cargo.lock pins $want" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cargo build --lib --release --target wasm32-unknown-unknown --features wasm
|
||||||
|
|
||||||
|
rm -rf "$dist"
|
||||||
|
for target in nodejs web; do
|
||||||
|
out="$dist/$([ "$target" = nodejs ] && echo node || echo web)"
|
||||||
|
"$bindgen" --target "$target" --out-dir "$out" --out-name typikon \
|
||||||
|
target/wasm32-unknown-unknown/release/typikon.wasm
|
||||||
|
# wasm-bindgen writes an overridden parameter type without the optional
|
||||||
|
# marker, so an options argument nobody wants to pass has to be passed as
|
||||||
|
# `undefined`. The override is what gives the argument a real type at all;
|
||||||
|
# this restores the `?` it costs.
|
||||||
|
sed -i 's/options: Options/options?: Options/g' "$out/typikon.d.ts"
|
||||||
|
done
|
||||||
|
|
||||||
|
# wasm-opt, where it is to hand. Worth about a fifth of the file.
|
||||||
|
if command -v wasm-opt >/dev/null; then
|
||||||
|
for w in "$dist"/*/typikon_bg.wasm; do
|
||||||
|
wasm-opt -Oz -o "$w.opt" "$w" && mv "$w.opt" "$w"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
sed "s/@VERSION@/$version/" npm/package.json.in > "$dist/package.json"
|
||||||
|
# The package is ESM; the Node build is what wasm-bindgen emits for Node, which
|
||||||
|
# is CommonJS. One file says so for that directory alone.
|
||||||
|
echo '{ "type": "commonjs" }' > "$dist/node/package.json"
|
||||||
|
cp npm/README.md "$dist/README.md"
|
||||||
|
cp LICENSE "$dist/LICENSE"
|
||||||
|
|
||||||
|
echo "built $dist, version $version"
|
||||||
|
du -h "$dist"/*/typikon_bg.wasm
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "typikon",
|
||||||
|
"version": "@VERSION@",
|
||||||
|
"description": "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings.",
|
||||||
|
"license": "GPL-2.0-only",
|
||||||
|
"homepage": "https://git.pumpin.online/funkpower/typikon",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://git.pumpin.online/funkpower/typikon.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"orthodox",
|
||||||
|
"liturgical",
|
||||||
|
"calendar",
|
||||||
|
"byzantine",
|
||||||
|
"pascha",
|
||||||
|
"fasting",
|
||||||
|
"wasm"
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"types": "./web/typikon.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"node": {
|
||||||
|
"types": "./node/typikon.d.ts",
|
||||||
|
"default": "./node/typikon.js"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"types": "./web/typikon.d.ts",
|
||||||
|
"default": "./web/typikon.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"./node": {
|
||||||
|
"types": "./node/typikon.d.ts",
|
||||||
|
"default": "./node/typikon.js"
|
||||||
|
},
|
||||||
|
"./web": {
|
||||||
|
"types": "./web/typikon.d.ts",
|
||||||
|
"default": "./web/typikon.js"
|
||||||
|
},
|
||||||
|
"./node/*": "./node/*",
|
||||||
|
"./web/*": "./web/*",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"node/",
|
||||||
|
"web/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
// The npm package, exercised as a consumer would use it, against the same
|
||||||
|
// answers the command line gives.
|
||||||
|
//
|
||||||
|
// npm/build.sh && node npm/test.mjs
|
||||||
|
//
|
||||||
|
// It imports by package path through a temporary node_modules link, so what is
|
||||||
|
// tested is the exports map and the built files, not a lucky relative import.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, symlinkSync, mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = resolve(here, "..");
|
||||||
|
const cli = join(root, "target", "release", "typikon");
|
||||||
|
|
||||||
|
const stage = mkdtempSync(join(tmpdir(), "typikon-npm-"));
|
||||||
|
mkdirSync(join(stage, "node_modules"));
|
||||||
|
symlinkSync(join(root, "npm", "dist"), join(stage, "node_modules", "typikon"), "dir");
|
||||||
|
|
||||||
|
// Resolve "typikon" from inside the staged directory, so the exports map is
|
||||||
|
// what decides which build loads.
|
||||||
|
const { createRequire } = await import("node:module");
|
||||||
|
const req = createRequire(join(stage, "probe.js"));
|
||||||
|
const entry = req.resolve("typikon");
|
||||||
|
const typikon = await import(pathToFileURL(entry).href);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ok ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
failures++;
|
||||||
|
console.log(` FAIL ${name}\n ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`typikon ${typikon.version()} from ${entry}`);
|
||||||
|
|
||||||
|
check("node resolves to the node build", () => {
|
||||||
|
assert.match(entry, /dist\/node\/typikon\.js$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a day is a plain object, not a handle", () => {
|
||||||
|
const day = typikon.describeIso("2026-04-12", { jurisdiction: "moscow" });
|
||||||
|
assert.equal(Object.getPrototypeOf(day), Object.prototype);
|
||||||
|
assert.equal(typeof day.free, "undefined");
|
||||||
|
assert.doesNotThrow(() => JSON.stringify(day));
|
||||||
|
});
|
||||||
|
|
||||||
|
check("it answers what the command line answers", () => {
|
||||||
|
for (const [iso, jurisdiction] of [
|
||||||
|
["2026-04-12", "moscow"],
|
||||||
|
["2026-01-07", "goarch"],
|
||||||
|
["2026-02-16", "ruthenian"],
|
||||||
|
["2025-12-25", "old"],
|
||||||
|
["2026-08-15", "antiochian"],
|
||||||
|
]) {
|
||||||
|
const expected = JSON.parse(
|
||||||
|
execFileSync(cli, ["--date", iso, "--jurisdiction", jurisdiction, "--json"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, XDG_CONFIG_HOME: stage, XDG_DATA_HOME: stage },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const got = typikon.describeIso(iso, { jurisdiction });
|
||||||
|
assert.deepEqual(got, expected, `${iso} ${jurisdiction}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a year of days matches the command line, day for day", () => {
|
||||||
|
// Five dates prove the wiring; a year proves the engine crossed the boundary
|
||||||
|
// whole. The CLI emits an array for --days, so this is one process per
|
||||||
|
// jurisdiction rather than one per day.
|
||||||
|
for (const [jurisdiction, opts] of [
|
||||||
|
["moscow", {}],
|
||||||
|
["goarch", {}],
|
||||||
|
["ruthenian", { typikon: true }],
|
||||||
|
["finnish", {}],
|
||||||
|
]) {
|
||||||
|
const args = ["--date", "2026-01-01", "--days", "365", "--jurisdiction", jurisdiction, "--json"];
|
||||||
|
if (opts.typikon) args.push("--typikon");
|
||||||
|
const expected = JSON.parse(
|
||||||
|
execFileSync(cli, args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 64 << 20,
|
||||||
|
env: { ...process.env, XDG_CONFIG_HOME: stage, XDG_DATA_HOME: stage },
|
||||||
|
}),
|
||||||
|
).days; // a run of days is wrapped, a single day is not
|
||||||
|
const got = typikon.week(new Date(2026, 0, 1), 365, { jurisdiction, ...opts });
|
||||||
|
assert.equal(got.length, expected.length, jurisdiction);
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
assert.deepEqual(got[i], expected[i], `${jurisdiction} ${expected[i].date}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a Date and an ISO string agree", () => {
|
||||||
|
// JS months are zero-based, which is the single easiest thing to get wrong
|
||||||
|
// on this boundary.
|
||||||
|
const a = typikon.describe(new Date(2026, 3, 12), { jurisdiction: "moscow" });
|
||||||
|
const b = typikon.describeIso("2026-04-12", { jurisdiction: "moscow" });
|
||||||
|
assert.deepEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
check("options are optional", () => {
|
||||||
|
const day = typikon.today();
|
||||||
|
assert.equal(day.jurisdiction, "new");
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a week is consecutive and matches day by day", () => {
|
||||||
|
const days = typikon.week(new Date(2026, 3, 12), 7, { jurisdiction: "moscow" });
|
||||||
|
assert.equal(days.length, 7);
|
||||||
|
assert.equal(days[0].date, "2026-04-12");
|
||||||
|
assert.equal(days[6].date, "2026-04-18");
|
||||||
|
for (const d of days) {
|
||||||
|
assert.deepEqual(d, typikon.describeIso(d.date, { jurisdiction: "moscow" }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check("pascha matches the published dates", () => {
|
||||||
|
const iso = (d) =>
|
||||||
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
assert.equal(iso(typikon.pascha(2026, "old")), "2026-04-12");
|
||||||
|
assert.equal(iso(typikon.pascha(2026, "western")), "2026-04-05");
|
||||||
|
assert.equal(iso(typikon.pascha(2027, "old")), "2027-05-02");
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a two-digit year is that year, not 19xx", () => {
|
||||||
|
// The Date constructor reads 0..99 as 1900 + n, which would have made the
|
||||||
|
// Paschalion for the year 50 answer for 1950 without a word.
|
||||||
|
assert.equal(typikon.pascha(50, "old").getFullYear(), 50);
|
||||||
|
assert.equal(typikon.pascha(99, "old").getFullYear(), 99);
|
||||||
|
assert.equal(typikon.pascha(100, "old").getFullYear(), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
check("every listed jurisdiction reckons", () => {
|
||||||
|
const all = typikon.jurisdictions();
|
||||||
|
assert.ok(all.length > 20);
|
||||||
|
for (const j of all) {
|
||||||
|
assert.equal(typeof j.key, "string");
|
||||||
|
assert.equal(typeof j.name, "string");
|
||||||
|
const day = typikon.describeIso("2026-04-12", { jurisdiction: j.key });
|
||||||
|
assert.equal(day.jurisdiction, j.key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
check("the discipline flag moves what is shown but not what binds", () => {
|
||||||
|
const law = typikon.describeIso("2026-03-04", { jurisdiction: "ruthenian", typikon: false });
|
||||||
|
const full = typikon.describeIso("2026-03-04", { jurisdiction: "ruthenian", typikon: true });
|
||||||
|
assert.notEqual(law.fast, full.fast);
|
||||||
|
assert.equal(law.binds, full.binds);
|
||||||
|
});
|
||||||
|
|
||||||
|
check("the usage can be overridden", () => {
|
||||||
|
const greek = typikon.describeIso("2025-12-09", { jurisdiction: "old", tradition: "greek" });
|
||||||
|
const slavic = typikon.describeIso("2025-12-09", { jurisdiction: "old", tradition: "slavic" });
|
||||||
|
assert.equal(greek.tradition, "greek");
|
||||||
|
assert.equal(slavic.tradition, "slavic");
|
||||||
|
assert.notEqual(greek.fast, slavic.fast);
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a jurisdiction nobody keeps throws", () => {
|
||||||
|
assert.throws(() => typikon.describeIso("2026-04-12", { jurisdiction: "shire" }), /shire/);
|
||||||
|
});
|
||||||
|
|
||||||
|
check("a date outside the years throws", () => {
|
||||||
|
assert.throws(() => typikon.describeIso("not-a-date"));
|
||||||
|
assert.throws(() => typikon.describe(new Date(-5000, 0, 1)));
|
||||||
|
});
|
||||||
|
|
||||||
|
check("the web build is there", () => {
|
||||||
|
assert.match(req.resolve("typikon/web"), /dist\/web\/typikon\.js$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The web build, driven the way a browser drives it. Node can hand it the
|
||||||
|
// bytes, which is enough to prove the glue and the module agree; what it
|
||||||
|
// cannot prove is that a browser will fetch them, and no test here can.
|
||||||
|
{
|
||||||
|
const webEntry = req.resolve("typikon/web");
|
||||||
|
const web = await import(pathToFileURL(webEntry).href);
|
||||||
|
const { readFileSync } = await import("node:fs");
|
||||||
|
await web.default({
|
||||||
|
module_or_path: readFileSync(join(dirname(webEntry), "typikon_bg.wasm")),
|
||||||
|
});
|
||||||
|
check("the web build answers what the node build answers", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
web.describeIso("2026-04-12", { jurisdiction: "moscow" }),
|
||||||
|
typikon.describeIso("2026-04-12", { jurisdiction: "moscow" }),
|
||||||
|
);
|
||||||
|
assert.equal(web.version(), typikon.version());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeScript, if it is to hand. The Python bindings are gated on `mypy
|
||||||
|
// --strict`; this is the same gate, and the same reason: a published type is a
|
||||||
|
// promise, and a promise nobody checks drifts.
|
||||||
|
{
|
||||||
|
const tsc = process.env.TSC ?? "tsc";
|
||||||
|
let have = true;
|
||||||
|
try {
|
||||||
|
execFileSync(tsc, ["--version"], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
have = false;
|
||||||
|
console.log(" skip typescript --strict (no tsc on PATH; set TSC=)");
|
||||||
|
}
|
||||||
|
if (have) {
|
||||||
|
const { copyFileSync } = await import("node:fs");
|
||||||
|
copyFileSync(join(here, "typecheck.ts"), join(stage, "typecheck.ts"));
|
||||||
|
check("the published types pass tsc --strict", () => {
|
||||||
|
execFileSync(
|
||||||
|
tsc,
|
||||||
|
["--strict", "--noEmit", "--target", "es2022", "--module", "nodenext",
|
||||||
|
"--moduleResolution", "nodenext", "typecheck.ts"],
|
||||||
|
{ cwd: stage, stdio: "pipe", encoding: "utf8" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(stage, { recursive: true, force: true });
|
||||||
|
console.log(failures ? `\n${failures} failed` : "\nall passed");
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// The published types, checked the way a TypeScript consumer would meet them.
|
||||||
|
// Run by npm/test.mjs. Nothing here is executed; the point is that `tsc
|
||||||
|
// --strict` accepts it, and rejects the lines below that it should.
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
describeIso,
|
||||||
|
today,
|
||||||
|
week,
|
||||||
|
pascha,
|
||||||
|
jurisdictions,
|
||||||
|
version,
|
||||||
|
type Day,
|
||||||
|
type Options,
|
||||||
|
type JurisdictionInfo,
|
||||||
|
} from "typikon";
|
||||||
|
|
||||||
|
const opts: Options = { jurisdiction: "rocor", tradition: "slavic", typikon: true };
|
||||||
|
|
||||||
|
const day: Day = describeIso("2026-04-12", opts);
|
||||||
|
const byDate: Day = describe(new Date(), { jurisdiction: "moscow" });
|
||||||
|
const now: Day = today();
|
||||||
|
|
||||||
|
const churchDate: string = day.churchDate;
|
||||||
|
const season: string | null = day.season;
|
||||||
|
const commemorations: string[] = day.commemorations;
|
||||||
|
const daysToPascha: number = day.daysToPascha;
|
||||||
|
|
||||||
|
const seven: Day[] = week(new Date(), 7, opts);
|
||||||
|
const p: Date = pascha(2027, "old");
|
||||||
|
const all: JurisdictionInfo[] = jurisdictions();
|
||||||
|
const v: string = version();
|
||||||
|
|
||||||
|
// Referenced so nothing is unused, which --strict would otherwise let pass but
|
||||||
|
// a linter would not.
|
||||||
|
export const _ = { byDate, now, churchDate, season, commemorations, daysToPascha, seven, p, all, v };
|
||||||
|
|
||||||
|
// @ts-expect-error a season may be absent, so it is not a plain string
|
||||||
|
const wrong: string = day.season;
|
||||||
|
// @ts-expect-error there is no such option
|
||||||
|
const badOption: Options = { jurisdictionn: "rocor" };
|
||||||
|
// @ts-expect-error days is a number
|
||||||
|
const badWeek = week(new Date(), "7", opts);
|
||||||
|
|
||||||
|
export const __ = { wrong, badOption, badWeek };
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched"
|
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Day:
|
|||||||
@property
|
@property
|
||||||
def cycle(self) -> str | None: ...
|
def cycle(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
|
def week(self) -> str | None: ...
|
||||||
|
@property
|
||||||
def feast(self) -> str | None: ...
|
def feast(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def rank(self) -> str | None: ...
|
def rank(self) -> str | None: ...
|
||||||
|
|||||||
+490
@@ -0,0 +1,490 @@
|
|||||||
|
//! A C ABI, so this can be embedded rather than spawned.
|
||||||
|
//!
|
||||||
|
//! Every desktop shell that consumes typikon today runs the binary and reads
|
||||||
|
//! its JSON, which costs about 1.5ms and is the right answer for a calendar
|
||||||
|
//! that changes once a day. This exists for the callers that cannot spawn, or
|
||||||
|
//! for whom a subprocess per redraw is the wrong shape: a LuaJIT statusline, a
|
||||||
|
//! C++ panel, anything with an FFI.
|
||||||
|
//!
|
||||||
|
//! # Shape
|
||||||
|
//!
|
||||||
|
//! Dates cross the boundary as **Julian Day Numbers**, so no struct layout is
|
||||||
|
//! part of the ABI and there is nothing to keep in step. `typikon_jdn_from_ymd`
|
||||||
|
//! converts.
|
||||||
|
//!
|
||||||
|
//! A `typikon_day` is opaque and owns its strings. Every accessor returns a
|
||||||
|
//! borrowed, NUL-terminated pointer valid until `typikon_day_free`, or NULL
|
||||||
|
//! where the day has no such field. The full JSON is available too, and it is
|
||||||
|
//! the same document the command line emits, under the same stability promise.
|
||||||
|
//!
|
||||||
|
//! # Errors
|
||||||
|
//!
|
||||||
|
//! A function that can fail returns NULL or 0 and leaves a message in
|
||||||
|
//! `typikon_last_error`, which is thread-local and valid until the next failing
|
||||||
|
//! call on the same thread.
|
||||||
|
//!
|
||||||
|
//! # Threads
|
||||||
|
//!
|
||||||
|
//! Every function is safe to call from any thread. A `typikon_day` is not
|
||||||
|
//! synchronised: do not use one from two threads at once.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ffi::{c_char, c_int, CStr, CString};
|
||||||
|
use std::ptr;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use crate::calendars::Date;
|
||||||
|
use crate::day::{self, Day};
|
||||||
|
use crate::fasting::Obligation;
|
||||||
|
use crate::jurisdictions;
|
||||||
|
|
||||||
|
/// Bumped when the meaning or signature of anything here changes. Additions
|
||||||
|
/// leave it alone.
|
||||||
|
const ABI_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_error(msg: impl Into<Vec<u8>>) {
|
||||||
|
let cs = CString::new(msg).unwrap_or_else(|_| c"error".into());
|
||||||
|
LAST_ERROR.with(|e| *e.borrow_mut() = Some(cs));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_error() {
|
||||||
|
LAST_ERROR.with(|e| *e.borrow_mut() = None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message from the last failing call on this thread, or NULL if the last
|
||||||
|
/// call succeeded. Valid until the next failing call on this thread.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_last_error() -> *const c_char {
|
||||||
|
LAST_ERROR.with(|e| match &*e.borrow() {
|
||||||
|
Some(s) => s.as_ptr(),
|
||||||
|
None => ptr::null(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crate version, as a dotted string. Static; never freed.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_version() -> *const c_char {
|
||||||
|
concat!(env!("CARGO_PKG_VERSION"), "\0").as_ptr() as *const c_char
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ABI version of this header's contract.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_abi_version() -> u32 {
|
||||||
|
ABI_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Safety
|
||||||
|
/// `p` must be NULL or a valid NUL-terminated C string.
|
||||||
|
unsafe fn opt_str<'a>(p: *const c_char) -> Option<&'a str> {
|
||||||
|
if p.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
unsafe { CStr::from_ptr(p) }.to_str().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── dates ──────────────────────────────────
|
||||||
|
|
||||||
|
/// The Julian Day Number for a civil (Gregorian) date, or 0 if there is no
|
||||||
|
/// such date or it falls outside the years this calendar answers for.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_jdn_from_ymd(year: i32, month: u32, day: u32) -> i32 {
|
||||||
|
match Date::from_ymd(year, month, day) {
|
||||||
|
Some(d) => {
|
||||||
|
clear_error();
|
||||||
|
d.jdn()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
set_error(format!("{year:04}-{month:02}-{day:02} is not a date this calendar reaches"));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The civil date for a Julian Day Number. Returns 0 on success and -1 if the
|
||||||
|
/// number is outside the supported years, in which case the out-params are
|
||||||
|
/// untouched. Any out-param may be NULL.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `year`, `month` and `day` must each be NULL or point to writable storage.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_ymd_from_jdn(
|
||||||
|
jdn: i32,
|
||||||
|
year: *mut i32,
|
||||||
|
month: *mut u32,
|
||||||
|
day: *mut u32,
|
||||||
|
) -> c_int {
|
||||||
|
let Some(d) = Date::from_jdn(jdn) else {
|
||||||
|
set_error(format!("julian day {jdn} is outside the supported years"));
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
clear_error();
|
||||||
|
let (y, m, dd) = d.ymd();
|
||||||
|
unsafe {
|
||||||
|
if !year.is_null() {
|
||||||
|
*year = y;
|
||||||
|
}
|
||||||
|
if !month.is_null() {
|
||||||
|
*month = m;
|
||||||
|
}
|
||||||
|
if !day.is_null() {
|
||||||
|
*day = dd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today's Julian Day Number, in local time.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_today() -> i32 {
|
||||||
|
clear_error();
|
||||||
|
crate::local_today().jdn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── a day ──────────────────────────────────
|
||||||
|
|
||||||
|
/// One reckoned day. Opaque: its layout is not part of the ABI.
|
||||||
|
///
|
||||||
|
/// Named in C's style rather than Rust's, because the name is what appears in
|
||||||
|
/// the header and a caller writing C should not have to translate.
|
||||||
|
#[allow(non_camel_case_types)]
|
||||||
|
pub struct typikon_day {
|
||||||
|
inner: Day,
|
||||||
|
/// Every string the accessors hand out, built once when the day is
|
||||||
|
/// reckoned. An earlier version allocated on each call and kept the
|
||||||
|
/// allocation alive until free, so a statusline polling `title` in a loop
|
||||||
|
/// grew without bound.
|
||||||
|
json: CString,
|
||||||
|
iso: CString,
|
||||||
|
weekday: CString,
|
||||||
|
jurisdiction: CString,
|
||||||
|
calendar: CString,
|
||||||
|
tradition: CString,
|
||||||
|
church_date: CString,
|
||||||
|
title: CString,
|
||||||
|
fast: CString,
|
||||||
|
fast_label: CString,
|
||||||
|
emoji: CString,
|
||||||
|
reason: CString,
|
||||||
|
season: Option<CString>,
|
||||||
|
cycle: Option<CString>,
|
||||||
|
week: Option<CString>,
|
||||||
|
feast: Option<CString>,
|
||||||
|
rank: Option<CString>,
|
||||||
|
sign: Option<CString>,
|
||||||
|
obligation: Option<CString>,
|
||||||
|
binds: Option<CString>,
|
||||||
|
commemorations: Vec<CString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn build(
|
||||||
|
jdn: i32,
|
||||||
|
jurisdiction: Option<&str>,
|
||||||
|
tradition: Option<&str>,
|
||||||
|
discipline: c_int,
|
||||||
|
) -> Option<Box<typikon_day>> {
|
||||||
|
let Some(date) = Date::from_jdn(jdn) else {
|
||||||
|
set_error(format!("julian day {jdn} is outside the supported years"));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -1 asks for whatever the config remembers; 0 and 1 override it.
|
||||||
|
let typikon = match discipline {
|
||||||
|
d if d < 0 => None,
|
||||||
|
0 => Some(false),
|
||||||
|
_ => Some(true),
|
||||||
|
};
|
||||||
|
let jur = match jurisdictions::resolve_full(jurisdiction, tradition, typikon) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
set_error(e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clear_error();
|
||||||
|
let inner = day::describe(date, jur);
|
||||||
|
let cs = |s: &str| CString::new(s).unwrap_or_default();
|
||||||
|
Some(Box::new(typikon_day {
|
||||||
|
json: cs(&serde_json::to_string(&inner.to_json()).ok()?),
|
||||||
|
iso: cs(&inner.date.iso()),
|
||||||
|
weekday: cs(inner.weekday.name()),
|
||||||
|
jurisdiction: cs(inner.jurisdiction),
|
||||||
|
calendar: cs(inner.calendar),
|
||||||
|
tradition: cs(inner.tradition),
|
||||||
|
church_date: cs(&inner.church_date),
|
||||||
|
title: cs(inner.title()),
|
||||||
|
fast: cs(inner.fast.key()),
|
||||||
|
fast_label: cs(inner.fast_label),
|
||||||
|
emoji: cs(inner.emoji),
|
||||||
|
reason: cs(&inner.reason),
|
||||||
|
season: inner.season.map(|s| cs(s.name())),
|
||||||
|
cycle: inner.cycle.as_deref().map(&cs),
|
||||||
|
week: inner.week.as_deref().map(&cs),
|
||||||
|
feast: inner.feast.as_deref().map(&cs),
|
||||||
|
rank: inner.rank.map(|r| cs(r.key())),
|
||||||
|
sign: inner.rank.map(|r| cs(r.sign())),
|
||||||
|
obligation: inner.obligation.map(|o| {
|
||||||
|
cs(match o {
|
||||||
|
Obligation::Obligatory => "obligatory",
|
||||||
|
Obligation::Customary => "customary",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
binds: inner.binds.map(|b| cs(b.key())),
|
||||||
|
commemorations: inner.commemorations.iter().map(|c| cs(c)).collect(),
|
||||||
|
inner,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reckon a day. `jurisdiction` may be NULL to use whatever
|
||||||
|
/// `typikon --set-default` remembered. Returns NULL on error; see
|
||||||
|
/// `typikon_last_error`. Free it with `typikon_day_free`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `jurisdiction` must be NULL or a valid NUL-terminated C string.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_describe(
|
||||||
|
jdn: i32,
|
||||||
|
jurisdiction: *const c_char,
|
||||||
|
) -> *mut typikon_day {
|
||||||
|
match build(jdn, unsafe { opt_str(jurisdiction) }, None, -1) {
|
||||||
|
Some(d) => Box::into_raw(d),
|
||||||
|
None => ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As `typikon_describe`, overriding the usage and the discipline.
|
||||||
|
///
|
||||||
|
/// `tradition` is "slavic", "greek", or NULL to take the jurisdiction's own.
|
||||||
|
/// `discipline` is 1 to show the day at the Typikon's full discipline, 0 to
|
||||||
|
/// show only what the church's particular law requires, and -1 to use whatever
|
||||||
|
/// was remembered.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `jurisdiction` and `tradition` must each be NULL or a valid NUL-terminated
|
||||||
|
/// C string.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_describe_full(
|
||||||
|
jdn: i32,
|
||||||
|
jurisdiction: *const c_char,
|
||||||
|
tradition: *const c_char,
|
||||||
|
discipline: c_int,
|
||||||
|
) -> *mut typikon_day {
|
||||||
|
let (j, t) = unsafe { (opt_str(jurisdiction), opt_str(tradition)) };
|
||||||
|
match build(jdn, j, t, discipline) {
|
||||||
|
Some(d) => Box::into_raw(d),
|
||||||
|
None => ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free a day. NULL is accepted and ignored.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be NULL or a pointer returned by `typikon_describe` or
|
||||||
|
/// `typikon_describe_full` and not already freed.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_free(day: *mut typikon_day) {
|
||||||
|
if !day.is_null() {
|
||||||
|
drop(unsafe { Box::from_raw(day) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! accessor {
|
||||||
|
($name:ident, $doc:literal, |$d:ident| $body:expr) => {
|
||||||
|
#[doc = $doc]
|
||||||
|
///
|
||||||
|
/// Borrowed and NUL-terminated; valid until `typikon_day_free`. The
|
||||||
|
/// same pointer is returned every time, so calling this in a loop
|
||||||
|
/// allocates nothing.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be NULL or a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn $name(day: *const typikon_day) -> *const c_char {
|
||||||
|
let Some($d) = (unsafe { day.as_ref() }) else {
|
||||||
|
return ptr::null();
|
||||||
|
};
|
||||||
|
let value: Option<&CString> = $body;
|
||||||
|
value.map_or(ptr::null(), |v| v.as_ptr())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
accessor!(typikon_day_json, "The whole day as JSON, the same document the command line emits.", |d| Some(&d.json));
|
||||||
|
accessor!(typikon_day_iso, "The civil date, as \"2026-04-12\".", |d| Some(&d.iso));
|
||||||
|
accessor!(typikon_day_weekday, "The day of the week, in English.", |d| Some(&d.weekday));
|
||||||
|
accessor!(typikon_day_jurisdiction, "The key of the church whose reckoning this is, as `typikon_describe` takes it.", |d| Some(&d.jurisdiction));
|
||||||
|
accessor!(typikon_day_calendar, "The calendar: old, new or gregorian.", |d| Some(&d.calendar));
|
||||||
|
accessor!(typikon_day_tradition, "The usage the day was read under: slavic or greek.", |d| Some(&d.tradition));
|
||||||
|
accessor!(typikon_day_church_date, "The ecclesiastical date, as \"25 December\".", |d| Some(&d.church_date));
|
||||||
|
accessor!(typikon_day_title, "The feast, or the day's principal commemoration.", |d| Some(&d.title));
|
||||||
|
accessor!(typikon_day_fast, "The fasting level: strict, xerophagy, wine_oil, fish, dairy or none.", |d| Some(&d.fast));
|
||||||
|
accessor!(typikon_day_fast_label, "The fasting level as this tradition words it.", |d| Some(&d.fast_label));
|
||||||
|
accessor!(typikon_day_emoji, "The glyph for the fasting level.", |d| Some(&d.emoji));
|
||||||
|
accessor!(typikon_day_reason, "Why the day fasts as it does.", |d| Some(&d.reason));
|
||||||
|
accessor!(typikon_day_season, "The season, or NULL in ordinary time.", |d| d.season.as_ref());
|
||||||
|
accessor!(typikon_day_cycle, "The place in the movable cycle, or NULL outside it.", |d| d.cycle.as_ref());
|
||||||
|
accessor!(typikon_day_week, "The count of weeks from Pentecost, or NULL outside it.", |d| d.week.as_ref());
|
||||||
|
accessor!(typikon_day_feast, "The feast, or NULL if the day has none.", |d| d.feast.as_ref());
|
||||||
|
accessor!(typikon_day_rank, "The rank of the feast, or NULL if the day has none.", |d| d.rank.as_ref());
|
||||||
|
accessor!(typikon_day_sign, "The mark the books print beside a day of this rank, or NULL.", |d| d.sign.as_ref());
|
||||||
|
accessor!(typikon_day_obligation, "obligatory, customary, or NULL where the church does not legislate the day.", |d| d.obligation.as_ref());
|
||||||
|
accessor!(typikon_day_binds, "The minimum a particular law requires, or NULL where nothing binds.", |d| d.binds.as_ref());
|
||||||
|
|
||||||
|
/// Whether the day's fast binds under the church's own particular law.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_is_obligatory(day: *const typikon_day) -> c_int {
|
||||||
|
match unsafe { day.as_ref() } {
|
||||||
|
Some(d) => d.inner.is_obligatory() as c_int,
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days to Pascha, signed, counting to the Pascha of this day's own civil
|
||||||
|
/// year. Negative once it has passed.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_days_to_pascha(day: *const typikon_day) -> i32 {
|
||||||
|
match unsafe { day.as_ref() } {
|
||||||
|
Some(d) => d.inner.days_to_pascha,
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Julian Day Number of Pascha in this day's civil year, or 0 if `day` is
|
||||||
|
/// NULL.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_pascha(day: *const typikon_day) -> i32 {
|
||||||
|
match unsafe { day.as_ref() } {
|
||||||
|
Some(d) => d.inner.pascha.jdn(),
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many commemorations the day carries.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be NULL or a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_commemoration_count(day: *const typikon_day) -> u32 {
|
||||||
|
match unsafe { day.as_ref() } {
|
||||||
|
Some(d) => d.commemorations.len() as u32,
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `i`th commemoration, or NULL if there is no such one. Borrowed, and
|
||||||
|
/// valid until `typikon_day_free`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be NULL or a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_commemoration(
|
||||||
|
day: *const typikon_day,
|
||||||
|
i: u32,
|
||||||
|
) -> *const c_char {
|
||||||
|
match unsafe { day.as_ref() } {
|
||||||
|
Some(d) => d
|
||||||
|
.commemorations
|
||||||
|
.get(i as usize)
|
||||||
|
.map_or(ptr::null(), |c| c.as_ptr()),
|
||||||
|
None => ptr::null(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the Julian reckoning currently lags the Gregorian one: 13 until 14
|
||||||
|
/// March 2100, 14 after it. Reported for every jurisdiction, including those
|
||||||
|
/// that do not use it.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `day` must be NULL or a live pointer from `typikon_describe`.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_day_julian_offset(day: *const typikon_day) -> i32 {
|
||||||
|
match unsafe { day.as_ref() } {
|
||||||
|
Some(d) => d.inner.julian_offset,
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Julian Day Number of Pascha in a given civil year, by a given
|
||||||
|
/// jurisdiction's paschalion. Returns 0 on error.
|
||||||
|
///
|
||||||
|
/// Tradition and discipline cannot move Pascha, so they are not arguments.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `jurisdiction` must be NULL or a valid NUL-terminated C string.
|
||||||
|
#[no_mangle]
|
||||||
|
pub unsafe extern "C" fn typikon_pascha(year: i32, jurisdiction: *const c_char) -> i32 {
|
||||||
|
let jur = match jurisdictions::resolve_full(unsafe { opt_str(jurisdiction) }, None, None) {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(e) => {
|
||||||
|
set_error(e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (movables, _) = day::year_of(year, jur);
|
||||||
|
clear_error();
|
||||||
|
movables.pascha.jdn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────── jurisdictions ──────────────────────────────
|
||||||
|
|
||||||
|
/// Key, name and note for every jurisdiction, NUL-terminated once and kept.
|
||||||
|
/// The table is a compile-time constant, so this is built on first use and
|
||||||
|
/// never changes.
|
||||||
|
fn jurisdiction_table() -> &'static [(CString, CString, CString)] {
|
||||||
|
static TABLE: OnceLock<Vec<(CString, CString, CString)>> = OnceLock::new();
|
||||||
|
TABLE.get_or_init(|| {
|
||||||
|
let cs = |s: &str| CString::new(s).unwrap_or_default();
|
||||||
|
jurisdictions::JURISDICTIONS
|
||||||
|
.iter()
|
||||||
|
.chain(jurisdictions::CALENDARS)
|
||||||
|
.map(|j| (cs(j.key), cs(j.name), cs(j.note)))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many jurisdictions this build knows. The named churches come first,
|
||||||
|
/// then the three bare calendars.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_jurisdiction_count() -> u32 {
|
||||||
|
jurisdiction_table().len() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `i`th jurisdiction's key, the one `typikon_describe` takes, or NULL if
|
||||||
|
/// there is no such one. Static storage; never freed.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_jurisdiction_key(i: u32) -> *const c_char {
|
||||||
|
jurisdiction_table()
|
||||||
|
.get(i as usize)
|
||||||
|
.map_or(ptr::null(), |j| j.0.as_ptr())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `i`th jurisdiction's name, as it would be printed. Static storage.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_jurisdiction_name(i: u32) -> *const c_char {
|
||||||
|
jurisdiction_table()
|
||||||
|
.get(i as usize)
|
||||||
|
.map_or(ptr::null(), |j| j.1.as_ptr())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is worth knowing about the `i`th jurisdiction's reckoning, which is
|
||||||
|
/// often the empty string. Static storage.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn typikon_jurisdiction_note(i: u32) -> *const c_char {
|
||||||
|
jurisdiction_table()
|
||||||
|
.get(i as usize)
|
||||||
|
.map_or(ptr::null(), |j| j.2.as_ptr())
|
||||||
|
}
|
||||||
+18
-1
@@ -11,7 +11,7 @@ use crate::calendars::{Date, Weekday, MONTHS};
|
|||||||
use crate::fasting::{Fast, Level, Obligation};
|
use crate::fasting::{Fast, Level, Obligation};
|
||||||
use crate::jurisdictions::Jurisdiction;
|
use crate::jurisdictions::Jurisdiction;
|
||||||
use crate::menologion::{self, Menologion, Rank};
|
use crate::menologion::{self, Menologion, Rank};
|
||||||
use crate::paschalion::{movable_cycle, Movables};
|
use crate::paschalion::{movable_cycle, week_after_pentecost_label, Movables};
|
||||||
use crate::rules::{self, Context};
|
use crate::rules::{self, Context};
|
||||||
use crate::seasons::season_for;
|
use crate::seasons::season_for;
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@ pub struct Day {
|
|||||||
pub church_date: String,
|
pub church_date: String,
|
||||||
pub season: Option<crate::seasons::Season>,
|
pub season: Option<crate::seasons::Season>,
|
||||||
pub cycle: Option<String>,
|
pub cycle: Option<String>,
|
||||||
|
/// Where the day falls in the count of weeks from Pentecost, worded: "the
|
||||||
|
/// 32nd Sunday after Pentecost". `None` through the Triodion and the
|
||||||
|
/// Pentecostarion, whose Sundays have names instead of numbers.
|
||||||
|
pub week: Option<String>,
|
||||||
pub feast: Option<String>,
|
pub feast: Option<String>,
|
||||||
pub rank: Option<Rank>,
|
pub rank: Option<Rank>,
|
||||||
pub tradition: &'static str,
|
pub tradition: &'static str,
|
||||||
@@ -81,6 +85,7 @@ pub struct DayJson<'a> {
|
|||||||
pub church_date: &'a str,
|
pub church_date: &'a str,
|
||||||
pub season: Option<&'a str>,
|
pub season: Option<&'a str>,
|
||||||
pub cycle: Option<&'a str>,
|
pub cycle: Option<&'a str>,
|
||||||
|
pub week: Option<&'a str>,
|
||||||
pub feast: Option<&'a str>,
|
pub feast: Option<&'a str>,
|
||||||
pub rank: Option<&'a str>,
|
pub rank: Option<&'a str>,
|
||||||
/// The mark the books print beside a day of this rank.
|
/// The mark the books print beside a day of this rank.
|
||||||
@@ -109,6 +114,7 @@ impl Day {
|
|||||||
church_date: &self.church_date,
|
church_date: &self.church_date,
|
||||||
season: self.season.map(|s| s.name()),
|
season: self.season.map(|s| s.name()),
|
||||||
cycle: self.cycle.as_deref(),
|
cycle: self.cycle.as_deref(),
|
||||||
|
week: self.week.as_deref(),
|
||||||
feast: self.feast.as_deref(),
|
feast: self.feast.as_deref(),
|
||||||
rank: self.rank.map(|r| r.key()),
|
rank: self.rank.map(|r| r.key()),
|
||||||
sign: self.rank.map(|r| r.sign()),
|
sign: self.rank.map(|r| r.sign()),
|
||||||
@@ -182,6 +188,16 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
|
|||||||
(None, fixed) => fixed.clone(),
|
(None, fixed) => fixed.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The Sundays counted from the Exaltation, the Nativity and the Theophany
|
||||||
|
// join the day rather than naming it: the books print the saint and the
|
||||||
|
// Sunday both, and the saint keeps the title.
|
||||||
|
if let Some(sunday) = menologion::fixed_cycle_sunday((fm, fd), date.weekday()) {
|
||||||
|
let sunday = sunday.to_string();
|
||||||
|
if !commemorations.contains(&sunday) && feast.as_deref() != Some(sunday.as_str()) {
|
||||||
|
commemorations.insert(1.min(commemorations.len()), sunday);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ctx = Context {
|
let ctx = Context {
|
||||||
civil: date,
|
civil: date,
|
||||||
fixed: (fm, fd),
|
fixed: (fm, fd),
|
||||||
@@ -228,6 +244,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
|
|||||||
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
|
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
|
||||||
season,
|
season,
|
||||||
cycle: entry.cycle,
|
cycle: entry.cycle,
|
||||||
|
week: week_after_pentecost_label(date, date.weekday(), jur.paschalion),
|
||||||
feast,
|
feast,
|
||||||
rank,
|
rank,
|
||||||
tradition: jur.tradition.key(),
|
tradition: jur.tradition.key(),
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,7 @@ use crate::day::Day;
|
|||||||
|
|
||||||
pub const COLUMNS: &[&str] = &[
|
pub const COLUMNS: &[&str] = &[
|
||||||
"date", "weekday", "jurisdiction", "calendar", "church_date", "season",
|
"date", "weekday", "jurisdiction", "calendar", "church_date", "season",
|
||||||
"cycle", "feast", "rank", "sign", "tradition", "title", "fast", "fast_label", "emoji",
|
"cycle", "week", "feast", "rank", "sign", "tradition", "title", "fast", "fast_label", "emoji",
|
||||||
"reason", "obligation", "binds", "pascha", "days_to_pascha",
|
"reason", "obligation", "binds", "pascha", "days_to_pascha",
|
||||||
"julian_offset", "commemorations",
|
"julian_offset", "commemorations",
|
||||||
];
|
];
|
||||||
@@ -33,6 +33,7 @@ fn cell(day: &Day, column: &str) -> String {
|
|||||||
"church_date" => day.church_date.clone(),
|
"church_date" => day.church_date.clone(),
|
||||||
"season" => day.season.map(|s| s.name()).unwrap_or("").to_string(),
|
"season" => day.season.map(|s| s.name()).unwrap_or("").to_string(),
|
||||||
"cycle" => day.cycle.clone().unwrap_or_default(),
|
"cycle" => day.cycle.clone().unwrap_or_default(),
|
||||||
|
"week" => day.week.clone().unwrap_or_default(),
|
||||||
"feast" => day.feast.clone().unwrap_or_default(),
|
"feast" => day.feast.clone().unwrap_or_default(),
|
||||||
"rank" => day.rank.map(|r| r.key()).unwrap_or("").to_string(),
|
"rank" => day.rank.map(|r| r.key()).unwrap_or("").to_string(),
|
||||||
"sign" => day.rank.map(|r| r.sign()).unwrap_or("").to_string(),
|
"sign" => day.rank.map(|r| r.sign()).unwrap_or("").to_string(),
|
||||||
|
|||||||
+76
-31
@@ -1,14 +1,29 @@
|
|||||||
//! iCalendar, so the calendar can live where the rest of your year does.
|
//! iCalendar, so the calendar can live where the rest of your year does.
|
||||||
//!
|
//!
|
||||||
//! Each day becomes one all-day event. The summary is the feast or the
|
//! Each day becomes one all-day event. The summary is the feast or the
|
||||||
//! principal commemoration; the description carries the fast, the season and
|
//! principal commemoration; the description lists every commemoration, then the
|
||||||
//! the rest of the day's commemorations. Days with nothing to say, no feast and
|
//! fast, then where the day sits in the year.
|
||||||
//! no fast, are left out, because a subscribed calendar that marks all 365 days
|
//!
|
||||||
//! is one nobody keeps.
|
//! By default the days with nothing to say, no feast and no fast, are left out,
|
||||||
|
//! because a subscribed calendar that marks all 365 days is one nobody keeps.
|
||||||
|
//! `Ics::every_day` is for the other kind of reader, who wants the saint of
|
||||||
|
//! each morning and does not mind a full calendar: it gives every day an event,
|
||||||
|
//! ferial ones included.
|
||||||
|
|
||||||
use crate::day::Day;
|
use crate::day::Day;
|
||||||
use crate::fasting::{Level, Obligation};
|
use crate::fasting::{Level, Obligation};
|
||||||
|
|
||||||
|
/// How to write the calendar.
|
||||||
|
pub struct Ics<'a> {
|
||||||
|
/// What a client shows the subscription as.
|
||||||
|
pub name: &'a str,
|
||||||
|
/// `DTSTAMP`, as `YYYYMMDDTHHMMSSZ`.
|
||||||
|
pub stamp: &'a str,
|
||||||
|
/// One event for every day, rather than only the days carrying a feast or
|
||||||
|
/// a fast.
|
||||||
|
pub every_day: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// A count of octets, which is what RFC 5545 measures content lines in.
|
/// A count of octets, which is what RFC 5545 measures content lines in.
|
||||||
///
|
///
|
||||||
/// A newtype because the Python this was ported from folded on *characters*
|
/// A newtype because the Python this was ported from folded on *characters*
|
||||||
@@ -61,54 +76,75 @@ fn fold(line: &str) -> String {
|
|||||||
out.join("\r\n")
|
out.join("\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The calendar as it has always been written: a subscription, thinned to the
|
||||||
|
/// days worth marking.
|
||||||
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
||||||
|
to_ics_with(days, &Ics { name, stamp, every_day: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_ics_with(days: &[Day], opts: &Ics<'_>) -> String {
|
||||||
let mut lines: Vec<String> = vec![
|
let mut lines: Vec<String> = vec![
|
||||||
"BEGIN:VCALENDAR".into(),
|
"BEGIN:VCALENDAR".into(),
|
||||||
"VERSION:2.0".into(),
|
"VERSION:2.0".into(),
|
||||||
"PRODID:-//typikon//EN".into(),
|
"PRODID:-//typikon//EN".into(),
|
||||||
"CALSCALE:GREGORIAN".into(),
|
"CALSCALE:GREGORIAN".into(),
|
||||||
"METHOD:PUBLISH".into(),
|
"METHOD:PUBLISH".into(),
|
||||||
format!("X-WR-CALNAME:{}", escape(name)),
|
format!("X-WR-CALNAME:{}", escape(opts.name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
for d in days {
|
for d in days {
|
||||||
if d.feast.is_none() && d.fast == Level::None {
|
if !opts.every_day && d.feast.is_none() && d.fast == Level::None {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let start = d.date.iso().replace('-', "");
|
let start = d.date.iso().replace('-', "");
|
||||||
let end = (d.date + 1).iso().replace('-', "");
|
let end = (d.date + 1).iso().replace('-', "");
|
||||||
let summary = if d.emoji.is_empty() {
|
// The glyph for the fast and the mark for the rank, in the order the
|
||||||
d.title().to_string()
|
// text view prints them, so one is scannable down a month view and the
|
||||||
} else {
|
// other says at a glance whether the day is kept.
|
||||||
format!("{} {}", d.emoji, d.title())
|
let sign = d.rank.map(|r| r.sign()).unwrap_or("");
|
||||||
};
|
let summary = [d.emoji, sign, d.title()]
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// Paragraphs, joined by a blank line. A calendar client shows this in
|
||||||
|
// a popover a few lines tall, and the saints are what the reader came
|
||||||
|
// for, so they go first.
|
||||||
let mut body: Vec<String> = Vec::new();
|
let mut body: Vec<String> = Vec::new();
|
||||||
if d.fast != Level::None {
|
if !d.commemorations.is_empty() {
|
||||||
body.push(if d.reason.is_empty() {
|
body.push(
|
||||||
format!("Fast: {}", d.fast_label)
|
d.commemorations
|
||||||
} else {
|
.iter()
|
||||||
format!("Fast: {} ({})", d.fast_label, d.reason)
|
.map(|c| format!("\u{2022} {c}"))
|
||||||
});
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut fast = if d.reason.is_empty() {
|
||||||
|
format!("Fast: {}", d.fast_label)
|
||||||
|
} else {
|
||||||
|
format!("Fast: {} ({})", d.fast_label, d.reason)
|
||||||
|
};
|
||||||
if d.obligation == Some(Obligation::Obligatory) {
|
if d.obligation == Some(Obligation::Obligatory) {
|
||||||
body.push("Obligatory.".into());
|
fast.push_str("\nObligatory.");
|
||||||
|
}
|
||||||
|
body.push(fast);
|
||||||
|
|
||||||
|
let mut where_ = Vec::new();
|
||||||
|
if let Some(w) = &d.week {
|
||||||
|
where_.push(capitalised(w));
|
||||||
}
|
}
|
||||||
if let Some(s) = d.season {
|
if let Some(s) = d.season {
|
||||||
body.push(format!("Season: {}", s.name()));
|
where_.push(format!("Season: {}", s.name()));
|
||||||
}
|
}
|
||||||
if let Some(c) = &d.cycle {
|
if let Some(c) = &d.cycle {
|
||||||
let mut chars = c.chars();
|
where_.push(capitalised(c));
|
||||||
let capped = match chars.next() {
|
|
||||||
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
body.push(capped);
|
|
||||||
}
|
|
||||||
body.push(format!("Church date: {}", d.church_date));
|
|
||||||
for c in &d.commemorations {
|
|
||||||
body.push(c.clone());
|
|
||||||
}
|
}
|
||||||
|
where_.push(format!("Church date: {}", d.church_date));
|
||||||
|
body.push(where_.join("\n"));
|
||||||
|
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"BEGIN:VEVENT".into(),
|
"BEGIN:VEVENT".into(),
|
||||||
@@ -123,11 +159,11 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
|||||||
d.tradition,
|
d.tradition,
|
||||||
if d.shown_at_typikon { "typikon" } else { "particular" }
|
if d.shown_at_typikon { "typikon" } else { "particular" }
|
||||||
),
|
),
|
||||||
format!("DTSTAMP:{stamp}"),
|
format!("DTSTAMP:{}", opts.stamp),
|
||||||
format!("DTSTART;VALUE=DATE:{start}"),
|
format!("DTSTART;VALUE=DATE:{start}"),
|
||||||
format!("DTEND;VALUE=DATE:{end}"),
|
format!("DTEND;VALUE=DATE:{end}"),
|
||||||
fold(&format!("SUMMARY:{}", escape(&summary))),
|
fold(&format!("SUMMARY:{}", escape(&summary))),
|
||||||
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n")))),
|
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n\n")))),
|
||||||
"TRANSP:TRANSPARENT".into(),
|
"TRANSP:TRANSPARENT".into(),
|
||||||
"END:VEVENT".into(),
|
"END:VEVENT".into(),
|
||||||
]);
|
]);
|
||||||
@@ -138,3 +174,12 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
|||||||
s.push_str("\r\n");
|
s.push_str("\r\n");
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A phrase written to sit inside a sentence, promoted to the start of a line.
|
||||||
|
fn capitalised(phrase: &str) -> String {
|
||||||
|
let mut chars = phrase.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,5 +3,5 @@ pub mod ics;
|
|||||||
pub mod json;
|
pub mod json;
|
||||||
|
|
||||||
pub use csv::to_delimited;
|
pub use csv::to_delimited;
|
||||||
pub use ics::to_ics;
|
pub use ics::{to_ics, to_ics_with, Ics};
|
||||||
pub use json::to_json;
|
pub use json::to_json;
|
||||||
|
|||||||
@@ -221,3 +221,56 @@ pub fn resolve(name: Option<&str>) -> Result<&'static Jurisdiction, String> {
|
|||||||
.find(|j| j.key == key)
|
.find(|j| j.key == key)
|
||||||
.ok_or_else(|| name.to_string())
|
.ok_or_else(|| name.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a jurisdiction together with the usage and the discipline, the way
|
||||||
|
/// every caller that is not the command line needs it.
|
||||||
|
///
|
||||||
|
/// Shared, not duplicated. The Python bindings and the C ABI had every reason
|
||||||
|
/// to answer identically and one earlier copy of this logic read the config
|
||||||
|
/// file where the CLI read the environment first, so the same machine gave two
|
||||||
|
/// different churches depending on which door you came in by.
|
||||||
|
///
|
||||||
|
/// `jurisdiction` and `tradition` of `None` fall back to what was remembered;
|
||||||
|
/// `typikon` of `None` does the same.
|
||||||
|
///
|
||||||
|
/// Overridden combinations are interned rather than leaked per call. The
|
||||||
|
/// command line leaks one per run and is then done; a library is called in a
|
||||||
|
/// loop, and one leak per call is a slow leak that shows up as a shell's
|
||||||
|
/// resident size climbing all day. The set of combinations is bounded by
|
||||||
|
/// jurisdictions x traditions x discipline, so the map is small and never
|
||||||
|
/// grows past it.
|
||||||
|
pub fn resolve_full(
|
||||||
|
jurisdiction: Option<&str>,
|
||||||
|
tradition: Option<&str>,
|
||||||
|
typikon: Option<bool>,
|
||||||
|
) -> Result<&'static Jurisdiction, String> {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
let saved = crate::config::saved();
|
||||||
|
let (default_name, _) = crate::config::default_jurisdiction();
|
||||||
|
let name = jurisdiction.map(str::to_owned).or(default_name);
|
||||||
|
let base = resolve(name.as_deref()).map_err(|n| format!("unknown jurisdiction {n:?}"))?;
|
||||||
|
|
||||||
|
let trad = match tradition.map(str::to_owned).or(saved.tradition) {
|
||||||
|
None => base.tradition,
|
||||||
|
Some(t) => Tradition::parse(&t)
|
||||||
|
.ok_or_else(|| format!("unknown tradition {t:?}; try slavic or greek"))?,
|
||||||
|
};
|
||||||
|
let full = typikon.unwrap_or(saved.typikon);
|
||||||
|
let discipline = if full { Some(RuleKind::Orthodox) } else { base.discipline };
|
||||||
|
|
||||||
|
if trad == base.tradition && discipline == base.discipline {
|
||||||
|
return Ok(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interned = HashMap<(&'static str, &'static str, bool), &'static Jurisdiction>;
|
||||||
|
static INTERNED: OnceLock<Mutex<Interned>> = OnceLock::new();
|
||||||
|
let mut map = INTERNED
|
||||||
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "jurisdiction cache poisoned".to_string())?;
|
||||||
|
Ok(map.entry((base.key, trad.key(), full)).or_insert_with(|| {
|
||||||
|
Box::leak(Box::new(Jurisdiction { tradition: trad, discipline, ..*base }))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
+47
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::calendars::Date;
|
||||||
|
|
||||||
pub mod calendars;
|
pub mod calendars;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod day;
|
pub mod day;
|
||||||
@@ -12,3 +14,48 @@ pub mod seasons;
|
|||||||
|
|
||||||
#[cfg(feature = "python")]
|
#[cfg(feature = "python")]
|
||||||
mod python;
|
mod python;
|
||||||
|
|
||||||
|
#[cfg(feature = "capi")]
|
||||||
|
pub mod capi;
|
||||||
|
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
pub mod wasm;
|
||||||
|
|
||||||
|
/// Today, from the system clock, in local time.
|
||||||
|
///
|
||||||
|
/// The local offset comes from `date +%z`, because working it out from
|
||||||
|
/// /etc/localtime means parsing TZif and this program would rather shell out
|
||||||
|
/// than carry a time zone database. Where `date` is absent, as in a scratch
|
||||||
|
/// container, the answer is UTC and it says so on stderr rather than quietly
|
||||||
|
/// reporting the wrong day either side of midnight.
|
||||||
|
pub fn local_today() -> Date {
|
||||||
|
let secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let local = secs + local_offset_secs();
|
||||||
|
// Unix epoch is JDN 2440588. The clock cannot reach either bound.
|
||||||
|
Date::from_jdn(2440588 + local.div_euclid(86400) as i32).unwrap_or(Date::MIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds east of UTC, or None when it could not be determined.
|
||||||
|
fn local_offset_secs() -> i64 {
|
||||||
|
let Ok(out) = std::process::Command::new("date").arg("+%z").output() else {
|
||||||
|
eprintln!("typikon: `date` is not on PATH; reckoning today in UTC");
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let s = text.trim();
|
||||||
|
// "+HHMM". Checked as bytes so a short or multi-byte reply cannot panic a
|
||||||
|
// slice, which a str index here would.
|
||||||
|
let b = s.as_bytes();
|
||||||
|
if b.len() == 5 && (b[0] == b'+' || b[0] == b'-') && b[1..].iter().all(u8::is_ascii_digit) {
|
||||||
|
let h = (s[1..3].parse::<i64>()).unwrap_or(0);
|
||||||
|
let m = (s[3..5].parse::<i64>()).unwrap_or(0);
|
||||||
|
let mag = h * 3600 + m * 60;
|
||||||
|
return if b[0] == b'-' { -mag } else { mag };
|
||||||
|
}
|
||||||
|
eprintln!("typikon: could not read the local time offset; reckoning today in UTC");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-41
@@ -5,7 +5,7 @@ use clap::{Parser, ValueEnum};
|
|||||||
use typikon::calendars::Date;
|
use typikon::calendars::Date;
|
||||||
use typikon::config::{self, Source};
|
use typikon::config::{self, Source};
|
||||||
use typikon::day::{week, year_of, Day};
|
use typikon::day::{week, year_of, Day};
|
||||||
use typikon::export::{to_delimited, to_ics, to_json};
|
use typikon::export::{to_delimited, to_ics_with, to_json, Ics};
|
||||||
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
|
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
|
||||||
use typikon::menaia;
|
use typikon::menaia;
|
||||||
use typikon::rules;
|
use typikon::rules;
|
||||||
@@ -67,6 +67,11 @@ struct Args {
|
|||||||
#[arg(long, conflicts_with = "format")]
|
#[arg(long, conflicts_with = "format")]
|
||||||
ics: bool,
|
ics: bool,
|
||||||
|
|
||||||
|
/// Give every day an event in ics, ferias included, rather than only the
|
||||||
|
/// days carrying a feast or a fast
|
||||||
|
#[arg(long)]
|
||||||
|
every_day: bool,
|
||||||
|
|
||||||
/// Leave the glyphs out, for a terminal or a bar without them
|
/// Leave the glyphs out, for a terminal or a bar without them
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_emoji: bool,
|
no_emoji: bool,
|
||||||
@@ -222,6 +227,9 @@ fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
|
|||||||
} else {
|
} else {
|
||||||
println!(" {} {sign}{}", d.emoji, d.title());
|
println!(" {} {sign}{}", d.emoji, d.title());
|
||||||
}
|
}
|
||||||
|
if let Some(w) = &d.week {
|
||||||
|
println!(" {w}");
|
||||||
|
}
|
||||||
if let Some(c) = &d.cycle {
|
if let Some(c) = &d.cycle {
|
||||||
println!(" {c}");
|
println!(" {c}");
|
||||||
}
|
}
|
||||||
@@ -419,7 +427,7 @@ fn run() -> Result<i32, String> {
|
|||||||
let start = match &args.date {
|
let start = match &args.date {
|
||||||
Some(s) => Date::parse_iso(s)
|
Some(s) => Date::parse_iso(s)
|
||||||
.ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
|
.ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
|
||||||
None => today(),
|
None => typikon::local_today(),
|
||||||
};
|
};
|
||||||
let mut days = week(start, args.days, jur);
|
let mut days = week(start, args.days, jur);
|
||||||
if args.no_emoji {
|
if args.no_emoji {
|
||||||
@@ -443,7 +451,12 @@ fn run() -> Result<i32, String> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
Format::Ics => {
|
Format::Ics => {
|
||||||
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
|
let opts = Ics {
|
||||||
|
name: &format!("{} \u{2014} Typikon", jur.name),
|
||||||
|
stamp: &stamp(),
|
||||||
|
every_day: args.every_day,
|
||||||
|
};
|
||||||
|
print!("{}", to_ics_with(&days, &opts));
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
Format::Csv | Format::Tsv => {
|
Format::Csv | Format::Tsv => {
|
||||||
@@ -473,44 +486,6 @@ fn run() -> Result<i32, String> {
|
|||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Today, from the system clock, in local time.
|
|
||||||
///
|
|
||||||
/// The local offset comes from `date +%z`, because working it out from
|
|
||||||
/// /etc/localtime means parsing TZif and this program would rather shell out
|
|
||||||
/// than carry a time zone database. Where `date` is absent, as in a scratch
|
|
||||||
/// container, the answer is UTC and it says so on stderr rather than quietly
|
|
||||||
/// reporting the wrong day either side of midnight.
|
|
||||||
fn today() -> Date {
|
|
||||||
let secs = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs() as i64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let local = secs + local_offset_secs();
|
|
||||||
// Unix epoch is JDN 2440588. The clock cannot reach either bound.
|
|
||||||
Date::from_jdn(2440588 + local.div_euclid(86400) as i32).unwrap_or(Date::MIN)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seconds east of UTC, or None when it could not be determined.
|
|
||||||
fn local_offset_secs() -> i64 {
|
|
||||||
let Ok(out) = std::process::Command::new("date").arg("+%z").output() else {
|
|
||||||
eprintln!("typikon: `date` is not on PATH; reckoning today in UTC");
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
let s = text.trim();
|
|
||||||
// "+HHMM". Checked as bytes so a short or multi-byte reply cannot panic a
|
|
||||||
// slice, which a str index here would.
|
|
||||||
let b = s.as_bytes();
|
|
||||||
if b.len() == 5 && (b[0] == b'+' || b[0] == b'-') && b[1..].iter().all(u8::is_ascii_digit) {
|
|
||||||
let h = (s[1..3].parse::<i64>()).unwrap_or(0);
|
|
||||||
let m = (s[3..5].parse::<i64>()).unwrap_or(0);
|
|
||||||
let mag = h * 3600 + m * 60;
|
|
||||||
return if b[0] == b'-' { -mag } else { mag };
|
|
||||||
}
|
|
||||||
eprintln!("typikon: could not read the local time offset; reckoning today in UTC");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stamp() -> String {
|
fn stamp() -> String {
|
||||||
let out = std::process::Command::new("date")
|
let out = std::process::Command::new("date")
|
||||||
.args(["-u", "+%Y%m%dT%H%M%SZ"])
|
.args(["-u", "+%Y%m%dT%H%M%SZ"])
|
||||||
|
|||||||
+48
-2
@@ -14,7 +14,7 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::calendars::Date;
|
use crate::calendars::{Date, Weekday};
|
||||||
use crate::jurisdictions::Tradition;
|
use crate::jurisdictions::Tradition;
|
||||||
use crate::paschalion::Movables;
|
use crate::paschalion::Movables;
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ pub fn for_jurisdiction(tradition: Tradition, menaia: &[&str]) -> Menologion {
|
|||||||
/// The movable feasts, named by their offset from Pascha. These outrank
|
/// The movable feasts, named by their offset from Pascha. These outrank
|
||||||
/// whatever the fixed calendar holds for the day.
|
/// whatever the fixed calendar holds for the day.
|
||||||
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
|
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
|
||||||
let table: [(Date, &'static str, Option<Rank>); 24] = [
|
let table: [(Date, &'static str, Option<Rank>); 27] = [
|
||||||
(mv.pascha, "The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha", Some(Rank::Great)),
|
(mv.pascha, "The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha", Some(Rank::Great)),
|
||||||
(mv.palm_sunday, "The Entry of our Lord into Jerusalem", Some(Rank::Great)),
|
(mv.palm_sunday, "The Entry of our Lord into Jerusalem", Some(Rank::Great)),
|
||||||
(mv.ascension, "The Ascension of our Lord, God and Saviour Jesus Christ", Some(Rank::Great)),
|
(mv.ascension, "The Ascension of our Lord, God and Saviour Jesus Christ", Some(Rank::Great)),
|
||||||
@@ -223,6 +223,9 @@ pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Op
|
|||||||
(mv.holy_saturday, "Great and Holy Saturday", Some(Rank::Vigil)),
|
(mv.holy_saturday, "Great and Holy Saturday", Some(Rank::Vigil)),
|
||||||
(mv.thomas_sunday, "Thomas Sunday \u{2014} the Antipascha", Some(Rank::Polyeleos)),
|
(mv.thomas_sunday, "Thomas Sunday \u{2014} the Antipascha", Some(Rank::Polyeleos)),
|
||||||
(mv.myrrhbearers, "The Sunday of the Holy Myrrh-bearing Women", Some(Rank::Polyeleos)),
|
(mv.myrrhbearers, "The Sunday of the Holy Myrrh-bearing Women", Some(Rank::Polyeleos)),
|
||||||
|
(mv.paralytic, "The Sunday of the Paralytic", Some(Rank::Polyeleos)),
|
||||||
|
(mv.samaritan_woman, "The Sunday of the Samaritan Woman", Some(Rank::Polyeleos)),
|
||||||
|
(mv.blind_man, "The Sunday of the Blind Man", Some(Rank::Polyeleos)),
|
||||||
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
|
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
|
||||||
(mv.all_saints, "The Sunday of All Saints", Some(Rank::Polyeleos)),
|
(mv.all_saints, "The Sunday of All Saints", Some(Rank::Polyeleos)),
|
||||||
(mv.publican_pharisee, "The Sunday of the Publican and the Pharisee", None),
|
(mv.publican_pharisee, "The Sunday of the Publican and the Pharisee", None),
|
||||||
@@ -245,3 +248,46 @@ pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Op
|
|||||||
.find(|(d, _, _)| *d == civil)
|
.find(|(d, _, _)| *d == civil)
|
||||||
.map(|(_, title, rank)| (*title, *rank))
|
.map(|(_, title, rank)| (*title, *rank))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Sundays reckoned from a fixed feast rather than from Pascha.
|
||||||
|
///
|
||||||
|
/// Six of them, hung off the Exaltation, the Nativity and the Theophany. They
|
||||||
|
/// are named by the **ecclesiastical** date, so one table serves the old
|
||||||
|
/// calendar and the new alike, and they are found by the civil weekday,
|
||||||
|
/// because a Sunday is a Sunday in both reckonings.
|
||||||
|
///
|
||||||
|
/// These are commemorations, not feasts: they carry no rank and never displace
|
||||||
|
/// the saint of the day. The books print both, and on 26 December the Synaxis
|
||||||
|
/// of the Theotokos still names the day.
|
||||||
|
///
|
||||||
|
/// Three of the windows are shorter than a week and so can miss their Sunday
|
||||||
|
/// altogether:
|
||||||
|
///
|
||||||
|
/// - The Nativity on a Sunday leaves 26–31 December without one. The Typikon
|
||||||
|
/// settles this: the Sunday after the Nativity is kept on 26 December, and
|
||||||
|
/// that is the one day here that is not a Sunday.
|
||||||
|
/// - 1 January on a Sunday leaves 2–5 January without one, and the Sunday
|
||||||
|
/// before the Theophany is kept on 1 January, with the Circumcision.
|
||||||
|
/// - The Theophany itself on a Sunday, and the Exaltation on a Sunday, leave
|
||||||
|
/// their windows empty with nowhere agreed to put what belongs in them.
|
||||||
|
/// Those two years go unmarked: an answer this calendar cannot defend is
|
||||||
|
/// worse than none.
|
||||||
|
pub fn fixed_cycle_sunday(fixed: (u32, u32), weekday: Weekday) -> Option<&'static str> {
|
||||||
|
let sunday = weekday == Weekday::Sunday;
|
||||||
|
let title = match fixed {
|
||||||
|
(9, 8..=13) if sunday => "The Sunday before the Exaltation",
|
||||||
|
(9, 15..=21) if sunday => "The Sunday after the Exaltation",
|
||||||
|
(12, 11..=17) if sunday => "The Sunday of the Holy Forefathers",
|
||||||
|
(12, 18..=24) if sunday => "The Sunday before the Nativity \u{2014} the Holy Fathers",
|
||||||
|
(12, 26..=31) if sunday => "The Sunday after the Nativity",
|
||||||
|
// 26 December falls on a Monday only in a year whose Nativity was a
|
||||||
|
// Sunday, which is exactly the year the window above is empty.
|
||||||
|
(12, 26) if weekday == Weekday::Monday => "The Sunday after the Nativity",
|
||||||
|
// 2 to 5 January is the window. 1 January is a Sunday only in a year
|
||||||
|
// where that window holds none, so the two fold into one range.
|
||||||
|
(1, 1..=5) if sunday => "The Sunday before the Theophany",
|
||||||
|
(1, 7..=13) if sunday => "The Sunday after the Theophany",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(title)
|
||||||
|
}
|
||||||
|
|||||||
+59
-1
@@ -9,7 +9,7 @@
|
|||||||
//! agree in some years, 2025 falls on 20 April by both, and can diverge by as
|
//! agree in some years, 2025 falls on 20 April by both, and can diverge by as
|
||||||
//! much as five weeks.
|
//! much as five weeks.
|
||||||
|
|
||||||
use crate::calendars::{Date, JulianDate};
|
use crate::calendars::{Date, JulianDate, Weekday, MAX_YEAR, MIN_YEAR};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum Paschalion {
|
pub enum Paschalion {
|
||||||
@@ -95,7 +95,10 @@ pub struct Movables {
|
|||||||
pub bright_week_end: Date,
|
pub bright_week_end: Date,
|
||||||
pub thomas_sunday: Date,
|
pub thomas_sunday: Date,
|
||||||
pub myrrhbearers: Date,
|
pub myrrhbearers: Date,
|
||||||
|
pub paralytic: Date,
|
||||||
pub midfeast: Date,
|
pub midfeast: Date,
|
||||||
|
pub samaritan_woman: Date,
|
||||||
|
pub blind_man: Date,
|
||||||
pub ascension: Date,
|
pub ascension: Date,
|
||||||
pub fathers_first_council: Date,
|
pub fathers_first_council: Date,
|
||||||
pub pentecost: Date,
|
pub pentecost: Date,
|
||||||
@@ -131,7 +134,10 @@ pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
|
|||||||
bright_week_end: d + 6,
|
bright_week_end: d + 6,
|
||||||
thomas_sunday: d + 7,
|
thomas_sunday: d + 7,
|
||||||
myrrhbearers: d + 14,
|
myrrhbearers: d + 14,
|
||||||
|
paralytic: d + 21,
|
||||||
midfeast: d + 24,
|
midfeast: d + 24,
|
||||||
|
samaritan_woman: d + 28,
|
||||||
|
blind_man: d + 35,
|
||||||
ascension: d + 39,
|
ascension: d + 39,
|
||||||
fathers_first_council: d + 42,
|
fathers_first_council: d + 42,
|
||||||
pentecost: d + 49,
|
pentecost: d + 49,
|
||||||
@@ -140,3 +146,55 @@ pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
|
|||||||
apostles_start: d + 57,
|
apostles_start: d + 57,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a day falls in the count of weeks from Pentecost, which is how the
|
||||||
|
/// books head the long ordinary stretch of the year: "the 32nd Sunday after
|
||||||
|
/// Pentecost".
|
||||||
|
///
|
||||||
|
/// The count runs from the Monday after Pentecost to the eve of the next
|
||||||
|
/// Sunday of the Publican and the Pharisee, and it is `None` outside that,
|
||||||
|
/// through the Triodion and the Pentecostarion, where the Sundays have names
|
||||||
|
/// of their own instead of a number.
|
||||||
|
///
|
||||||
|
/// The governing Pentecost is not always the one in this day's civil year: a
|
||||||
|
/// day in January is counted from the Pentecost of the year before. That is
|
||||||
|
/// the one place this differs from `days_to_pascha`, which is always reckoned
|
||||||
|
/// against its own year's Pascha.
|
||||||
|
pub fn week_after_pentecost(date: Date, p: Paschalion) -> Option<i32> {
|
||||||
|
let year = date.year();
|
||||||
|
// A day at either end of the range this calendar answers for would need a
|
||||||
|
// Pascha outside it. One week unlabelled beats a panic.
|
||||||
|
if !(MIN_YEAR + 1..=MAX_YEAR - 1).contains(&year) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let this = movable_cycle(year, p);
|
||||||
|
let (pentecost, next_publican) = if date > this.pentecost {
|
||||||
|
(this.pentecost, movable_cycle(year + 1, p).publican_pharisee)
|
||||||
|
} else {
|
||||||
|
(movable_cycle(year - 1, p).pentecost, this.publican_pharisee)
|
||||||
|
};
|
||||||
|
if date <= pentecost || date >= next_publican {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((date - pentecost + 6) / 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same count, worded. Lowercase and article-first, like `cycle`, because
|
||||||
|
/// both are phrases a front end drops into a sentence of its own.
|
||||||
|
pub fn week_after_pentecost_label(date: Date, weekday: Weekday, p: Paschalion) -> Option<String> {
|
||||||
|
let n = week_after_pentecost(date, p)?;
|
||||||
|
let unit = if weekday == Weekday::Sunday { "Sunday" } else { "week" };
|
||||||
|
Some(format!("the {} {unit} after Pentecost", ordinal(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// English ordinals, for the one place this calendar has to count out loud.
|
||||||
|
fn ordinal(n: i32) -> String {
|
||||||
|
let suffix = match (n % 100, n % 10) {
|
||||||
|
(11..=13, _) => "th",
|
||||||
|
(_, 1) => "st",
|
||||||
|
(_, 2) => "nd",
|
||||||
|
(_, 3) => "rd",
|
||||||
|
_ => "th",
|
||||||
|
};
|
||||||
|
format!("{n}{suffix}")
|
||||||
|
}
|
||||||
|
|||||||
+10
-58
@@ -14,8 +14,6 @@ use pyo3::exceptions::{PyValueError, PyOSError};
|
|||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
use pyo3::types::{PyDate, PyDict, PyList};
|
use pyo3::types::{PyDate, PyDict, PyList};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Mutex, OnceLock};
|
|
||||||
|
|
||||||
use crate::calendars::Date;
|
use crate::calendars::Date;
|
||||||
use crate::day;
|
use crate::day;
|
||||||
@@ -44,67 +42,15 @@ fn from_date<'py>(py: Python<'py>, d: Date) -> PyResult<Bound<'py, PyDate>> {
|
|||||||
PyDate::new(py, y, m as u8, day as u8)
|
PyDate::new(py, y, m as u8, day as u8)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// jurisdiction key, tradition key, and whether the Typikon's full discipline was
|
/// The jurisdiction a call should reckon by, resolved the same way the command
|
||||||
/// asked for — enough to tell one override from another.
|
/// line resolves it: the named one or the remembered default, with the
|
||||||
type Interned = HashMap<(&'static str, &'static str, bool), &'static Jurisdiction>;
|
/// remembered usage and discipline applied on top.
|
||||||
|
|
||||||
/// Build the jurisdiction a call should reckon by: the named one, or the saved
|
|
||||||
/// default, with the saved tradition and discipline applied on top.
|
|
||||||
///
|
|
||||||
/// This mirrors main.rs deliberately and has to keep mirroring it. A Jurisdiction
|
|
||||||
/// is no longer just a key — it carries a tradition and a discipline, and the CLI
|
|
||||||
/// reads all three from config before it reckons anything. A binding that resolved
|
|
||||||
/// only the key would answer a different fast than the command line does on the
|
|
||||||
/// same machine for the same date: xerophagy on a Lenten Wednesday reads as
|
|
||||||
/// wine_oil, which is not a cosmetic difference in a tool whose whole job is to
|
|
||||||
/// say what may be eaten.
|
|
||||||
///
|
|
||||||
/// The overridden jurisdictions are interned rather than leaked per call. main.rs
|
|
||||||
/// leaks one per run and is then done; a library gets called in a loop, and one
|
|
||||||
/// leak per call is a slow leak. The set of combinations is bounded by
|
|
||||||
/// jurisdictions x traditions x discipline, so a map of them is small and never
|
|
||||||
/// grows past it.
|
|
||||||
fn resolve(
|
fn resolve(
|
||||||
jurisdiction: Option<&str>,
|
jurisdiction: Option<&str>,
|
||||||
tradition: Option<&str>,
|
tradition: Option<&str>,
|
||||||
typikon: Option<bool>,
|
typikon: Option<bool>,
|
||||||
) -> PyResult<&'static Jurisdiction> {
|
) -> PyResult<&'static Jurisdiction> {
|
||||||
let saved = crate::config::saved();
|
jurisdictions::resolve_full(jurisdiction, tradition, typikon).map_err(PyValueError::new_err)
|
||||||
|
|
||||||
// The environment beats the file, which is the order main.rs uses. Reading
|
|
||||||
// only the file meant TYPIKON_JURISDICTION moved the command line and left
|
|
||||||
// the bindings behind, so the two answered for different churches on the
|
|
||||||
// same machine, which is the exact failure the note above is about.
|
|
||||||
let (default_name, _) = crate::config::default_jurisdiction();
|
|
||||||
let name = jurisdiction.map(str::to_owned).or(default_name);
|
|
||||||
let base = jurisdictions::resolve(name.as_deref())
|
|
||||||
.map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))?;
|
|
||||||
|
|
||||||
let trad = match tradition.map(str::to_owned).or(saved.tradition) {
|
|
||||||
None => base.tradition,
|
|
||||||
Some(t) => jurisdictions::Tradition::parse(&t).ok_or_else(|| {
|
|
||||||
PyValueError::new_err(format!("unknown tradition {t:?}; try slavic or greek"))
|
|
||||||
})?,
|
|
||||||
};
|
|
||||||
let full = typikon.unwrap_or(saved.typikon);
|
|
||||||
let discipline = if full {
|
|
||||||
Some(jurisdictions::RuleKind::Orthodox)
|
|
||||||
} else {
|
|
||||||
base.discipline
|
|
||||||
};
|
|
||||||
|
|
||||||
if trad == base.tradition && discipline == base.discipline {
|
|
||||||
return Ok(base);
|
|
||||||
}
|
|
||||||
|
|
||||||
static INTERNED: OnceLock<Mutex<Interned>> = OnceLock::new();
|
|
||||||
let mut map = INTERNED
|
|
||||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| PyValueError::new_err("jurisdiction cache poisoned"))?;
|
|
||||||
Ok(map.entry((base.key, trad.key(), full)).or_insert_with(|| {
|
|
||||||
Box::leak(Box::new(Jurisdiction { tradition: trad, discipline, ..*base }))
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One day, fully reckoned.
|
/// One day, fully reckoned.
|
||||||
@@ -160,6 +106,11 @@ impl PyDay {
|
|||||||
self.inner.cycle.as_deref()
|
self.inner.cycle.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn week(&self) -> Option<&str> {
|
||||||
|
self.inner.week.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
#[getter]
|
#[getter]
|
||||||
fn feast(&self) -> Option<&str> {
|
fn feast(&self) -> Option<&str> {
|
||||||
self.inner.feast.as_deref()
|
self.inner.feast.as_deref()
|
||||||
@@ -257,6 +208,7 @@ impl PyDay {
|
|||||||
d.set_item("church_date", j.church_date)?;
|
d.set_item("church_date", j.church_date)?;
|
||||||
d.set_item("season", j.season)?;
|
d.set_item("season", j.season)?;
|
||||||
d.set_item("cycle", j.cycle)?;
|
d.set_item("cycle", j.cycle)?;
|
||||||
|
d.set_item("week", j.week)?;
|
||||||
d.set_item("feast", j.feast)?;
|
d.set_item("feast", j.feast)?;
|
||||||
d.set_item("rank", j.rank)?;
|
d.set_item("rank", j.rank)?;
|
||||||
d.set_item("sign", j.sign)?;
|
d.set_item("sign", j.sign)?;
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
//! JavaScript and TypeScript, through WebAssembly.
|
||||||
|
//!
|
||||||
|
//! # Shape
|
||||||
|
//!
|
||||||
|
//! A day comes back as a **plain object**, not a handle: the same document the
|
||||||
|
//! command line emits under `--json`, with the same field names and the same
|
||||||
|
//! stability promise. Nothing has to be freed, `JSON.stringify` works, and it
|
||||||
|
//! spreads into React state without ceremony. A wasm class would have meant a
|
||||||
|
//! `.free()` that every caller eventually forgets.
|
||||||
|
//!
|
||||||
|
//! # The clock and the config file
|
||||||
|
//!
|
||||||
|
//! WebAssembly has neither. `today()` asks the host what day it is, in the
|
||||||
|
//! host's own time zone, which is what a calendar wants. There is no config
|
||||||
|
//! file to remember a default jurisdiction in, so the default here is the New
|
||||||
|
//! Calendar and a caller who wants another passes it. That is the one place
|
||||||
|
//! this binding differs from the others, and it differs because a browser has
|
||||||
|
//! nowhere to put the answer.
|
||||||
|
//!
|
||||||
|
//! # The menologion
|
||||||
|
//!
|
||||||
|
//! The common menologion is compiled in, so the module is self-contained and
|
||||||
|
//! needs no network. The supplementary sets the command line can fetch are not
|
||||||
|
//! included: a caller that wants one passes it to `setMenaion`.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
use crate::calendars::Date;
|
||||||
|
use crate::day::{self, Day};
|
||||||
|
use crate::jurisdictions::{self, Jurisdiction};
|
||||||
|
|
||||||
|
/// Hand-written because the generated `.d.ts` would otherwise say `any`: a
|
||||||
|
/// binding that types its whole return value as `any` is a binding that
|
||||||
|
/// TypeScript cannot check.
|
||||||
|
#[wasm_bindgen(typescript_custom_section)]
|
||||||
|
const TYPES: &'static str = r#"
|
||||||
|
/** One day, fully reckoned. The same document `typikon --json` emits. */
|
||||||
|
export interface Day {
|
||||||
|
/** The civil date, as "2026-04-12". */
|
||||||
|
date: string;
|
||||||
|
weekday: string;
|
||||||
|
/** The key of the church whose reckoning this is. */
|
||||||
|
jurisdiction: string;
|
||||||
|
/** "old", "new" or "western". */
|
||||||
|
calendar: string;
|
||||||
|
/** The ecclesiastical date, as "25 December". */
|
||||||
|
churchDate: string;
|
||||||
|
/** null in ordinary time. */
|
||||||
|
season: string | null;
|
||||||
|
/** The place in the movable cycle, or null outside it. */
|
||||||
|
cycle: string | null;
|
||||||
|
/** "the 32nd Sunday after Pentecost", or null outside that count. */
|
||||||
|
week: string | null;
|
||||||
|
feast: string | null;
|
||||||
|
/** The rank of the feast, or null where there is none. */
|
||||||
|
rank: string | null;
|
||||||
|
/** The mark the books print beside a day of this rank. */
|
||||||
|
sign: string | null;
|
||||||
|
/** "slavic" or "greek". */
|
||||||
|
tradition: string;
|
||||||
|
/** The feast, or the day's principal commemoration. */
|
||||||
|
title: string;
|
||||||
|
commemorations: string[];
|
||||||
|
/** "strict", "xerophagy", "wine_oil", "fish", "dairy" or "none". */
|
||||||
|
fast: string;
|
||||||
|
/** The level as this tradition words it. */
|
||||||
|
fastLabel: string;
|
||||||
|
/** The glyph for the level. */
|
||||||
|
emoji: string;
|
||||||
|
/** Why the day fasts as it does. */
|
||||||
|
reason: string;
|
||||||
|
/** "obligatory", "customary", or null where the church does not legislate. */
|
||||||
|
obligation: string | null;
|
||||||
|
/** The minimum a particular law requires, or null where nothing binds. */
|
||||||
|
binds: string | null;
|
||||||
|
/** The civil date of Pascha in this day's own year. */
|
||||||
|
pascha: string;
|
||||||
|
/** Signed, to the Pascha of this day's own civil year. */
|
||||||
|
daysToPascha: number;
|
||||||
|
/** How far the Julian reckoning lags the Gregorian: 13 until 14 March 2100. */
|
||||||
|
julianOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whose reckoning to keep, and how much of it to show. */
|
||||||
|
export interface Options {
|
||||||
|
/** A jurisdiction key, or a bare calendar: "old", "new", "western". */
|
||||||
|
jurisdiction?: string;
|
||||||
|
/** "slavic" or "greek", overriding the jurisdiction's own usage. */
|
||||||
|
tradition?: string;
|
||||||
|
/**
|
||||||
|
* True to show the day at the Typikon's full discipline rather than at the
|
||||||
|
* minimum the church's own particular law requires. What binds is reported
|
||||||
|
* either way. This is for the Byzantine Catholic churches, whose law asks
|
||||||
|
* very much less than the tradition they keep.
|
||||||
|
*/
|
||||||
|
typikon?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JurisdictionInfo {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// The options object, as it arrives.
|
||||||
|
#[derive(Default, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
struct Options {
|
||||||
|
jurisdiction: Option<String>,
|
||||||
|
tradition: Option<String>,
|
||||||
|
typikon: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Options {
|
||||||
|
/// The named jurisdiction, or the New Calendar, with the usage and the
|
||||||
|
/// discipline applied.
|
||||||
|
fn resolve(&self) -> Result<&'static Jurisdiction, JsError> {
|
||||||
|
jurisdictions::resolve_full(
|
||||||
|
Some(self.jurisdiction.as_deref().unwrap_or("new")),
|
||||||
|
self.tradition.as_deref(),
|
||||||
|
self.typikon,
|
||||||
|
)
|
||||||
|
.map_err(|e| JsError::new(&e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_options(value: Option<js_sys::Object>) -> Result<Options, JsError> {
|
||||||
|
let Some(value) = value else { return Ok(Options::default()) };
|
||||||
|
serde_wasm_bindgen::from_value(value.into()).map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize with plain JS objects and arrays rather than Maps, and an absent
|
||||||
|
/// field as `null` rather than `undefined`.
|
||||||
|
///
|
||||||
|
/// Both defaults would otherwise break the promise that this is the document
|
||||||
|
/// `--json` emits: `JSON.stringify` drops a key whose value is `undefined`, so
|
||||||
|
/// a day with no feast would round-trip without a `feast` key at all, and the
|
||||||
|
/// declared type says `string | null`.
|
||||||
|
fn to_js<T: Serialize>(value: &T) -> Result<JsValue, JsError> {
|
||||||
|
let ser = serde_wasm_bindgen::Serializer::new()
|
||||||
|
.serialize_maps_as_objects(true)
|
||||||
|
.serialize_missing_as_null(true);
|
||||||
|
value
|
||||||
|
.serialize(&ser)
|
||||||
|
.map_err(|e| JsError::new(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn day_to_js(d: &Day) -> Result<JsValue, JsError> {
|
||||||
|
to_js(&d.to_json())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JS `Date`, read in the host's own time zone.
|
||||||
|
///
|
||||||
|
/// The liturgical day turns at local midnight, so reading UTC would report
|
||||||
|
/// yesterday's fast all evening for anyone west of Greenwich.
|
||||||
|
fn from_js_date(d: &js_sys::Date) -> Result<Date, JsError> {
|
||||||
|
Date::from_ymd(
|
||||||
|
d.get_full_year() as i32,
|
||||||
|
d.get_month() + 1, // JS months are zero-based
|
||||||
|
d.get_date(),
|
||||||
|
)
|
||||||
|
.ok_or_else(|| JsError::new("that is not a date this calendar reaches"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_js_date(d: Date) -> js_sys::Date {
|
||||||
|
let (y, m, day) = d.ymd();
|
||||||
|
let out = js_sys::Date::new_with_year_month_day(y as u32, m as i32 - 1, day as i32);
|
||||||
|
// `new Date(50, 0, 1)` is 1950, not the year 50: the two-digit-year rule is
|
||||||
|
// in the constructor and not in the setter. This calendar answers from the
|
||||||
|
// year 1, so the rule has to be undone rather than lived with.
|
||||||
|
if (0..=99).contains(&y) {
|
||||||
|
out.set_full_year(y as u32);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── the api ─────────────────────────────────
|
||||||
|
|
||||||
|
/// One day, fully reckoned.
|
||||||
|
#[wasm_bindgen(unchecked_return_type = "Day")]
|
||||||
|
pub fn describe(
|
||||||
|
date: &js_sys::Date,
|
||||||
|
#[wasm_bindgen(unchecked_param_type = "Options")] options: Option<js_sys::Object>,
|
||||||
|
) -> Result<JsValue, JsError> {
|
||||||
|
let opts = parse_options(options)?;
|
||||||
|
day_to_js(&day::describe(from_js_date(date)?, opts.resolve()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One day, from an ISO date, as "2026-04-12".
|
||||||
|
#[wasm_bindgen(js_name = describeIso, unchecked_return_type = "Day")]
|
||||||
|
pub fn describe_iso(
|
||||||
|
date: &str,
|
||||||
|
#[wasm_bindgen(unchecked_param_type = "Options")] options: Option<js_sys::Object>,
|
||||||
|
) -> Result<JsValue, JsError> {
|
||||||
|
let opts = parse_options(options)?;
|
||||||
|
let d = Date::parse_iso(date)
|
||||||
|
.ok_or_else(|| JsError::new(&format!("{date:?} is not a date this calendar reaches")))?;
|
||||||
|
day_to_js(&day::describe(d, opts.resolve()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Today, by the host's clock and in the host's time zone.
|
||||||
|
#[wasm_bindgen(unchecked_return_type = "Day")]
|
||||||
|
pub fn today(
|
||||||
|
#[wasm_bindgen(unchecked_param_type = "Options")] options: Option<js_sys::Object>,
|
||||||
|
) -> Result<JsValue, JsError> {
|
||||||
|
describe(&js_sys::Date::new_0(), options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `days` consecutive days from `start`, which is what a week view wants and
|
||||||
|
/// what it would otherwise get by calling `describe` seven times and paying
|
||||||
|
/// for the year's movable cycle each time.
|
||||||
|
#[wasm_bindgen(unchecked_return_type = "Day[]")]
|
||||||
|
pub fn week(
|
||||||
|
start: &js_sys::Date,
|
||||||
|
days: i32,
|
||||||
|
#[wasm_bindgen(unchecked_param_type = "Options")] options: Option<js_sys::Object>,
|
||||||
|
) -> Result<JsValue, JsError> {
|
||||||
|
if !(1..=36600).contains(&days) {
|
||||||
|
return Err(JsError::new("days must be between 1 and 36600"));
|
||||||
|
}
|
||||||
|
let opts = parse_options(options)?;
|
||||||
|
let out = day::week(from_js_date(start)?, days, opts.resolve()?);
|
||||||
|
let arr = js_sys::Array::new();
|
||||||
|
for d in &out {
|
||||||
|
arr.push(&day_to_js(d)?);
|
||||||
|
}
|
||||||
|
Ok(arr.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pascha in a civil year, by a jurisdiction's paschalion. Tradition and
|
||||||
|
/// discipline cannot move it, so they are not arguments.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn pascha(year: i32, jurisdiction: Option<String>) -> Result<js_sys::Date, JsError> {
|
||||||
|
let jur = jurisdictions::resolve_full(
|
||||||
|
Some(jurisdiction.as_deref().unwrap_or("new")),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| JsError::new(&e))?;
|
||||||
|
let (movables, _) = day::year_of(year, jur);
|
||||||
|
Ok(to_js_date(movables.pascha))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every jurisdiction this build knows: the named churches first, then the
|
||||||
|
/// three bare calendars.
|
||||||
|
#[wasm_bindgen(unchecked_return_type = "JurisdictionInfo[]")]
|
||||||
|
pub fn jurisdictions() -> Result<JsValue, JsError> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Info<'a> {
|
||||||
|
key: &'a str,
|
||||||
|
name: &'a str,
|
||||||
|
note: &'a str,
|
||||||
|
}
|
||||||
|
let out: Vec<Info> = jurisdictions::JURISDICTIONS
|
||||||
|
.iter()
|
||||||
|
.chain(jurisdictions::CALENDARS)
|
||||||
|
.map(|j| Info { key: j.key, name: j.name, note: j.note })
|
||||||
|
.collect();
|
||||||
|
to_js(&out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version of the crate this module was built from.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn version() -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
|
}
|
||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
//! The C ABI, exercised as a C caller would use it.
|
||||||
|
//!
|
||||||
|
//! These run only under `--features capi`, which is what builds the module.
|
||||||
|
//! They cover three things a Rust test can check and a C compiler cannot: that
|
||||||
|
//! the answers match what the crate gives natively, that the pointers an
|
||||||
|
//! accessor hands out stay put, and that the header in `include/` still
|
||||||
|
//! describes the library that is actually built.
|
||||||
|
|
||||||
|
#![cfg(feature = "capi")]
|
||||||
|
|
||||||
|
use std::ffi::{c_char, CStr, CString};
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
use typikon::capi::*;
|
||||||
|
|
||||||
|
/// Borrow a returned pointer as a `str`, or `None` for NULL.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Only called on pointers these functions returned, which live until free.
|
||||||
|
fn s<'a>(p: *const c_char) -> Option<&'a str> {
|
||||||
|
if p.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(unsafe { CStr::from_ptr(p) }.to_str().expect("utf-8"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(iso: &str, jurisdiction: &str) -> *mut typikon_day {
|
||||||
|
let (y, m, d) = (
|
||||||
|
iso[0..4].parse().unwrap(),
|
||||||
|
iso[5..7].parse().unwrap(),
|
||||||
|
iso[8..10].parse().unwrap(),
|
||||||
|
);
|
||||||
|
let jdn = typikon_jdn_from_ymd(y, m, d);
|
||||||
|
assert_ne!(jdn, 0, "{iso} should be a date");
|
||||||
|
let key = CString::new(jurisdiction).unwrap();
|
||||||
|
let day = unsafe { typikon_describe(jdn, key.as_ptr()) };
|
||||||
|
assert!(!day.is_null(), "{iso} {jurisdiction} should reckon");
|
||||||
|
day
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── dates ───────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jdn_round_trips() {
|
||||||
|
for (y, m, d) in [(1, 1, 1), (1583, 10, 15), (2026, 4, 12), (9999, 12, 31)] {
|
||||||
|
let jdn = typikon_jdn_from_ymd(y, m, d);
|
||||||
|
assert_ne!(jdn, 0, "{y}-{m}-{d}");
|
||||||
|
let (mut yy, mut mm, mut dd) = (0i32, 0u32, 0u32);
|
||||||
|
let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut yy, &mut mm, &mut dd) };
|
||||||
|
assert_eq!(rc, 0);
|
||||||
|
assert_eq!((yy, mm, dd), (y, m, d));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_impossible_date_is_an_error_not_a_guess() {
|
||||||
|
assert_eq!(typikon_jdn_from_ymd(2026, 2, 30), 0);
|
||||||
|
let msg = s(typikon_last_error()).expect("a message");
|
||||||
|
assert!(msg.contains("2026-02-30"), "{msg}");
|
||||||
|
|
||||||
|
// And the next success clears it, so a caller that only checks the error
|
||||||
|
// pointer cannot read a stale one.
|
||||||
|
assert_ne!(typikon_jdn_from_ymd(2026, 2, 28), 0);
|
||||||
|
assert!(typikon_last_error().is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ymd_out_params_are_each_optional() {
|
||||||
|
let jdn = typikon_jdn_from_ymd(2026, 4, 12);
|
||||||
|
let mut year = 0i32;
|
||||||
|
let rc = unsafe { typikon_ymd_from_jdn(jdn, &mut year, ptr::null_mut(), ptr::null_mut()) };
|
||||||
|
assert_eq!(rc, 0);
|
||||||
|
assert_eq!(year, 2026);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_julian_day_outside_the_years_is_refused() {
|
||||||
|
let mut year = 12345i32;
|
||||||
|
let rc = unsafe { typikon_ymd_from_jdn(-1, &mut year, ptr::null_mut(), ptr::null_mut()) };
|
||||||
|
assert_eq!(rc, -1);
|
||||||
|
assert_eq!(year, 12345, "out-params are untouched on failure");
|
||||||
|
assert!(s(typikon_last_error()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────────────── a day ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_day_answers_what_the_crate_answers() {
|
||||||
|
let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap();
|
||||||
|
let native = typikon::day::describe(
|
||||||
|
typikon::calendars::Date::parse_iso("2026-04-12").unwrap(),
|
||||||
|
jur,
|
||||||
|
);
|
||||||
|
|
||||||
|
let day = describe("2026-04-12", "moscow");
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(s(typikon_day_iso(day)), Some("2026-04-12"));
|
||||||
|
assert_eq!(s(typikon_day_title(day)), Some(native.title()));
|
||||||
|
assert_eq!(s(typikon_day_fast(day)), Some(native.fast.key()));
|
||||||
|
assert_eq!(s(typikon_day_emoji(day)), Some(native.emoji));
|
||||||
|
assert_eq!(s(typikon_day_jurisdiction(day)), Some(native.jurisdiction));
|
||||||
|
assert_eq!(s(typikon_day_church_date(day)), Some(&*native.church_date));
|
||||||
|
assert_eq!(typikon_day_days_to_pascha(day), native.days_to_pascha);
|
||||||
|
assert_eq!(typikon_day_pascha(day), native.pascha.jdn());
|
||||||
|
assert_eq!(typikon_day_julian_offset(day), native.julian_offset);
|
||||||
|
assert_eq!(
|
||||||
|
typikon_day_commemoration_count(day) as usize,
|
||||||
|
native.commemorations.len()
|
||||||
|
);
|
||||||
|
typikon_day_free(day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_json_is_the_document_the_command_line_emits() {
|
||||||
|
let day = describe("2026-01-07", "moscow");
|
||||||
|
let json = s(unsafe { typikon_day_json(day) }).expect("json");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(json).expect("valid json");
|
||||||
|
|
||||||
|
let jur = typikon::jurisdictions::resolve(Some("moscow")).unwrap();
|
||||||
|
let native = typikon::day::describe(
|
||||||
|
typikon::calendars::Date::parse_iso("2026-01-07").unwrap(),
|
||||||
|
jur,
|
||||||
|
);
|
||||||
|
let expected = serde_json::to_value(native.to_json()).unwrap();
|
||||||
|
assert_eq!(parsed, expected);
|
||||||
|
|
||||||
|
unsafe { typikon_day_free(day) };
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_accessor_returns_the_same_pointer_every_time() {
|
||||||
|
// The point of building the strings once: a statusline that reads `title`
|
||||||
|
// on every redraw must not allocate on every redraw. An earlier version
|
||||||
|
// did, and kept every allocation alive until free.
|
||||||
|
let day = describe("2026-04-12", "moscow");
|
||||||
|
unsafe {
|
||||||
|
let first = typikon_day_title(day);
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
assert_eq!(typikon_day_title(day), first);
|
||||||
|
}
|
||||||
|
typikon_day_free(day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_null_day_answers_null_rather_than_crashing() {
|
||||||
|
let n: *const typikon_day = ptr::null();
|
||||||
|
unsafe {
|
||||||
|
assert!(typikon_day_json(n).is_null());
|
||||||
|
assert!(typikon_day_title(n).is_null());
|
||||||
|
assert!(typikon_day_season(n).is_null());
|
||||||
|
assert!(typikon_day_commemoration(n, 0).is_null());
|
||||||
|
assert_eq!(typikon_day_commemoration_count(n), 0);
|
||||||
|
assert_eq!(typikon_day_is_obligatory(n), 0);
|
||||||
|
assert_eq!(typikon_day_days_to_pascha(n), 0);
|
||||||
|
assert_eq!(typikon_day_pascha(n), 0);
|
||||||
|
assert_eq!(typikon_day_julian_offset(n), 0);
|
||||||
|
typikon_day_free(ptr::null_mut()); // accepted and ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_commemoration_past_the_end_is_null() {
|
||||||
|
let day = describe("2026-04-12", "moscow");
|
||||||
|
unsafe {
|
||||||
|
let n = typikon_day_commemoration_count(day);
|
||||||
|
assert!(typikon_day_commemoration(day, n).is_null());
|
||||||
|
assert!(typikon_day_commemoration(day, u32::MAX).is_null());
|
||||||
|
if n > 0 {
|
||||||
|
assert!(!typikon_day_commemoration(day, 0).is_null());
|
||||||
|
}
|
||||||
|
typikon_day_free(day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_jurisdiction_nobody_keeps_is_an_error() {
|
||||||
|
let key = CString::new("shire").unwrap();
|
||||||
|
let day = unsafe { typikon_describe(typikon_today(), key.as_ptr()) };
|
||||||
|
assert!(day.is_null());
|
||||||
|
let msg = s(typikon_last_error()).expect("a message");
|
||||||
|
assert!(msg.contains("shire"), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_null_jurisdiction_takes_the_remembered_default() {
|
||||||
|
let day = unsafe { typikon_describe(typikon_jdn_from_ymd(2026, 4, 12), ptr::null()) };
|
||||||
|
assert!(!day.is_null());
|
||||||
|
unsafe {
|
||||||
|
assert!(s(typikon_day_jurisdiction(day)).is_some());
|
||||||
|
typikon_day_free(day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_discipline_flag_moves_what_is_shown_but_not_what_binds() {
|
||||||
|
// The Byzantine Catholic case the flag exists for: on a Wednesday of the
|
||||||
|
// Great Fast, Ruthenian law requires nothing, while the Typikon it inherits
|
||||||
|
// keeps xerophagy.
|
||||||
|
let jdn = typikon_jdn_from_ymd(2026, 3, 4); // Wednesday, Great Fast
|
||||||
|
let key = CString::new("ruthenian").unwrap();
|
||||||
|
unsafe {
|
||||||
|
let law = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 0);
|
||||||
|
let typikon = typikon_describe_full(jdn, key.as_ptr(), ptr::null(), 1);
|
||||||
|
assert!(!law.is_null() && !typikon.is_null());
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
s(typikon_day_fast(law)),
|
||||||
|
s(typikon_day_fast(typikon)),
|
||||||
|
"the Typikon should keep more than the law requires"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s(typikon_day_binds(law)),
|
||||||
|
s(typikon_day_binds(typikon)),
|
||||||
|
"what binds is the law either way"
|
||||||
|
);
|
||||||
|
typikon_day_free(law);
|
||||||
|
typikon_day_free(typikon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_usage_can_be_overridden() {
|
||||||
|
// Fish in the Nativity Fast: Greek practice permits it on a weekday before
|
||||||
|
// 17 December, Slavic practice keeps it to Saturdays and Sundays.
|
||||||
|
let jdn = typikon_jdn_from_ymd(2025, 12, 9); // a Tuesday
|
||||||
|
let key = CString::new("old").unwrap();
|
||||||
|
let (greek, slavic) = (CString::new("greek").unwrap(), CString::new("slavic").unwrap());
|
||||||
|
unsafe {
|
||||||
|
let g = typikon_describe_full(jdn, key.as_ptr(), greek.as_ptr(), -1);
|
||||||
|
let sl = typikon_describe_full(jdn, key.as_ptr(), slavic.as_ptr(), -1);
|
||||||
|
assert_eq!(s(typikon_day_tradition(g)), Some("greek"));
|
||||||
|
assert_eq!(s(typikon_day_tradition(sl)), Some("slavic"));
|
||||||
|
typikon_day_free(g);
|
||||||
|
typikon_day_free(sl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_usage_is_an_error() {
|
||||||
|
let key = CString::new("moscow").unwrap();
|
||||||
|
let bad = CString::new("frankish").unwrap();
|
||||||
|
let day = unsafe { typikon_describe_full(typikon_today(), key.as_ptr(), bad.as_ptr(), -1) };
|
||||||
|
assert!(day.is_null());
|
||||||
|
assert!(s(typikon_last_error()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────── without a day ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pascha_by_year_matches_the_published_dates() {
|
||||||
|
let old = CString::new("old").unwrap();
|
||||||
|
let west = CString::new("western").unwrap();
|
||||||
|
for (year, julian, gregorian) in [
|
||||||
|
(2026, "2026-04-12", "2026-04-05"),
|
||||||
|
(2027, "2027-05-02", "2027-03-28"),
|
||||||
|
(2028, "2028-04-16", "2028-04-16"),
|
||||||
|
] {
|
||||||
|
for (key, expected) in [(&old, julian), (&west, gregorian)] {
|
||||||
|
let jdn = unsafe { typikon_pascha(year, key.as_ptr()) };
|
||||||
|
let (mut y, mut m, mut d) = (0i32, 0u32, 0u32);
|
||||||
|
assert_eq!(unsafe { typikon_ymd_from_jdn(jdn, &mut y, &mut m, &mut d) }, 0);
|
||||||
|
assert_eq!(format!("{y:04}-{m:02}-{d:02}"), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pascha_refuses_a_jurisdiction_nobody_keeps() {
|
||||||
|
let key = CString::new("shire").unwrap();
|
||||||
|
assert_eq!(unsafe { typikon_pascha(2026, key.as_ptr()) }, 0);
|
||||||
|
assert!(s(typikon_last_error()).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_jurisdiction_is_listed_and_every_listed_key_reckons() {
|
||||||
|
let n = typikon_jurisdiction_count();
|
||||||
|
assert_eq!(
|
||||||
|
n as usize,
|
||||||
|
typikon::jurisdictions::JURISDICTIONS.len() + typikon::jurisdictions::CALENDARS.len()
|
||||||
|
);
|
||||||
|
for i in 0..n {
|
||||||
|
let key = s(typikon_jurisdiction_key(i)).expect("a key");
|
||||||
|
assert!(s(typikon_jurisdiction_name(i)).is_some_and(|x| !x.is_empty()));
|
||||||
|
assert!(s(typikon_jurisdiction_note(i)).is_some(), "note may be empty, not NULL");
|
||||||
|
let day = describe("2026-04-12", key);
|
||||||
|
unsafe { typikon_day_free(day) };
|
||||||
|
}
|
||||||
|
assert!(typikon_jurisdiction_key(n).is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_version_is_the_crate_version() {
|
||||||
|
assert_eq!(s(typikon_version()), Some(env!("CARGO_PKG_VERSION")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── the header ──────────────────────────────
|
||||||
|
|
||||||
|
/// Every symbol the library exports is declared in `include/typikon.h`, and
|
||||||
|
/// every function the header declares is exported.
|
||||||
|
///
|
||||||
|
/// A header is a promise made in a second file, and a second file drifts. This
|
||||||
|
/// is the cheapest check that it has not.
|
||||||
|
#[test]
|
||||||
|
fn the_header_describes_the_library() {
|
||||||
|
let src = std::fs::read_to_string("src/capi.rs").expect("src/capi.rs");
|
||||||
|
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
|
||||||
|
|
||||||
|
let mut exported: Vec<&str> = Vec::new();
|
||||||
|
for line in src.lines() {
|
||||||
|
// `fn typikon_foo(` in a line that declares an extern function, plus
|
||||||
|
// the names the accessor macro generates, which are its first argument.
|
||||||
|
if let Some(rest) = line.trim().strip_prefix("pub extern \"C\" fn ") {
|
||||||
|
exported.push(rest.split('(').next().unwrap());
|
||||||
|
} else if let Some(rest) = line.trim().strip_prefix("pub unsafe extern \"C\" fn ") {
|
||||||
|
exported.push(rest.split('(').next().unwrap());
|
||||||
|
} else if let Some(rest) = line.trim().strip_prefix("accessor!(") {
|
||||||
|
exported.push(rest.split(',').next().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The macro's own definition line reads as `fn $name(`; the names it
|
||||||
|
// generates are picked up from the invocations.
|
||||||
|
exported.retain(|n| !n.starts_with('$'));
|
||||||
|
assert!(exported.len() > 20, "found only {} symbols", exported.len());
|
||||||
|
|
||||||
|
for name in &exported {
|
||||||
|
assert!(
|
||||||
|
header.contains(&format!("{name}(")),
|
||||||
|
"{name} is exported but not declared in include/typikon.h"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in header.lines() {
|
||||||
|
let Some(start) = line.find("typikon_") else { continue };
|
||||||
|
if !line.contains('(') || line.trim_start().starts_with('*') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name: String = line[start..]
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||||
|
.collect();
|
||||||
|
if !line[start + name.len()..].starts_with('(') {
|
||||||
|
continue; // a mention in prose, not a declaration
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
exported.contains(&name.as_str()),
|
||||||
|
"{name} is declared in include/typikon.h but not exported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The header hard-codes the ABI version so a caller can compare what it
|
||||||
|
/// compiled against with what it loaded.
|
||||||
|
#[test]
|
||||||
|
fn the_headers_abi_version_matches_the_library() {
|
||||||
|
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
|
||||||
|
let line = header
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("#define TYPIKON_ABI_VERSION"))
|
||||||
|
.expect("the header defines it");
|
||||||
|
let declared: u32 = line
|
||||||
|
.split_whitespace()
|
||||||
|
.next_back()
|
||||||
|
.unwrap()
|
||||||
|
.trim_end_matches('u')
|
||||||
|
.parse()
|
||||||
|
.expect("a number");
|
||||||
|
assert_eq!(declared, typikon_abi_version());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The LuaJIT binding declares the library it loads, and a declaration that
|
||||||
|
/// has drifted from the header is a segfault rather than a compile error.
|
||||||
|
#[test]
|
||||||
|
fn the_lua_binding_declares_the_whole_header() {
|
||||||
|
let header = std::fs::read_to_string("include/typikon.h").expect("include/typikon.h");
|
||||||
|
let lua = std::fs::read_to_string("examples/lua/typikon.lua").expect("the lua binding");
|
||||||
|
let cdef = {
|
||||||
|
let start = lua.find("ffi.cdef").expect("a cdef block");
|
||||||
|
let body = &lua[start..];
|
||||||
|
let end = body.find("]]").expect("the block ends");
|
||||||
|
&body[..end]
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in header.lines() {
|
||||||
|
let Some(start) = line.find("typikon_") else { continue };
|
||||||
|
let name: String = line[start..]
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_alphanumeric() || *c == '_')
|
||||||
|
.collect();
|
||||||
|
if !line[start + name.len()..].starts_with('(') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
cdef.contains(&format!("{name}(")),
|
||||||
|
"{name} is in the header but not in the lua cdef"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! The packaging metadata, which lives in three files and is published to two
|
||||||
|
//! registries.
|
||||||
|
//!
|
||||||
|
//! `Cargo.toml` describes the crate, `pyproject.toml` is what maturin puts on
|
||||||
|
//! PyPI, and `npm/package.json.in` is what npm shows. Nothing makes them agree
|
||||||
|
//! and nothing notices when they stop: a description edited in one of the three
|
||||||
|
//! reaches one registry and not the other, and the wrong one is then frozen
|
||||||
|
//! into a release, because neither registry lets published metadata be edited.
|
||||||
|
//! That has happened once already.
|
||||||
|
//!
|
||||||
|
//! The two READMEs open with the same sentence, because that paragraph is the
|
||||||
|
//! first thing shown on both registry pages and is what most people will read
|
||||||
|
//! instead of the description field.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// The value of a `key = "…"` line, from the first section that has one.
|
||||||
|
fn toml_string(path: &str, key: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
let Some(rest) = line.strip_prefix(key) else { continue };
|
||||||
|
let Some(rest) = rest.trim_start().strip_prefix('=') else { continue };
|
||||||
|
let rest = rest.trim();
|
||||||
|
if let Some(v) = rest.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{path}: no `{key} = \"…\"` line");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of a `"key": "…"` line. Enough for a hand-written file with no
|
||||||
|
/// escapes in it, and the test below fails loudly rather than quietly if that
|
||||||
|
/// ever stops being true.
|
||||||
|
fn json_string(path: &str, key: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
let needle = format!("\"{key}\":");
|
||||||
|
for line in text.lines() {
|
||||||
|
let Some(rest) = line.trim().strip_prefix(&needle) else { continue };
|
||||||
|
let rest = rest.trim().trim_end_matches(',').trim();
|
||||||
|
if let Some(v) = rest.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||||
|
assert!(!v.contains('\\'), "{path}: {key} has escapes this reader cannot handle");
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{path}: no `\"{key}\": \"…\"` line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_three_descriptions_are_the_same_sentence() {
|
||||||
|
let cargo = toml_string("Cargo.toml", "description");
|
||||||
|
let pyproject = toml_string("pyproject.toml", "description");
|
||||||
|
let npm = json_string("npm/package.json.in", "description");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cargo, pyproject,
|
||||||
|
"Cargo.toml and pyproject.toml disagree; pyproject.toml is the one PyPI shows"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cargo, npm,
|
||||||
|
"Cargo.toml and npm/package.json.in disagree; the latter is the one npm shows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_description_says_what_the_crate_is_called() {
|
||||||
|
// Cheap guard against a description edited into something that no longer
|
||||||
|
// describes this package at all.
|
||||||
|
let d = toml_string("Cargo.toml", "description");
|
||||||
|
assert!(d.len() > 40, "description is too short to be one: {d:?}");
|
||||||
|
assert!(!d.ends_with(' '), "description has a trailing space");
|
||||||
|
assert!(d.contains("Orthodox"), "description no longer mentions Orthodox: {d:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening paragraph of a README, unwrapped to one line.
|
||||||
|
fn lede(path: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
let body = text
|
||||||
|
.split("\n\n")
|
||||||
|
.find(|p| !p.trim().is_empty() && !p.trim_start().starts_with('#'))
|
||||||
|
.unwrap_or_else(|| panic!("{path}: no opening paragraph"));
|
||||||
|
body.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_readmes_open_with_the_description() {
|
||||||
|
// A registry page leads with the README, not with the description field,
|
||||||
|
// so the sentence a reader actually meets is this one. npm's README is a
|
||||||
|
// separate file from the crate's and had gone its own way: it described
|
||||||
|
// the package without mentioning that it covers Byzantine Catholic
|
||||||
|
// churches at all.
|
||||||
|
let description = toml_string("Cargo.toml", "description");
|
||||||
|
assert_eq!(
|
||||||
|
lede("README.md"),
|
||||||
|
description,
|
||||||
|
"README.md does not open with the published description"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lede("npm/README.md"),
|
||||||
|
description,
|
||||||
|
"npm/README.md does not open with the published description"
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+244
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Every binding, asked the same twelve days, compared field by field against
|
||||||
|
# what the command line says.
|
||||||
|
#
|
||||||
|
# tests/parity.sh # against what is in this checkout
|
||||||
|
# tests/parity.sh --published # against what is on PyPI and npm
|
||||||
|
#
|
||||||
|
# A surface whose toolchain is missing is skipped by name. A surface that runs
|
||||||
|
# and disagrees fails the script. tests/parity/README.md says why these twelve.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$root"
|
||||||
|
P=tests/parity
|
||||||
|
published=0
|
||||||
|
[ "${1:-}" = "--published" ] && published=1
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
server=""
|
||||||
|
# By pid. A job spec does not survive a subshell, and a web server left holding
|
||||||
|
# the script's stdout is a suite that hangs rather than fails.
|
||||||
|
cleanup() {
|
||||||
|
[ -n "$server" ] && kill "$server" 2>/dev/null && wait "$server" 2>/dev/null
|
||||||
|
rm -rf "$work"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
pass=0 fail=0 skipped=()
|
||||||
|
note() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||||
|
skip() { skipped+=("$1: $2"); printf ' skip %s (%s)\n' "$1" "$2"; }
|
||||||
|
verify() { # name, path to that surface's json
|
||||||
|
if python3 "$P/compare.py" "$1" "$work/oracle.json" "$2"; then
|
||||||
|
pass=$((pass + 1))
|
||||||
|
else
|
||||||
|
fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Neither a config file nor a fetched menaion may reach any surface, or this
|
||||||
|
# measures the machine rather than the code.
|
||||||
|
#
|
||||||
|
# Empty directories, not missing ones. Pointing these at a path that does not
|
||||||
|
# exist isolates typikon and breaks everything else that keeps state under
|
||||||
|
# them: a browser cannot build a profile and its crash handler will not start
|
||||||
|
# without somewhere to write.
|
||||||
|
export XDG_CONFIG_HOME="$work/xdg/config" XDG_DATA_HOME="$work/xdg/data"
|
||||||
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME"
|
||||||
|
|
||||||
|
# ── the oracle ────────────────────────────────────────────────────────────
|
||||||
|
note "oracle"
|
||||||
|
cargo build --quiet --release || exit 1
|
||||||
|
python3 "$P/oracle.py" ./target/release/typikon "$P/probes.json" "$work/oracle.json" || exit 1
|
||||||
|
|
||||||
|
# The C probe carries its own copy of the list, because a binding whose point
|
||||||
|
# is to spare its callers a JSON parser should not need one to be tested.
|
||||||
|
python3 - "$P/probes.json" "$P/probe.c" <<'PY' || exit 1
|
||||||
|
import json, re, sys
|
||||||
|
probes = json.load(open(sys.argv[1]))
|
||||||
|
src = open(sys.argv[2]).read()
|
||||||
|
found = re.findall(r"\{(\d+),(\d+),(\d+),\"([a-z-]+)\",(-?\d+)\}", src.replace(" ", ""))
|
||||||
|
mine = [(f"{int(y):04d}-{int(m):02d}-{int(d):02d}", j, t == "1") for y, m, d, j, t in found]
|
||||||
|
want = [(d, j, bool(t)) for d, j, t in probes]
|
||||||
|
if mine != want:
|
||||||
|
sys.exit(f"tests/parity/probe.c has drifted from probes.json:\n c {mine}\n json {want}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# ── python ────────────────────────────────────────────────────────────────
|
||||||
|
note "python"
|
||||||
|
py="$work/venv/bin/python"
|
||||||
|
if ! python3 -m venv "$work/venv" >"$work/py.log" 2>&1; then
|
||||||
|
skip python "no usable python3 -m venv"
|
||||||
|
elif [ "$published" = 1 ] && ! "$work/venv/bin/pip" install --quiet typikon >>"$work/py.log" 2>&1; then
|
||||||
|
skip python "typikon would not install from PyPI"
|
||||||
|
elif [ "$published" = 0 ] && ! {
|
||||||
|
"$work/venv/bin/pip" install --quiet maturin >>"$work/py.log" 2>&1 &&
|
||||||
|
"$work/venv/bin/maturin" build --release --out "$work/wheels" >>"$work/py.log" 2>&1 &&
|
||||||
|
"$work/venv/bin/pip" install --quiet --no-index --find-links "$work/wheels" typikon \
|
||||||
|
>>"$work/py.log" 2>&1
|
||||||
|
}; then
|
||||||
|
echo " FAIL python: the wheel would not build or install"
|
||||||
|
tail -12 "$work/py.log" | sed 's/^/ /'
|
||||||
|
fail=$((fail + 1))
|
||||||
|
else
|
||||||
|
"$py" "$P/probe.py" "$P/probes.json" "$work/python.json" \
|
||||||
|
&& verify "python" "$work/python.json"
|
||||||
|
# The stubs are part of what is published, so they are part of what is
|
||||||
|
# checked. --strict, because a stub that only passes loosely is not one.
|
||||||
|
if "$work/venv/bin/pip" install --quiet mypy >>"$work/py.log" 2>&1; then
|
||||||
|
if "$py" -m mypy --strict "$P/probe.py" >"$work/mypy.log" 2>&1; then
|
||||||
|
echo " ok python: mypy --strict"
|
||||||
|
else
|
||||||
|
echo " FAIL python: mypy --strict"
|
||||||
|
tail -8 "$work/mypy.log" | sed 's/^/ /'
|
||||||
|
fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "python, mypy" "mypy would not install"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── javascript ────────────────────────────────────────────────────────────
|
||||||
|
note "javascript"
|
||||||
|
if ! command -v node >/dev/null; then
|
||||||
|
skip javascript "no node"
|
||||||
|
else
|
||||||
|
js="$work/js"
|
||||||
|
mkdir -p "$js"
|
||||||
|
printf '{"name":"parity","private":true,"type":"module"}\n' > "$js/package.json"
|
||||||
|
cp "$P/probes.json" "$P/shape.mjs" "$P"/probe.{mjs,cjs,ts} "$js/"
|
||||||
|
mkdir -p "$js/web" && cp "$P"/web/{entry.mjs,index.html,extract.py} "$js/web/"
|
||||||
|
|
||||||
|
if [ "$published" = 1 ]; then
|
||||||
|
(cd "$js" && npm install --silent typikon >/dev/null 2>&1)
|
||||||
|
else
|
||||||
|
npm/build.sh >/dev/null 2>&1 && (cd "$js" && npm install --silent "$root/npm/dist" >/dev/null 2>&1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$js/node_modules/typikon" ]; then
|
||||||
|
skip javascript "the package would not build or install"
|
||||||
|
else
|
||||||
|
node "$js/probe.mjs" "$js/probes.json" "$work/node-esm.json" \
|
||||||
|
&& verify "node, import" "$work/node-esm.json"
|
||||||
|
node "$js/probe.cjs" "$js/probes.json" "$work/node-cjs.json" \
|
||||||
|
&& verify "node, require" "$work/node-cjs.json"
|
||||||
|
|
||||||
|
# TypeScript: compiled under --strict, then run.
|
||||||
|
if (cd "$js" && npm install --silent --no-save typescript@5 @types/node >/dev/null 2>&1); then
|
||||||
|
if (cd "$js" && ./node_modules/.bin/tsc --strict --target es2022 --module nodenext \
|
||||||
|
--moduleResolution nodenext --types node --outDir out probe.ts >/dev/null); then
|
||||||
|
node "$js/out/probe.js" "$js/probes.json" "$work/typescript.json" \
|
||||||
|
&& verify "typescript, tsc --strict then run" "$work/typescript.json"
|
||||||
|
else
|
||||||
|
echo " FAIL typescript: tsc --strict rejected the published types"; fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip typescript "typescript would not install"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The browser, which is the only place the web build is really exercised:
|
||||||
|
# a real fetch of the wasm, and no Node to fall back on.
|
||||||
|
chrome=""
|
||||||
|
for c in chromium chromium-browser google-chrome google-chrome-stable; do
|
||||||
|
command -v "$c" >/dev/null && { chrome="$c"; break; }
|
||||||
|
done
|
||||||
|
if [ -z "$chrome" ]; then
|
||||||
|
skip browser "no chromium"
|
||||||
|
elif ! (cd "$js" && npm install --silent --no-save esbuild >/dev/null 2>&1); then
|
||||||
|
skip browser "esbuild would not install"
|
||||||
|
else
|
||||||
|
if ! (cd "$js" && ./node_modules/.bin/esbuild web/entry.mjs --bundle \
|
||||||
|
--format=esm --platform=browser --loader:.wasm=file \
|
||||||
|
--outdir=served --log-level=error); then
|
||||||
|
echo " FAIL browser: the bundle would not build"; fail=$((fail + 1))
|
||||||
|
else
|
||||||
|
cp "$js/web/index.html" "$js/probes.json" "$js/served/"
|
||||||
|
# A port the kernel picks, so a leftover server from a previous run
|
||||||
|
# cannot quietly serve a stale bundle to this one.
|
||||||
|
port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')
|
||||||
|
python3 -m http.server "$port" --bind 127.0.0.1 \
|
||||||
|
--directory "$js/served" >/dev/null 2>&1 &
|
||||||
|
server=$!
|
||||||
|
for _ in $(seq 40); do
|
||||||
|
curl -sf -o /dev/null "http://127.0.0.1:$port/index.html" && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
# --user-data-dir because this script points XDG_CONFIG_HOME at
|
||||||
|
# nothing, to keep a real config file away from the surfaces under
|
||||||
|
# test, and a browser given nowhere to put a profile does not start.
|
||||||
|
# --disable-dev-shm-usage because a container's /dev/shm is too small.
|
||||||
|
# Bounded: a browser that never paints must fail the suite, not hang it.
|
||||||
|
timeout 90 "$chrome" --headless --disable-gpu --no-sandbox \
|
||||||
|
--no-first-run --disable-dev-shm-usage \
|
||||||
|
--user-data-dir="$work/chrome" \
|
||||||
|
--virtual-time-budget=20000 --dump-dom "http://127.0.0.1:$port/index.html" \
|
||||||
|
>"$work/dom.html" 2>"$work/chrome.log"
|
||||||
|
kill "$server" 2>/dev/null; wait "$server" 2>/dev/null; server=""
|
||||||
|
if python3 "$P/web/extract.py" "$work/dom.html" "$work/browser.json"; then
|
||||||
|
verify "browser, bare import of the web build" "$work/browser.json"
|
||||||
|
else
|
||||||
|
echo " FAIL browser"
|
||||||
|
tail -5 "$work/chrome.log" 2>/dev/null | sed 's/^/ /'
|
||||||
|
fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
for rt in deno bun; do
|
||||||
|
command -v "$rt" >/dev/null || { skip "$rt" "not installed"; continue; }
|
||||||
|
case "$rt" in
|
||||||
|
deno) (cd "$js" && deno run --quiet --allow-read --allow-write --allow-env \
|
||||||
|
probe.mjs probes.json "$work/deno.json") ;;
|
||||||
|
bun) (cd "$js" && bun run probe.mjs probes.json "$work/bun.json") ;;
|
||||||
|
esac && verify "$rt" "$work/$rt.json"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── the C ABI, and Lua over it ────────────────────────────────────────────
|
||||||
|
note "C and Lua"
|
||||||
|
if ! command -v cc >/dev/null; then
|
||||||
|
skip "C ABI" "no C compiler"
|
||||||
|
elif ! cargo build --quiet --release --features capi; then
|
||||||
|
echo " FAIL C ABI: the library would not build"; fail=$((fail + 1))
|
||||||
|
else
|
||||||
|
if cc -Wall -Wextra -Werror -Iinclude "$P/probe.c" \
|
||||||
|
-Ltarget/release -ltypikon -o "$work/c-probe" 2>"$work/cc.log"; then
|
||||||
|
LD_LIBRARY_PATH=target/release "$work/c-probe" "$work/c.json" \
|
||||||
|
&& verify "C ABI" "$work/c.json"
|
||||||
|
else
|
||||||
|
echo " FAIL C ABI: the probe would not compile"; sed 's/^/ /' "$work/cc.log"
|
||||||
|
fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
# The worked example has to keep working too.
|
||||||
|
if cc -Wall -Wextra -Werror -Iinclude examples/c/demo.c \
|
||||||
|
-Ltarget/release -ltypikon -o "$work/demo" 2>/dev/null \
|
||||||
|
&& LD_LIBRARY_PATH=target/release "$work/demo" rocor >/dev/null; then
|
||||||
|
echo " ok C ABI: examples/c/demo.c"
|
||||||
|
else
|
||||||
|
echo " FAIL C ABI: examples/c/demo.c"; fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v luajit >/dev/null; then
|
||||||
|
skip luajit "not installed"
|
||||||
|
else
|
||||||
|
TYPIKON_LIB=target/release/libtypikon.so TYPIKON_LUA=examples/lua \
|
||||||
|
luajit "$P/probe.lua" "$work/lua.json" && verify "luajit" "$work/lua.json"
|
||||||
|
TYPIKON_LIB=target/release/libtypikon.so \
|
||||||
|
luajit examples/lua/statusline.lua rocor >/dev/null \
|
||||||
|
&& echo " ok luajit: examples/lua/statusline.lua" \
|
||||||
|
|| { echo " FAIL luajit: examples/lua/statusline.lua"; fail=$((fail + 1)); }
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── verdict ───────────────────────────────────────────────────────────────
|
||||||
|
note "verdict"
|
||||||
|
printf ' %d surfaces agree with the command line, %d disagree\n' "$pass" "$fail"
|
||||||
|
for s in "${skipped[@]:-}"; do [ -n "$s" ] && printf ' not run: %s\n' "$s"; done
|
||||||
|
|
||||||
|
# A run that skipped everything must not read as a pass.
|
||||||
|
if [ "$pass" -lt 3 ]; then
|
||||||
|
echo " too few surfaces ran to call this a check"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit $(( fail > 0 ))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Parity
|
||||||
|
|
||||||
|
Every binding answers the same question, so every binding has to give the same
|
||||||
|
answer. These probes ask each one for the same twelve days and compare all
|
||||||
|
twenty-two fields against what the command line says.
|
||||||
|
|
||||||
|
The probes are chosen for the places the bindings could plausibly disagree:
|
||||||
|
Pascha itself, the Nativity on both calendars, a Wednesday of the Great Fast
|
||||||
|
under Byzantine Catholic law and again at the Typikon, a weekday of the
|
||||||
|
Nativity Fast where Greek and Slavic usage part, the one Orthodox church on the
|
||||||
|
Gregorian Paschalion, and a day with no feast at all.
|
||||||
|
|
||||||
|
Run them with `tests/parity.sh`. Surfaces whose toolchain is absent are skipped
|
||||||
|
by name rather than silently passed.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""One surface's answers against the oracle's.
|
||||||
|
|
||||||
|
A surface need not expose every field; it must not disagree about one it does.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
name, oracle_path, got_path = sys.argv[1:4]
|
||||||
|
oracle = json.load(open(oracle_path))
|
||||||
|
got = json.load(open(got_path))
|
||||||
|
|
||||||
|
bad = []
|
||||||
|
for key in sorted(set(oracle) - set(got)):
|
||||||
|
bad.append(f"missing probe {key}")
|
||||||
|
for key in sorted(set(got) - set(oracle)):
|
||||||
|
bad.append(f"unexpected probe {key}")
|
||||||
|
for key in sorted(set(oracle) & set(got)):
|
||||||
|
for field, expected in sorted(oracle[key].items()):
|
||||||
|
if field in got[key] and got[key][field] != expected:
|
||||||
|
bad.append(f"{key} {field}: {got[key][field]!r} != {expected!r}")
|
||||||
|
|
||||||
|
fields = sorted({f for v in got.values() for f in v})
|
||||||
|
for line in bad[:20]:
|
||||||
|
print(f" {line}")
|
||||||
|
if len(bad) > 20:
|
||||||
|
print(f" ... and {len(bad) - 20} more")
|
||||||
|
print(f" {'ok ' if not bad else 'FAIL'} {name}: "
|
||||||
|
f"{len(got)} probes, {len(fields)} fields, {len(bad)} differences")
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""The command line's answer, which every other surface is measured against."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
"date", "weekday", "jurisdiction", "calendar", "churchDate", "season",
|
||||||
|
"cycle", "week", "feast", "rank", "sign", "tradition", "title", "commemorations",
|
||||||
|
"fast", "fastLabel", "emoji", "reason", "obligation", "binds", "pascha",
|
||||||
|
"daysToPascha", "julianOffset",
|
||||||
|
]
|
||||||
|
|
||||||
|
cli, probes_path, out_path = sys.argv[1:4]
|
||||||
|
|
||||||
|
# Neither a config file nor a fetched menaion may reach any surface, or the
|
||||||
|
# comparison measures the machine rather than the code. The caller has already
|
||||||
|
# pointed these at empty directories; this only refuses to run without them,
|
||||||
|
# because an oracle that quietly read the developer's own jurisdiction would
|
||||||
|
# make every surface agree about the wrong thing.
|
||||||
|
env = dict(os.environ)
|
||||||
|
for var in ("XDG_CONFIG_HOME", "XDG_DATA_HOME"):
|
||||||
|
if not env.get(var):
|
||||||
|
sys.exit(f"{var} must be set to an empty directory before reckoning the oracle")
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for date, jurisdiction, typikon in json.load(open(probes_path)):
|
||||||
|
args = [cli, "--date", date, "--jurisdiction", jurisdiction, "--json",
|
||||||
|
"--typikon" if typikon else "--no-typikon"]
|
||||||
|
day = json.loads(subprocess.run(args, capture_output=True, text=True,
|
||||||
|
env=env, check=True).stdout)
|
||||||
|
out[f"{date}|{jurisdiction}|{int(typikon)}"] = {k: day[k] for k in FIELDS}
|
||||||
|
|
||||||
|
json.dump(out, open(out_path, "w"), ensure_ascii=False)
|
||||||
|
print(f"oracle: {len(out)} probes, {len(FIELDS)} fields")
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* The C ABI, over the same probes, emitting the same JSON.
|
||||||
|
*
|
||||||
|
* The probe list is written out here rather than read from probes.json,
|
||||||
|
* because a JSON parser is exactly what this binding exists to spare its
|
||||||
|
* callers and it would be odd to need one to test it. tests/parity.sh checks
|
||||||
|
* the two lists have not drifted apart.
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <typikon.h>
|
||||||
|
|
||||||
|
static void esc(FILE *f, const char *s) {
|
||||||
|
fputc('"', f);
|
||||||
|
for (; *s; s++) {
|
||||||
|
if (*s == '"' || *s == '\\') fprintf(f, "\\%c", *s);
|
||||||
|
else if ((unsigned char)*s < 0x20) fprintf(f, "\\u%04x", *s);
|
||||||
|
else fputc(*s, f);
|
||||||
|
}
|
||||||
|
fputc('"', f);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void field(FILE *f, const char *k, const char *v, int *first) {
|
||||||
|
if (!*first) fputc(',', f);
|
||||||
|
*first = 0;
|
||||||
|
esc(f, k); fputc(':', f);
|
||||||
|
if (v) esc(f, v); else fputs("null", f);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
(void)argc;
|
||||||
|
FILE *f = fopen(argv[1], "w");
|
||||||
|
fputc('{', f);
|
||||||
|
int firstkey = 1;
|
||||||
|
|
||||||
|
/* date, jurisdiction, discipline: the same twelve as probes.json. */
|
||||||
|
struct { int y, m, d; const char *j; int t; } probes[] = {
|
||||||
|
{2026,4,12,"moscow",-1}, {2026,4,12,"goarch",-1}, {2026,1,7,"moscow",-1},
|
||||||
|
{2025,12,25,"old",-1}, {2026,3,4,"ruthenian",0}, {2026,3,4,"ruthenian",1},
|
||||||
|
{2025,12,9,"old",-1}, {2026,2,16,"oca",-1}, {2026,8,15,"antiochian",-1},
|
||||||
|
{2026,9,18,"finnish",-1},{2027,5,2,"serbian",-1}, {2026,6,28,"constantinople",-1},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (size_t i = 0; i < sizeof probes / sizeof *probes; i++) {
|
||||||
|
int32_t jdn = typikon_jdn_from_ymd(probes[i].y, probes[i].m, probes[i].d);
|
||||||
|
int disc = probes[i].t < 0 ? 0 : probes[i].t; /* the CLI oracle passed --no-typikon */
|
||||||
|
typikon_day *day = typikon_describe_full(jdn, probes[i].j, NULL, disc);
|
||||||
|
if (!day) { fprintf(stderr, "%s\n", typikon_last_error()); return 1; }
|
||||||
|
|
||||||
|
char key[64];
|
||||||
|
snprintf(key, sizeof key, "%04d-%02d-%02d|%s|%d",
|
||||||
|
probes[i].y, probes[i].m, probes[i].d, probes[i].j,
|
||||||
|
probes[i].t == 1 ? 1 : 0);
|
||||||
|
if (!firstkey) fputc(',', f);
|
||||||
|
firstkey = 0;
|
||||||
|
esc(f, key); fputs(":{", f);
|
||||||
|
int first = 1;
|
||||||
|
field(f, "date", typikon_day_iso(day), &first);
|
||||||
|
field(f, "weekday", typikon_day_weekday(day), &first);
|
||||||
|
field(f, "jurisdiction",typikon_day_jurisdiction(day), &first);
|
||||||
|
field(f, "calendar", typikon_day_calendar(day), &first);
|
||||||
|
field(f, "churchDate", typikon_day_church_date(day), &first);
|
||||||
|
field(f, "season", typikon_day_season(day), &first);
|
||||||
|
field(f, "cycle", typikon_day_cycle(day), &first);
|
||||||
|
field(f, "week", typikon_day_week(day), &first);
|
||||||
|
field(f, "feast", typikon_day_feast(day), &first);
|
||||||
|
field(f, "rank", typikon_day_rank(day), &first);
|
||||||
|
field(f, "sign", typikon_day_sign(day), &first);
|
||||||
|
field(f, "tradition", typikon_day_tradition(day), &first);
|
||||||
|
field(f, "title", typikon_day_title(day), &first);
|
||||||
|
field(f, "fast", typikon_day_fast(day), &first);
|
||||||
|
field(f, "fastLabel", typikon_day_fast_label(day), &first);
|
||||||
|
field(f, "emoji", typikon_day_emoji(day), &first);
|
||||||
|
field(f, "reason", typikon_day_reason(day), &first);
|
||||||
|
field(f, "obligation", typikon_day_obligation(day), &first);
|
||||||
|
field(f, "binds", typikon_day_binds(day), &first);
|
||||||
|
|
||||||
|
fputs(",\"commemorations\":[", f);
|
||||||
|
for (uint32_t c = 0; c < typikon_day_commemoration_count(day); c++) {
|
||||||
|
if (c) fputc(',', f);
|
||||||
|
esc(f, typikon_day_commemoration(day, c));
|
||||||
|
}
|
||||||
|
fputc(']', f);
|
||||||
|
|
||||||
|
int32_t p = typikon_day_pascha(day);
|
||||||
|
int32_t py; uint32_t pm, pd;
|
||||||
|
typikon_ymd_from_jdn(p, &py, &pm, &pd);
|
||||||
|
char iso[16];
|
||||||
|
snprintf(iso, sizeof iso, "%04d-%02u-%02u", py, pm, pd);
|
||||||
|
fputs(",\"pascha\":", f); esc(f, iso);
|
||||||
|
fprintf(f, ",\"daysToPascha\":%d", typikon_day_days_to_pascha(day));
|
||||||
|
fprintf(f, ",\"julianOffset\":%d", typikon_day_julian_offset(day));
|
||||||
|
fputc('}', f);
|
||||||
|
typikon_day_free(day);
|
||||||
|
}
|
||||||
|
fputs("}\n", f);
|
||||||
|
fclose(f);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Node, by require. The other half of Node's world, and the half an exports
|
||||||
|
// map gets wrong.
|
||||||
|
const { readFileSync, writeFileSync } = require("node:fs");
|
||||||
|
const { describeIso } = require("typikon");
|
||||||
|
|
||||||
|
const [probesPath, outPath] = process.argv.slice(2);
|
||||||
|
const out = {};
|
||||||
|
for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, "utf8"))) {
|
||||||
|
const d = describeIso(date, { jurisdiction, typikon });
|
||||||
|
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = {
|
||||||
|
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
||||||
|
calendar: d.calendar, churchDate: d.churchDate, season: d.season,
|
||||||
|
cycle: d.cycle, week: d.week, feast: d.feast, rank: d.rank, sign: d.sign,
|
||||||
|
tradition: d.tradition, title: d.title, commemorations: d.commemorations,
|
||||||
|
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
||||||
|
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
||||||
|
daysToPascha: d.daysToPascha, julianOffset: d.julianOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
writeFileSync(outPath, JSON.stringify(out));
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- The binding under test is the one in examples/lua.
|
||||||
|
package.path = (os.getenv("TYPIKON_LUA") or "examples/lua") .. "/?.lua;" .. package.path
|
||||||
|
local typikon = require("typikon")
|
||||||
|
|
||||||
|
-- Minimal JSON writer: the binding hands back Lua tables, and the point is to
|
||||||
|
-- compare them with everything else, not to pull in a dependency.
|
||||||
|
local function esc(s)
|
||||||
|
return '"' .. s:gsub('[%c"\\]', function(c)
|
||||||
|
if c == '"' then return '\\"' elseif c == '\\' then return '\\\\' end
|
||||||
|
return string.format('\\u%04x', c:byte())
|
||||||
|
end) .. '"'
|
||||||
|
end
|
||||||
|
local function val(v)
|
||||||
|
if v == nil then return "null"
|
||||||
|
elseif type(v) == "number" then return tostring(math.floor(v))
|
||||||
|
elseif type(v) == "table" then
|
||||||
|
local parts = {}
|
||||||
|
for i = 1, #v do parts[i] = esc(v[i]) end
|
||||||
|
return "[" .. table.concat(parts, ",") .. "]"
|
||||||
|
else return esc(v) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local probes = {
|
||||||
|
{2026,4,12,"moscow",false}, {2026,4,12,"goarch",false}, {2026,1,7,"moscow",false},
|
||||||
|
{2025,12,25,"old",false}, {2026,3,4,"ruthenian",false},{2026,3,4,"ruthenian",true},
|
||||||
|
{2025,12,9,"old",false}, {2026,2,16,"oca",false}, {2026,8,15,"antiochian",false},
|
||||||
|
{2026,9,18,"finnish",false},{2027,5,2,"serbian",false}, {2026,6,28,"constantinople",false},
|
||||||
|
}
|
||||||
|
|
||||||
|
local fields = {
|
||||||
|
{"date","iso"}, {"weekday","weekday"}, {"jurisdiction","jurisdiction"},
|
||||||
|
{"calendar","calendar"}, {"churchDate","church_date"}, {"season","season"},
|
||||||
|
{"cycle","cycle"}, {"week","week"}, {"feast","feast"}, {"rank","rank"}, {"sign","sign"},
|
||||||
|
{"tradition","tradition"}, {"title","title"}, {"fast","fast"},
|
||||||
|
{"fastLabel","fast_label"}, {"emoji","emoji"}, {"reason","reason"},
|
||||||
|
{"obligation","obligation"}, {"binds","binds"},
|
||||||
|
{"commemorations","commemorations"},
|
||||||
|
}
|
||||||
|
|
||||||
|
local out, firstkey = {}, true
|
||||||
|
for _, p in ipairs(probes) do
|
||||||
|
local jdn = assert(typikon.jdn(p[1], p[2], p[3]))
|
||||||
|
local day = assert(typikon.describe(jdn, { jurisdiction = p[4], typikon = p[5] }))
|
||||||
|
local parts = {}
|
||||||
|
for _, f in ipairs(fields) do
|
||||||
|
parts[#parts + 1] = esc(f[1]) .. ":" .. val(day[f[2]])
|
||||||
|
end
|
||||||
|
local py, pm, pd = typikon.ymd(day.pascha_jdn)
|
||||||
|
parts[#parts + 1] = esc("pascha") .. ":" .. esc(string.format("%04d-%02d-%02d", py, pm, pd))
|
||||||
|
parts[#parts + 1] = esc("daysToPascha") .. ":" .. tostring(day.days_to_pascha)
|
||||||
|
parts[#parts + 1] = esc("julianOffset") .. ":" .. tostring(day.julian_offset)
|
||||||
|
local key = string.format("%04d-%02d-%02d|%s|%d", p[1], p[2], p[3], p[4], p[5] and 1 or 0)
|
||||||
|
out[#out + 1] = (firstkey and "" or ",") .. esc(key) .. ":{" .. table.concat(parts, ",") .. "}"
|
||||||
|
firstkey = false
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = assert(io.open(arg[1], "w"))
|
||||||
|
f:write("{" .. table.concat(out) .. "}\n")
|
||||||
|
f:close()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Node, by import.
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { describeIso } from "typikon";
|
||||||
|
import { shape } from "./shape.mjs";
|
||||||
|
|
||||||
|
const [probesPath, outPath] = process.argv.slice(2);
|
||||||
|
const out = {};
|
||||||
|
for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, "utf8"))) {
|
||||||
|
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] =
|
||||||
|
shape(describeIso(date, { jurisdiction, typikon }));
|
||||||
|
}
|
||||||
|
writeFileSync(outPath, JSON.stringify(out));
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""The Python bindings."""
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import typikon
|
||||||
|
|
||||||
|
probes_path, out_path = sys.argv[1:3]
|
||||||
|
out = {}
|
||||||
|
for date, jurisdiction, tk in json.load(open(probes_path)):
|
||||||
|
y, m, d = map(int, date.split("-"))
|
||||||
|
day = typikon.describe(datetime.date(y, m, d), jurisdiction=jurisdiction, typikon=tk)
|
||||||
|
out[f"{date}|{jurisdiction}|{int(tk)}"] = {
|
||||||
|
"date": day.iso,
|
||||||
|
"weekday": day.weekday,
|
||||||
|
"jurisdiction": day.jurisdiction,
|
||||||
|
"calendar": day.calendar,
|
||||||
|
"churchDate": day.church_date,
|
||||||
|
"season": day.season,
|
||||||
|
"cycle": day.cycle,
|
||||||
|
"week": day.week,
|
||||||
|
"feast": day.feast,
|
||||||
|
"rank": day.rank,
|
||||||
|
"sign": day.sign,
|
||||||
|
"tradition": day.tradition,
|
||||||
|
"title": day.title,
|
||||||
|
"commemorations": day.commemorations,
|
||||||
|
"fast": day.fast,
|
||||||
|
"fastLabel": day.fast_label,
|
||||||
|
"emoji": day.emoji,
|
||||||
|
"reason": day.reason,
|
||||||
|
"obligation": day.obligation,
|
||||||
|
"binds": day.binds,
|
||||||
|
"pascha": day.pascha.isoformat(),
|
||||||
|
"daysToPascha": day.days_to_pascha,
|
||||||
|
"julianOffset": day.julian_offset,
|
||||||
|
}
|
||||||
|
json.dump(out, open(out_path, "w"), ensure_ascii=False)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// TypeScript, compiled under --strict and then run. Every field is annotated
|
||||||
|
// on the way out, so a type that has drifted from what the package returns is
|
||||||
|
// a compile error rather than a surprise at run time.
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { describeIso, week, pascha, jurisdictions, type Day, type Options } from "typikon";
|
||||||
|
|
||||||
|
const [probesPath, outPath] = process.argv.slice(2);
|
||||||
|
type Probe = [string, string, boolean];
|
||||||
|
|
||||||
|
const out: Record<string, Record<string, unknown>> = {};
|
||||||
|
for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, "utf8")) as Probe[]) {
|
||||||
|
const options: Options = { jurisdiction, typikon };
|
||||||
|
const d: Day = describeIso(date, options);
|
||||||
|
const season: string | null = d.season;
|
||||||
|
const commemorations: string[] = d.commemorations;
|
||||||
|
const daysToPascha: number = d.daysToPascha;
|
||||||
|
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = {
|
||||||
|
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
||||||
|
calendar: d.calendar, churchDate: d.churchDate, season,
|
||||||
|
cycle: d.cycle, week: d.week, feast: d.feast, rank: d.rank, sign: d.sign,
|
||||||
|
tradition: d.tradition, title: d.title, commemorations,
|
||||||
|
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
||||||
|
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
||||||
|
daysToPascha, julianOffset: d.julianOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rest of the surface, so nothing is typed only where it is called.
|
||||||
|
const seven: Day[] = week(new Date(2026, 3, 12), 7, { jurisdiction: "moscow" });
|
||||||
|
const p: Date = pascha(2027, "old");
|
||||||
|
const keys: string[] = jurisdictions().map((j) => j.key);
|
||||||
|
if (seven.length !== 7 || p.getFullYear() !== 2027 || keys.length < 20) {
|
||||||
|
throw new Error("the rest of the surface did not answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(outPath, JSON.stringify(out));
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
["2026-04-12", "moscow", false],
|
||||||
|
["2026-04-12", "goarch", false],
|
||||||
|
["2026-01-07", "moscow", false],
|
||||||
|
["2025-12-25", "old", false],
|
||||||
|
["2026-03-04", "ruthenian", false],
|
||||||
|
["2026-03-04", "ruthenian", true],
|
||||||
|
["2025-12-09", "old", false],
|
||||||
|
["2026-02-16", "oca", false],
|
||||||
|
["2026-08-15", "antiochian", false],
|
||||||
|
["2026-09-18", "finnish", false],
|
||||||
|
["2027-05-02", "serbian", false],
|
||||||
|
["2026-06-28", "constantinople", false]
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// The fields, named once for every JavaScript surface. Written out rather than
|
||||||
|
// spread, so that a field the package stops exporting is a missing key here and
|
||||||
|
// not a silently absent one.
|
||||||
|
export function shape(d) {
|
||||||
|
return {
|
||||||
|
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
||||||
|
calendar: d.calendar, churchDate: d.churchDate, season: d.season,
|
||||||
|
cycle: d.cycle, week: d.week, feast: d.feast, rank: d.rank, sign: d.sign,
|
||||||
|
tradition: d.tradition, title: d.title, commemorations: d.commemorations,
|
||||||
|
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
||||||
|
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
||||||
|
daysToPascha: d.daysToPascha, julianOffset: d.julianOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// A bare "typikon" import under a browser-targeted bundler, which is what a
|
||||||
|
// web app actually writes. It has to resolve to the web build and not the Node
|
||||||
|
// one: the exports map is the only thing making that true.
|
||||||
|
import init, { describeIso } from "typikon";
|
||||||
|
import wasmUrl from "typikon/web/typikon_bg.wasm";
|
||||||
|
import { shape } from "../shape.mjs";
|
||||||
|
|
||||||
|
window.typikonProbe = async (probes) => {
|
||||||
|
await init({ module_or_path: wasmUrl });
|
||||||
|
const out = {};
|
||||||
|
for (const [date, jurisdiction, typikon] of probes) {
|
||||||
|
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] =
|
||||||
|
shape(describeIso(date, { jurisdiction, typikon }));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Pull the probe result back out of the DOM the browser printed."""
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
dom = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
match = re.search(r'<pre id="out">(.*?)</pre>', dom, re.S)
|
||||||
|
if not match:
|
||||||
|
sys.exit("the page produced no result element")
|
||||||
|
text = html.unescape(match.group(1))
|
||||||
|
if text == "running":
|
||||||
|
sys.exit("the page never finished; the wasm probably failed to load")
|
||||||
|
if text.startswith("ERROR"):
|
||||||
|
sys.exit(text[:600])
|
||||||
|
json.dump(json.loads(text), open(sys.argv[2], "w"), ensure_ascii=False)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>typikon parity</title>
|
||||||
|
<pre id="out">running</pre>
|
||||||
|
<script type="module">
|
||||||
|
import "./entry.js";
|
||||||
|
const probes = await (await fetch("./probes.json")).json();
|
||||||
|
try {
|
||||||
|
document.getElementById("out").textContent =
|
||||||
|
JSON.stringify(await window.typikonProbe(probes));
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("out").textContent = "ERROR " + (e && e.stack || e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+220
-2
@@ -3,11 +3,11 @@
|
|||||||
|
|
||||||
use typikon::calendars::{Date, JulianDate, Weekday};
|
use typikon::calendars::{Date, JulianDate, Weekday};
|
||||||
use typikon::day::{describe, week, year_of};
|
use typikon::day::{describe, week, year_of};
|
||||||
use typikon::export::to_ics;
|
use typikon::export::{to_ics, to_ics_with, Ics};
|
||||||
use typikon::fasting::{Level, Obligation};
|
use typikon::fasting::{Level, Obligation};
|
||||||
use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS};
|
use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS};
|
||||||
use typikon::menologion::Rank;
|
use typikon::menologion::Rank;
|
||||||
use typikon::paschalion::{gregorian_pascha, julian_pascha, Paschalion};
|
use typikon::paschalion::{gregorian_pascha, julian_pascha, week_after_pentecost, Paschalion};
|
||||||
use typikon::seasons::Season;
|
use typikon::seasons::Season;
|
||||||
|
|
||||||
fn d(s: &str) -> Date {
|
fn d(s: &str) -> Date {
|
||||||
@@ -366,6 +366,200 @@ fn ics_is_well_formed() {
|
|||||||
assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count());
|
assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_day_gives_every_day_an_event() {
|
||||||
|
let days = week(d("2026-09-21"), 60, resolve(Some("ruthenian")).unwrap());
|
||||||
|
let thin = to_ics(&days, "Typikon", "20260101T000000Z");
|
||||||
|
let full = to_ics_with(
|
||||||
|
&days,
|
||||||
|
&Ics { name: "Typikon", stamp: "20260101T000000Z", every_day: true },
|
||||||
|
);
|
||||||
|
assert!(thin.matches("BEGIN:VEVENT").count() < 60, "the thinned calendar drops days");
|
||||||
|
assert_eq!(full.matches("BEGIN:VEVENT").count(), 60);
|
||||||
|
for line in full.split("\r\n") {
|
||||||
|
assert!(line.len() <= 75, "{} octets: {line}", line.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_event_carries_the_saints_and_the_fast() {
|
||||||
|
// A ferial Wednesday of the Great Fast: no feast, a fast that binds, and
|
||||||
|
// a saint the thinned calendar would keep but the old body did not name
|
||||||
|
// until after the church date.
|
||||||
|
let days = week(d("2027-03-17"), 1, resolve(Some("ruthenian")).unwrap());
|
||||||
|
let text = to_ics_with(
|
||||||
|
&days,
|
||||||
|
&Ics { name: "Typikon", stamp: "20260101T000000Z", every_day: true },
|
||||||
|
);
|
||||||
|
let unfolded = text.replace("\r\n ", "");
|
||||||
|
let description = unfolded
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("DESCRIPTION:"))
|
||||||
|
.expect("an event with a description");
|
||||||
|
assert!(description.contains("\u{2022} Our Venerable Father Alexis the Man of God"),
|
||||||
|
"{description}");
|
||||||
|
assert!(description.contains("Fast: no meat"), "{description}");
|
||||||
|
assert!(description.contains("Obligatory."), "{description}");
|
||||||
|
assert!(description.contains("Church date: 17 March"), "{description}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────── the Sundays of the movable year ──────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pentecostarion_names_all_of_its_sundays() {
|
||||||
|
// Pascha 2027 is 28 March by the Gregorian paschalion and 2 May by the
|
||||||
|
// Julian one, so the same Sundays are asked of both reckonings.
|
||||||
|
for (jur, pascha) in [("ruthenian", "2027-03-28"), ("rocor", "2027-05-02")] {
|
||||||
|
let pascha = d(pascha);
|
||||||
|
for (offset, name) in [
|
||||||
|
(7, "Thomas Sunday"),
|
||||||
|
(14, "The Sunday of the Holy Myrrh-bearing Women"),
|
||||||
|
(21, "The Sunday of the Paralytic"),
|
||||||
|
(28, "The Sunday of the Samaritan Woman"),
|
||||||
|
(35, "The Sunday of the Blind Man"),
|
||||||
|
(42, "The Sunday of the Fathers of the First Council"),
|
||||||
|
] {
|
||||||
|
let day = describe(pascha + offset, resolve(Some(jur)).unwrap());
|
||||||
|
assert_eq!(day.weekday, Weekday::Sunday, "{jur} +{offset}");
|
||||||
|
let named = day.title().starts_with(name)
|
||||||
|
|| day.commemorations.iter().any(|c| c.starts_with(name));
|
||||||
|
assert!(named, "{jur} {} ({}) does not name {name}: {:?}",
|
||||||
|
day.date, offset, day.commemorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vigil_rank_saint_keeps_the_day_and_the_sunday_joins_it() {
|
||||||
|
// 25 April 2027 is both the Sunday of the Samaritan Woman and St Mark,
|
||||||
|
// whose feast is of vigil rank. The apostle names the day; the Sunday is
|
||||||
|
// commemorated on it.
|
||||||
|
let day = at("2027-04-25", "ruthenian");
|
||||||
|
assert_eq!(day.title(), "The Holy Apostle and Evangelist Mark");
|
||||||
|
assert_eq!(day.rank, Some(Rank::Vigil));
|
||||||
|
assert!(day.commemorations.iter().any(|c| c == "The Sunday of the Samaritan Woman"),
|
||||||
|
"{:?}", day.commemorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sundays_reckoned_from_a_fixed_feast_are_kept() {
|
||||||
|
for (date, jur, name) in [
|
||||||
|
("2026-09-13", "ruthenian", "The Sunday before the Exaltation"),
|
||||||
|
("2026-09-20", "ruthenian", "The Sunday after the Exaltation"),
|
||||||
|
("2026-12-13", "ruthenian", "The Sunday of the Holy Forefathers"),
|
||||||
|
("2026-12-20", "ruthenian", "The Sunday before the Nativity \u{2014} the Holy Fathers"),
|
||||||
|
("2026-12-27", "ruthenian", "The Sunday after the Nativity"),
|
||||||
|
("2027-01-03", "ruthenian", "The Sunday before the Theophany"),
|
||||||
|
("2027-01-10", "ruthenian", "The Sunday after the Theophany"),
|
||||||
|
// The old calendar keeps the same windows thirteen days later, which
|
||||||
|
// is the whole point of reckoning them in ecclesiastical space.
|
||||||
|
("2027-01-03", "moscow", "The Sunday before the Nativity \u{2014} the Holy Fathers"),
|
||||||
|
("2027-01-10", "moscow", "The Sunday after the Nativity"),
|
||||||
|
("2027-01-17", "moscow", "The Sunday before the Theophany"),
|
||||||
|
] {
|
||||||
|
let day = at(date, jur);
|
||||||
|
assert!(day.commemorations.iter().any(|c| c == name),
|
||||||
|
"{jur} {date}: {name} missing from {:?}", day.commemorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nativity_on_a_sunday_moves_the_sunday_after_it_to_the_26th() {
|
||||||
|
// 25 December 2033 is a Sunday, so 26–31 December holds none and the
|
||||||
|
// Typikon keeps the Sunday after the Nativity on the Monday.
|
||||||
|
assert_eq!(at("2033-12-25", "ruthenian").weekday, Weekday::Sunday);
|
||||||
|
let monday = at("2033-12-26", "ruthenian");
|
||||||
|
assert_eq!(monday.weekday, Weekday::Monday);
|
||||||
|
assert!(monday.commemorations.iter().any(|c| c == "The Sunday after the Nativity"),
|
||||||
|
"{:?}", monday.commemorations);
|
||||||
|
// And it is kept once, not twice.
|
||||||
|
for n in 1..=6 {
|
||||||
|
let day = describe(d("2033-12-26") + n, resolve(Some("ruthenian")).unwrap());
|
||||||
|
assert!(!day.commemorations.iter().any(|c| c == "The Sunday after the Nativity"),
|
||||||
|
"{}", day.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_year_on_a_sunday_keeps_the_sunday_before_the_theophany_with_it() {
|
||||||
|
let day = at("2034-01-01", "ruthenian");
|
||||||
|
assert_eq!(day.weekday, Weekday::Sunday);
|
||||||
|
assert!(day.commemorations.iter().any(|c| c == "The Sunday before the Theophany"),
|
||||||
|
"{:?}", day.commemorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_sunday_of_the_year_is_named_twice() {
|
||||||
|
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
|
||||||
|
for day in week(d("2024-01-01"), 366 * 12, jur) {
|
||||||
|
let mut seen = day.commemorations.clone();
|
||||||
|
seen.sort();
|
||||||
|
let before = seen.len();
|
||||||
|
seen.dedup();
|
||||||
|
assert_eq!(seen.len(), before, "{} {}: {:?}", jur.key, day.date, day.commemorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── the count of weeks from Pentecost ─────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_weeks_after_pentecost_are_counted_from_all_saints() {
|
||||||
|
let p = Paschalion::Gregorian;
|
||||||
|
// Pascha 2026 is 5 April, so Pentecost is 24 May and All Saints 31 May.
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-05-24"), p), None, "Pentecost itself");
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-05-25"), p), Some(1), "the Monday after");
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-05-31"), p), Some(1), "All Saints");
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-06-07"), p), Some(2));
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-09-27"), p), Some(18));
|
||||||
|
// The count does not restart at the new year: it runs on from last
|
||||||
|
// year's Pentecost until the Triodion opens.
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-01-10"), p), Some(33));
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-01-16"), p), Some(34));
|
||||||
|
// 17 January 2027 is the Publican and the Pharisee, and the Triodion
|
||||||
|
// names its own Sundays.
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-01-17"), p), None);
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-03-28"), p), None, "Pascha");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_week_is_worded_for_the_day_it_falls_on() {
|
||||||
|
assert_eq!(at("2026-09-27", "ruthenian").week.as_deref(),
|
||||||
|
Some("the 18th Sunday after Pentecost"));
|
||||||
|
assert_eq!(at("2026-09-28", "ruthenian").week.as_deref(),
|
||||||
|
Some("the 19th week after Pentecost"));
|
||||||
|
// The Pentecostarion and the Triodion carry names instead of numbers.
|
||||||
|
assert_eq!(at("2027-04-18", "ruthenian").week, None);
|
||||||
|
assert_eq!(at("2027-02-14", "ruthenian").week, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_week_count_never_skips_or_repeats_a_number() {
|
||||||
|
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
|
||||||
|
// The walk starts mid-count, so the first run it sees is not a run
|
||||||
|
// that opened. Every later one is.
|
||||||
|
let mut last: Option<i32> = None;
|
||||||
|
let mut first_run = true;
|
||||||
|
for day in week(d("2025-01-01"), 366 * 6, jur) {
|
||||||
|
let Some(n) = week_after_pentecost(day.date, jur.paschalion) else {
|
||||||
|
if last.is_some() {
|
||||||
|
first_run = false;
|
||||||
|
}
|
||||||
|
last = None;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match last {
|
||||||
|
Some(prev) => assert!(n == prev || n == prev + 1,
|
||||||
|
"{} {}: {prev} then {n}", jur.key, day.date),
|
||||||
|
None if !first_run => assert_eq!(n, 1,
|
||||||
|
"{} {}: the count opens at one", jur.key, day.date),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
last = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────── every jurisdiction ───────────────────────────
|
// ─────────────────────────── every jurisdiction ───────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -706,3 +900,27 @@ fn date_arithmetic_saturates_instead_of_overflowing() {
|
|||||||
assert_eq!(d.iso().len(), 10);
|
assert_eq!(d.iso().len(), 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The published description counts the churches, on PyPI and on npm, where
|
||||||
|
/// nobody editing this table will see it. Adding a jurisdiction without saying
|
||||||
|
/// so makes that description quietly wrong, so the count is pinned here and
|
||||||
|
/// the failure names the two places to correct.
|
||||||
|
#[test]
|
||||||
|
fn the_published_description_still_counts_the_churches_correctly() {
|
||||||
|
let catholic = ["ruthenian", "ugcc", "melkite", "romanian-catholic"];
|
||||||
|
let orthodox = JURISDICTIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|j| !catholic.contains(&j.key))
|
||||||
|
.count();
|
||||||
|
let byzantine_catholic = JURISDICTIONS
|
||||||
|
.iter()
|
||||||
|
.filter(|j| catholic.contains(&j.key))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(orthodox, byzantine_catholic, CALENDARS.len()),
|
||||||
|
(20, 4, 3),
|
||||||
|
"the counts moved; update `description` in Cargo.toml (PyPI) and in \
|
||||||
|
npm/package.json.in (npm), and the README's opening"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user