mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 03:07:12 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,6 +99,22 @@ 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
|
||||||
|
|||||||
@@ -59,6 +59,23 @@ 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
|
||||||
|
|
||||||
- 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 +94,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/
|
||||||
|
|||||||
@@ -4,6 +4,33 @@ 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.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.1.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"
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.0.0"
|
version = "1.1.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"
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -404,6 +413,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
|
||||||
|
|||||||
@@ -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,17 @@ $ /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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
+23
-2
@@ -72,6 +72,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 +119,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,168 @@
|
|||||||
|
-- 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_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", "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,152 @@
|
|||||||
|
/* 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_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,84 @@
|
|||||||
|
# typikon
|
||||||
|
|
||||||
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
|
than fetched. 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,49 @@
|
|||||||
|
{
|
||||||
|
"name": "typikon",
|
||||||
|
"version": "@VERSION@",
|
||||||
|
"description": "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"./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 };
|
||||||
+487
@@ -0,0 +1,487 @@
|
|||||||
|
//! 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>,
|
||||||
|
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),
|
||||||
|
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_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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-39
@@ -419,7 +419,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 {
|
||||||
@@ -473,44 +473,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"])
|
||||||
|
|||||||
+4
-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.
|
||||||
|
|||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
//! 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;
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user