commit 58b379d6e3abe37dbe37be1b5e73a60ccd9c8473 Author: Антон Трошин Date: Thu Jul 23 02:41:15 2026 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8966d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +dist/ +node_modules/ +static/data/ +.opencode/ +*.log +.DS_Store +Thumbs.db diff --git a/index.html b/index.html new file mode 100644 index 0000000..54edda0 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + Ponomar + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1068c7d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1948 @@ +{ + "name": "ponomar-pwa", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ponomar-pwa", + "version": "0.1.0", + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "fast-xml-parser": "^5.10.1", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.1", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4f6a573 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "ponomar-pwa", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "npm run convert && vite dev", + "build": "npm run convert && vite build", + "preview": "vite preview", + "convert": "tsx scripts/convert-all.ts", + "check": "svelte-kit sync 2>/dev/null; tsc --noEmit" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "fast-xml-parser": "^5.10.1", + "tailwindcss": "^4.3.3", + "tsx": "^4.23.1", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } +} diff --git a/scripts/convert-all.ts b/scripts/convert-all.ts new file mode 100644 index 0000000..84fe9f8 --- /dev/null +++ b/scripts/convert-all.ts @@ -0,0 +1,59 @@ +/** + * Master conversion script — converts all XML data to JSON at build time. + * Run with: tsx scripts/convert-all.ts + */ + +import { convertCalendar } from './convert-calendar.ts'; +import { convertLives } from './convert-lives.ts'; +import { convertServices } from './convert-services.ts'; +import { convertBibleMeta } from './convert-bible-meta.ts'; +import { convertFasting } from './convert-fasting.ts'; +import { convertCommands } from './convert-commands.ts'; +import { convertServiceRules } from './convert-service-rules.ts'; +import { mkdirSync, existsSync, cpSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SRC = join(__dirname, '..', '..', 'ponomar', 'Ponomar'); +const OUT = join(__dirname, '..', 'static', 'data'); + +function ensureDir(dir: string) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); +} + +async function main() { + const start = Date.now(); + console.log('Converting Ponomar XML data to JSON...\n'); + + ensureDir(OUT); + + // Copy font + const fontSrc = join(SRC, 'Ponomar-Regular.woff'); + const fontDst = join(__dirname, '..', 'static', 'fonts', 'Ponomar-Regular.woff'); + ensureDir(dirname(fontDst)); + if (existsSync(fontSrc)) { + cpSync(fontSrc, fontDst); + console.log('Copied Ponomar font.'); + } + + // Convert all data in parallel + await Promise.all([ + convertCalendar(SRC, OUT), + convertLives(SRC, OUT), + convertServices(SRC, OUT), + convertBibleMeta(SRC, OUT), + convertFasting(SRC, OUT), + convertCommands(SRC, OUT), + convertServiceRules(SRC, OUT), + ]); + + const elapsed = ((Date.now() - start) / 1000).toFixed(1); + console.log(`\nAll conversions complete in ${elapsed}s.`); + console.log(`Output: ${OUT}`); +} + +main().catch((err) => { + console.error('Conversion failed:', err); + process.exit(1); +}); diff --git a/scripts/convert-bible-meta.ts b/scripts/convert-bible-meta.ts new file mode 100644 index 0000000..695c416 --- /dev/null +++ b/scripts/convert-bible-meta.ts @@ -0,0 +1,146 @@ +/** + * Convert bible.xml to JSON metadata for all translations. + * Bible text .text files are copied as-is. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, cpSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, +}); + +interface BibleBookMeta { + id: string; + name: string; + short: string; + chapters: number; + intro?: string; +} + +interface BibleVersionMeta { + id: string; + name: string; + language: string; + books: BibleBookMeta[]; +} + +export async function convertBibleMeta(SRC: string, OUT: string) { + console.log('Converting Bible metadata...'); + + const bibleXml = join(SRC, 'languages', 'xml', 'bible.xml'); + if (!existsSync(bibleXml)) { + console.log(' bible.xml not found, skipping'); + return; + } + + const data = parser.parse(readFileSync(bibleXml, 'utf-8')); + const versions: BibleVersionMeta[] = []; + + // Root element is , elements are children + const bml = data['BMLFILE'] || data; + const bibleNodes = bml['BIBLE']; + if (!bibleNodes) { + console.log(' No BIBLE elements found'); + return; + } + + const bibleArr = Array.isArray(bibleNodes) ? bibleNodes : [bibleNodes]; + + for (const bible of bibleArr) { + // fast-xml-parser with attributeNamePrefix '@_' puts attributes as flat keys + const id = bible['@_Id'] || bible['@_ID'] || ''; + const name = bible['@_Name'] || ''; + + // Handle special case: Russian Bibles are under cu/ru/ in XML but should be ru/ + const isRussianBible = id.startsWith('cu/ru/bible/'); + const effectiveId = isRussianBible ? id.replace('cu/ru/', 'ru/') : id; + const effectiveLang = isRussianBible ? 'ru' : effectiveId.split('/')[0]; + + const version: BibleVersionMeta = { + id: effectiveId, + name, + language: effectiveLang, + books: [], + }; + + // Parse INFO attributes for display formatting + if (bible['INFO']) { + const infoObj = bible['INFO'] as Record; + const formatting: Record = {}; + for (const [k, v] of Object.entries(infoObj)) { + if (k.startsWith('@_')) { + formatting[k.slice(2)] = v; + } + } + if (Object.keys(formatting).length > 0) { + (version as Record).formatting = formatting; + } + } + + // Parse books + if (bible['BOOK']) { + const bookArr = Array.isArray(bible['BOOK']) ? bible['BOOK'] : [bible['BOOK']]; + for (const book of bookArr) { + const bookId = book['@_Id'] || book['@_ID'] || ''; + const bookName = book['@_Name'] || ''; + const bookShort = book['@_Short'] || book['@_Abbr'] || ''; + const bookChapters = parseInt(book['@_Chapters'] || '0', 10); + const bookIntro = book['@_Intro'] || undefined; + const bookMeta: BibleBookMeta = { + id: bookId, + name: bookName, + short: bookShort, + chapters: bookChapters, + }; + if (bookIntro) bookMeta.intro = bookIntro; + version.books.push(bookMeta); + } + } + + versions.push(version); + } + + // Write versions metadata + const outDir = join(OUT, 'bible'); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, 'versions.json'), JSON.stringify(versions, null, 2)); + console.log(` ${versions.length} Bible versions`); + +// Copy .text files for each version + for (const ver of versions) { + // Use the version's id directly (already normalized) + const idParts = ver.id.split('/'); + const lang = idParts[0]; + const subPath = idParts.slice(1).join('/'); // "bible/kjv" + const textSrc = join(SRC, 'languages', lang, subPath); + + if (!existsSync(textSrc)) continue; + + const textFiles = readdirSync(textSrc).filter(f => f.endsWith('.text')); + if (textFiles.length === 0) continue; + + // Fix: Russian Bibles are under cu/ru/ in source but should go to ru/ in output + let outLang: string; + let outSubPath: string; + if (ver.id.startsWith('cu/ru/')) { + outLang = 'ru'; + outSubPath = idParts.slice(2).join('/'); // Skip "cu/ru" → "bible/synod" + } else { + outLang = lang; + outSubPath = subPath; + } + const textDst = join(OUT, outLang, outSubPath); + mkdirSync(textDst, { recursive: true }); + + for (const f of textFiles) { + cpSync(join(textSrc, f), join(textDst, f)); + } + console.log(` ${ver.id}: ${textFiles.length} .text files copied to ${outLang}/`); + } +} diff --git a/scripts/convert-calendar.ts b/scripts/convert-calendar.ts new file mode 100644 index 0000000..a713248 --- /dev/null +++ b/scripts/convert-calendar.ts @@ -0,0 +1,215 @@ +/** + * Convert calendar XML data (triodion, pentecostarion, menaion, commemorations) to JSON. + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, +}); + +function parseFile(path: string): Record | null { + if (!existsSync(path)) return null; + const xml = readFileSync(path, 'utf-8'); + try { + return parser.parse(xml); + } catch { + return null; + } +} + +function extractDayData(data: Record | null): unknown[] { + if (!data) return []; + const result: unknown[] = []; + + // Handle structure (menaion per-language files) + if (data['DAY']) { + const day = data['DAY']; + if (typeof day === 'object' && day !== null) { + const saints = day['SAINT']; + if (saints) { + const saintArr = Array.isArray(saints) ? saints : [saints]; + for (const saint of saintArr) { + if (typeof saint === 'object' && saint !== null) { + // fast-xml-parser puts attributes at top level with @_ prefix + const sId = saint['@_SId'] || saint['@_SID'] || ''; + const cId = saint['@_CId'] || saint['@_CID'] || ''; + if (cId) { + result.push({ SId: sId, CId: cId }); + } + } + } + } + } + } + + // Handle ... structure (triodion/pentecostarion shared files) + if (data['LANGUAGE']) { + const lang = data['LANGUAGE']; + if (typeof lang === 'object' && lang !== null) { + for (const [key, value] of Object.entries(lang as Record)) { + if (key === '@_attributes') continue; + if (typeof value === 'object' && value !== null) { + const attrs = (value as Record)['@_attributes'] as Record | undefined; + if (attrs && Object.keys(attrs).length > 0) { + result.push(attrs); + } + } + } + } + } + + return result; +} + +function convertCommemorationFile(data: Record): Record | null { + if (!data) return null; + const comm = data['COMMEMORATION']; + if (!comm || typeof comm !== 'object') return null; + const result: Record = {}; + + function extractAttrs(obj: Record): Record { + const attrs: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (k.startsWith('@_')) { + attrs[k.slice(2)] = v; // strip @_ prefix + } + } + return attrs; + } + + function processElement(obj: Record): Record { + const attrs = extractAttrs(obj); + const text = obj['#text']; + const out = { ...attrs }; + if (typeof text === 'string' && text.trim()) { + out.text = text.trim(); + } + return out; + } + + for (const [key, value] of Object.entries(comm as Record)) { + if (key.startsWith('@_')) continue; // skip root attributes + if (typeof value !== 'object' || value === null) continue; + + if (Array.isArray(value)) { + result[key] = value.map(item => + typeof item === 'object' && item !== null ? processElement(item as Record) : item + ); + } else if (typeof value === 'object') { + result[key] = processElement(value as Record); + } + } + + return Object.keys(result).length > 0 ? result : null; +} + +export async function convertCalendar(SRC: string, OUT: string) { + console.log('Converting calendar data...'); + + // --- Triodion (shared, language-agnostic) --- + const triodionDir = join(SRC, 'languages', 'xml', 'triodion'); + if (existsSync(triodionDir)) { + const outDir = join(OUT, 'shared', 'triodion'); + mkdirSync(outDir, { recursive: true }); + const files = readdirSync(triodionDir).filter(f => f.endsWith('.xml')).sort(); + for (const file of files) { + const data = parseFile(join(triodionDir, file)); + const dayData = extractDayData(data); + writeFileSync(join(outDir, file.replace('.xml', '.json')), JSON.stringify(dayData)); + } + console.log(` triodion: ${files.length} files`); + } + + // --- Pentecostarion (shared, language-agnostic) --- + const pentecostarionDir = join(SRC, 'languages', 'xml', 'pentecostarion'); + if (existsSync(pentecostarionDir)) { + const outDir = join(OUT, 'shared', 'pentecostarion'); + mkdirSync(outDir, { recursive: true }); + const files = readdirSync(pentecostarionDir).filter(f => f.endsWith('.xml')).sort(); + for (const file of files) { + const data = parseFile(join(pentecostarionDir, file)); + const dayData = extractDayData(data); + writeFileSync(join(outDir, file.replace('.xml', '.json')), JSON.stringify(dayData)); + } + console.log(` pentecostarion: ${files.length} files`); + } + + // --- Commemorations (shared, indexed by CId) --- + const commBase = join(SRC, 'languages', 'xml', 'Commemorations'); + if (existsSync(commBase)) { + const outDir = join(OUT, 'shared', 'commemorations'); + mkdirSync(outDir, { recursive: true }); + let count = 0; + const subdirs = readdirSync(commBase); + for (const sub of subdirs) { + const subDir = join(commBase, sub); + if (!existsSync(subDir)) continue; + const files = readdirSync(subDir).filter(f => f.endsWith('.xml')); + for (const file of files) { + const data = parseFile(join(subDir, file)); + const commData = convertCommemorationFile(data); + if (commData) { + const cid = file.replace('.xml', ''); + writeFileSync(join(outDir, `${cid}.json`), JSON.stringify(commData)); + count++; + } + } + } + console.log(` commemorations: ${count} files`); + } + + // --- Menaion (per-language monthly day files) --- + const MENAION_SOURCES = [ + { id: 'en', prefix: 'en' }, + { id: 'cu', prefix: 'cu' }, + { id: 'el', prefix: 'el' }, + { id: 'fr', prefix: 'fr' }, + { id: 'zh', prefix: 'zh' }, + ]; + + for (const { id, prefix } of MENAION_SOURCES) { + const xmlDir = join(SRC, 'languages', prefix, 'xml'); + if (!existsSync(xmlDir)) continue; + + const outDir = join(OUT, id, 'menaion'); + mkdirSync(outDir, { recursive: true }); + + let count = 0; + const months = readdirSync(xmlDir).filter(f => /^\d{2}$/.test(f)); + for (const month of months) { + const monthDir = join(xmlDir, month); + if (!existsSync(monthDir)) continue; + const days = readdirSync(monthDir).filter(f => f.endsWith('.xml')); + for (const day of days) { + const data = parseFile(join(monthDir, day)); + const dayData = extractDayData(data); + if (dayData) { + const mm = month; + const dd = day.replace('.xml', ''); + writeFileSync(join(outDir, `${mm}-${dd}.json`), JSON.stringify(dayData)); + count++; + } + } + } + console.log(` ${id}: ${count} menaion day files`); + + // Build menaion bundle (all days in one file for fast loading) + if (count > 0) { + const bundle: Record = {}; + const files = readdirSync(outDir).filter(f => f.endsWith('.json')); + for (const f of files) { + try { + bundle[f.replace('.json', '')] = JSON.parse(readFileSync(join(outDir, f), 'utf-8')); + } catch {} + } + writeFileSync(join(outDir, '..', 'menaion-bundle.json'), JSON.stringify(bundle)); + } + } +} diff --git a/scripts/convert-commands.ts b/scripts/convert-commands.ts new file mode 100644 index 0000000..f9e871c --- /dev/null +++ b/scripts/convert-commands.ts @@ -0,0 +1,59 @@ +/** + * Convert command XML files to JSON. + * Handles both per-language (en/xml/Commands/, cu/xml/Commands/) + * and shared (xml/Commands/). + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, +}); + +function convertCmdDir(srcDir: string, outDir: string): number { + if (!existsSync(srcDir)) return 0; + mkdirSync(outDir, { recursive: true }); + const files = readdirSync(srcDir).filter(f => f.endsWith('.xml')); + for (const file of files) { + const data = parser.parse(readFileSync(join(srcDir, file), 'utf-8')); + const outName = file.replace('.xml', '.json'); + writeFileSync(join(outDir, outName), JSON.stringify(data, null, 2)); + } + return files.length; +} + +export async function convertCommands(SRC: string, OUT: string) { + console.log('Converting command files...'); + + // Shared commands (Fasting.xml, ServiceRules.xml, DivineLiturgy.xml, ScriptureTransfers.xml) + const sharedCmdDir = join(SRC, 'languages', 'xml', 'Commands'); + const sharedOut = join(OUT, 'shared', 'commands'); + const sharedCount = convertCmdDir(sharedCmdDir, sharedOut); + console.log(` shared: ${sharedCount} command files`); + + // Per-language commands (Times.xml, Podobni.xml, LanguagePacks.xml, etc.) + const CMD_SOURCES = [ + { id: 'en', prefix: 'en' }, + { id: 'cu', prefix: 'cu' }, + { id: 'ru', prefix: 'cu/ru' }, + { id: 'el', prefix: 'el' }, + { id: 'el/mono', prefix: 'el/mono' }, + { id: 'fr', prefix: 'fr' }, + { id: 'zh/Hans', prefix: 'zh/Hans' }, + { id: 'zh/Hant', prefix: 'zh/Hant' }, + { id: 'zh', prefix: 'zh' }, + ]; + + for (const { id, prefix } of CMD_SOURCES) { + const cmdDir = join(SRC, 'languages', prefix, 'xml', 'Commands'); + const outDir = join(OUT, id, 'commands'); + const count = convertCmdDir(cmdDir, outDir); + if (count > 0) console.log(` ${id}: ${count} command files`); + } +} diff --git a/scripts/convert-fasting.ts b/scripts/convert-fasting.ts new file mode 100644 index 0000000..fe8aa62 --- /dev/null +++ b/scripts/convert-fasting.ts @@ -0,0 +1,61 @@ +/** + * Convert Fasting.xml to JSON. + * Fasting.xml is shared at languages/xml/Commands/Fasting.xml + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, +}); + +export async function convertFasting(SRC: string, OUT: string) { + console.log('Converting Fasting.xml...'); + + const filePath = join(SRC, 'languages', 'xml', 'Commands', 'Fasting.xml'); + if (!existsSync(filePath)) { + console.log(' Fasting.xml not found, skipping'); + return; + } + + const data = parser.parse(readFileSync(filePath, 'utf-8')); + const periods: { cmd: string; rules: { case: string; cmd: string }[] }[] = []; + + if (data['FASTING']) { + const fasting = data['FASTING']; + const periodArr = Array.isArray(fasting['PERIOD']) ? fasting['PERIOD'] : [fasting['PERIOD']]; + + for (const period of periodArr) { + if (!period) continue; + const periodCmd = period['@_Cmd'] || ''; + const rules: { case: string; cmd: string }[] = []; + + if (period['RULE']) { + const ruleArr = Array.isArray(period['RULE']) ? period['RULE'] : [period['RULE']]; + for (const rule of ruleArr) { + if (!rule) continue; + rules.push({ + case: rule['@_Case'] || '1111111', + cmd: rule['@_Cmd'] || '', + }); + } + } + + periods.push({ + cmd: periodCmd, + rules, + }); + } + } + + const outDir = join(OUT, 'shared'); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, 'fasting.json'), JSON.stringify(periods, null, 2)); + console.log(` ${periods.length} fasting periods`); +} diff --git a/scripts/convert-lives.ts b/scripts/convert-lives.ts new file mode 100644 index 0000000..f081687 --- /dev/null +++ b/scripts/convert-lives.ts @@ -0,0 +1,261 @@ +/** + * Convert saint lives XML to JSON. + * Lives are per-language: languages/{lang}/xml/lives/ + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, +}); + +function parseFile(path: string): Record | null { + if (!existsSync(path)) return null; + const xml = readFileSync(path, 'utf-8'); + try { + return parser.parse(xml); + } catch { + return null; + } +} + +function extractAttrs(obj: Record): Record { + const attrs: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (k.startsWith('@_')) { + attrs[k.slice(2)] = v; + } + } + return attrs; +} + +function simplifySaintData(data: Record): Record | null { + if (!data) return null; + const result: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (key === '@_attributes') continue; + if (typeof value !== 'object' || value === null) continue; + + const attrs = (value as Record)['@_attributes'] as Record | undefined; + const k = key.toUpperCase(); + + if (k === 'GRAMMAR') { + const grammar: Record = {}; + for (const [gKey, gValue] of Object.entries(value as Record)) { + if (gKey === '@_attributes') continue; + if (typeof gValue === 'string') grammar[gKey] = gValue; + else if (typeof gValue === 'object' && gValue !== null) { + const inner = (gValue as Record)['#text']; + if (typeof inner === 'string') grammar[gKey] = inner; + } + } + result.grammar = grammar; + } else if (k === 'NAME') { + const nameObj: Record = {}; + const nameItems = Array.isArray(value) ? value : [value]; + for (const nameItem of nameItems) { + if (typeof nameItem === 'object' && nameItem !== null) { + for (const [nKey, nValue] of Object.entries(nameItem as Record)) { + if (nKey === '@_attributes') continue; + if (typeof nValue === 'string') nameObj[nKey] = nValue; + } + } + } + result.name = nameObj; + } else if (k === 'LIFE') { + const text = (value as Record)['#text']; + const lifeAttrs = extractAttrs(value as Record); + result.life = { + id: lifeAttrs.ID as string || '', + copyright: lifeAttrs.Copyright as string || '', + text: typeof text === 'string' ? text : '', + }; + } else if (k === 'RANK') { + const rankAttrs = extractAttrs(value as Record); + result.rank = parseInt(rankAttrs.ID as string || '0', 10); + } else if (k === 'SERVICE') { + const services: Record>> = {}; + const scriptures: { type?: string; reading?: string; pericope?: string; cmd?: string }[] = []; + + // Extract rank from SERVICE Type attribute (handles both single and array) + const svcItems = Array.isArray(value) ? value : [value]; + for (const svcItem of svcItems) { + const svcType = (svcItem as Record)['@_Type']; + if (svcType) { + const t = parseInt(svcType as string, 10); + if (!isNaN(t) && t > (result.rank || 0)) { + result.rank = t; + } + } + } + + function extractScriptures(obj: Record): void { + for (const [sk, sv] of Object.entries(obj)) { + if (sk === 'SCRIPTURE' && sv) { + const svArr = Array.isArray(sv) ? sv : [sv]; + for (const item of svArr) { + if (typeof item === 'object' && item !== null) { + const attrs = extractAttrs(item as Record); + if (Object.keys(attrs).length > 0) { + scriptures.push(attrs); + } + } + } + } else if (typeof sv === 'object' && sv !== null) { + extractScriptures(sv as Record); + } + } + } + + // Recursively extract nested service elements into {group/{element/Type}: {attrs+text}} + function extractServiceGroup(obj: Record): Record> { + const group: Record> = {}; + for (const [gk, gv] of Object.entries(obj)) { + if (gk === '@_attributes' || gk === '#text') continue; + if (typeof gv !== 'object' || gv === null) continue; + const gArr = Array.isArray(gv) ? gv : [gv]; + for (const item of gArr) { + if (typeof item !== 'object' || item === null) continue; + const attrs = extractAttrs(item as Record); + const text = (item as Record)['#text']; + const type = attrs.Type as string || '0'; + const key = `${gk}/${type}`; + group[key] = { text: typeof text === 'string' ? text : '', ...attrs }; + } + } + return group; + } + + for (const [sKey, sValue] of Object.entries(value as Record)) { + if (sKey === '@_attributes') continue; + if (typeof sValue === 'object' && sValue !== null) { + const sObj = sValue as Record; + const sAttrs = sObj['@_attributes'] as Record | undefined; + const sText = sObj['#text']; + + // Check if this element has nested children (e.g., ROYALHOURS contains IDIOMEL, VERSE, etc.) + const childKeys = Object.keys(sObj).filter(k => k !== '@_attributes' && k !== '#text'); + if (childKeys.length > 0) { + // Has nested elements — extract recursively + services[sKey] = extractServiceGroup(sObj); + } else { + // Leaf element — store directly + services[sKey] = { text: typeof sText === 'string' ? sText : '', ...(sAttrs || {}) }; + } + + // Extract scriptures from this service (recursively) + extractScriptures(sValue as Record); + } + } + if (Object.keys(services).length > 0) result.services = services; + if (scriptures.length > 0) result.scripture = scriptures; + } else if (k === 'ICON') { + const iconAttrs = extractAttrs(value as Record); + result.icon = iconAttrs.ID as string || ''; + } else if (k === 'SCRIPTURE') { + const scriptures: Record[] = []; + const scriptureArr = Array.isArray(value) ? value : [value]; + for (const s of scriptureArr) { + if (typeof s === 'object' && s !== null) { + const attrs = extractAttrs(s as Record); + if (Object.keys(attrs).length > 0) { + scriptures.push(attrs); + } + } + } + if (scriptures.length > 0) { + result.scripture = scriptures; + } + } + } + + return Object.keys(result).length > 0 ? result : null; +} + +export async function convertLives(SRC: string, OUT: string) { + console.log('Converting saint lives...'); + + const LANG_CONFIGS = [ + { id: 'en', prefix: 'en' }, + { id: 'cu', prefix: 'cu' }, + { id: 'ru', prefix: 'cu/ru' }, + { id: 'el', prefix: 'el' }, + { id: 'el/mono', prefix: 'el/mono' }, + { id: 'fr', prefix: 'fr' }, + { id: 'zh/Hans', prefix: 'zh/Hans' }, + { id: 'zh/Hant', prefix: 'zh/Hant' }, + { id: 'shared', prefix: '' }, + ]; + + for (const { id, prefix } of LANG_CONFIGS) { + const livesDir = join(SRC, 'languages', prefix, 'xml', 'lives'); + if (!existsSync(livesDir)) continue; + + const outDir = join(OUT, id, 'lives'); + mkdirSync(outDir, { recursive: true }); + // Clean old individual files from previous conversions + for (const oldFile of readdirSync(outDir).filter(f => f.endsWith('.json'))) { + rmSync(join(outDir, oldFile), { force: true }); + } + + let count = 0; + const files = readdirSync(livesDir).filter(f => f.endsWith('.xml')); + const bundles: Record>> = {}; + + for (const file of files) { + const data = parseFile(join(livesDir, file)); + if (!data) continue; + + let saintData: Record | null = null; + for (const [key, value] of Object.entries(data)) { + if (typeof value === 'object' && value !== null && key !== '@_attributes') { + saintData = simplifySaintData(value as Record); + break; + } + } + + if (saintData) { + const cid = file.replace('.xml', ''); + const mm = cid.match(/^(0[1-9]|1[0-2])\d+/)?.[1]; + const bundleName = mm ? `${mm}.json` : 'misc.json'; + if (!bundles[bundleName]) bundles[bundleName] = {}; + bundles[bundleName][cid] = saintData; + count++; + } + } + + // Write bundles + for (const [bundleName, bundle] of Object.entries(bundles)) { + writeFileSync(join(outDir, bundleName), JSON.stringify(bundle)); + } + + console.log(` ${id}: ${count} saint lives in ${Object.keys(bundles).length} bundles`); + } + + // Build shared lives index (CId → scripture for fast lookup) + const sharedOut = join(OUT, 'shared', 'lives'); + const index: Record = {}; + if (existsSync(sharedOut)) { + const files = readdirSync(sharedOut).filter(f => f.endsWith('.json')); + for (const f of files) { + if (f === 'lives-index.json') continue; + try { + const bundle = JSON.parse(readFileSync(join(sharedOut, f), 'utf-8')); + for (const [cid, data] of Object.entries(bundle as Record>)) { + if (data.scripture) { + index[cid] = data.scripture; + } + } + } catch {} + } + } + writeFileSync(join(sharedOut, '..', 'lives-index.json'), JSON.stringify(index)); + console.log(` lives-index: ${Object.keys(index).length} entries`); +} diff --git a/scripts/convert-service-rules.ts b/scripts/convert-service-rules.ts new file mode 100644 index 0000000..6d92461 --- /dev/null +++ b/scripts/convert-service-rules.ts @@ -0,0 +1,71 @@ +/** + * Convert ServiceRules.xml to JSON. + * ServiceRules.xml is shared at languages/xml/Commands/ServiceRules.xml + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, +}); + +export async function convertServiceRules(SRC: string, OUT: string) { + console.log('Converting ServiceRules.xml...'); + + const filePath = join(SRC, 'languages', 'xml', 'Commands', 'ServiceRules.xml'); + if (!existsSync(filePath)) { + console.log(' ServiceRules.xml not found, skipping'); + return; + } + + const data = parser.parse(readFileSync(filePath, 'utf-8')); + const periods: { cmd: string; rules: Record[] }[] = []; + + const root = data['DATA'] || data['SERVICES']; + if (root) { + const periodArr = Array.isArray(root['PERIOD']) ? root['PERIOD'] : [root['PERIOD']]; + + for (const period of periodArr) { + if (!period) continue; + const periodCmd = root['@_Cmd'] || period['@_Cmd'] || ''; + const rules: Record[] = []; + + for (const [key, value] of Object.entries(period)) { + if (key.startsWith('@_')) continue; + if (typeof value === 'object' && value !== null) { + const ruleArr = Array.isArray(value) ? value : [value]; + for (const rule of ruleArr) { + if (typeof rule === 'object' && rule !== null) { + // Collect all @_-prefixed attributes (flat format) + const attrs: Record = {}; + for (const [rKey, rValue] of Object.entries(rule as Record)) { + if (rKey.startsWith('@_') && typeof rValue === 'string') { + attrs[rKey.slice(2)] = rValue; + } + } + if (Object.keys(attrs).length > 0) { + rules.push({ type: key, ...attrs }); + } + } + } + } + } + + periods.push({ + cmd: periodCmd, + rules, + }); + } + } + + const outDir = join(OUT, 'shared'); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, 'service-rules.json'), JSON.stringify(periods, null, 2)); + console.log(` ${periods.length} service rule periods`); +} diff --git a/scripts/convert-services.ts b/scripts/convert-services.ts new file mode 100644 index 0000000..1326fa1 --- /dev/null +++ b/scripts/convert-services.ts @@ -0,0 +1,199 @@ +/** + * Convert service template XML to JSON. + * Recursively converts all subdirectories under Services/. + * Unwraps COMMONPRAYER root wrappers, flattens children, lowercases attributes. + */ + +import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, + parseTagValue: false, + trimValues: true, + preserveOrder: true, +}); + +function parseFile(path: string): unknown[] | null { + if (!existsSync(path)) return null; + const xml = readFileSync(path, 'utf-8'); + try { + return parser.parse(xml) as unknown[]; + } catch { + return null; + } +} + +/** + * Lowercase all @_-prefixed attribute keys and strip the prefix. + * e.g. { '@_Value': 'Wisdom.', '@_Who': 'Deacon' } → { value: 'Wisdom.', who: 'Deacon' } + */ +function lowerAttrs(obj: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key.startsWith('@_')) { + result[key.slice(2).toLowerCase()] = value; + } else { + result[key] = value; + } + } + return result; +} + +/** + * Convert a parsed XML node (preserveOrder format) into a flat array of service nodes. + * Input is an array of ordered entries, each: { tagName: children[], ":@": { attrs } } + */ +function convertNodes(data: unknown[]): unknown[] { + const result: unknown[] = []; + + for (const entry of data as Record[]) { + // Get the tag name (first key that isn't ':@') + const tagName = Object.keys(entry).find(k => k !== ':@'); + if (!tagName) continue; + + const attrs = (entry[':@'] ?? {}) as Record; + const children = (entry[tagName] ?? []) as unknown[]; + + // Unwrap wrapper elements (COMMONPRAYER, SERVICES, SERVICE) + if (tagName === 'COMMONPRAYER' || tagName === 'SERVICE' || tagName === 'SERVICES') { + result.push(...convertNodes(children)); + continue; + } + + // Convert element with its attributes + const node = convertNodeItem(tagName, attrs, children); + if (node) result.push(node); + } + + return result; +} + +/** + * Convert a single XML element into a service node object. + */ +function convertNodeItem( + tagName: string, + attrs: Record, + children: unknown[] +): Record | null { + const lowercased = lowerAttrs(attrs); + + // Convert boolean attribute values: "1"→true, "0"→false + const boolKeys = ['redfirst', 'newline', 'header', 'null', 'withmen']; + for (const k of boolKeys) { + if (lowercased[k] === '1') lowercased[k] = true; + else if (lowercased[k] === '0') lowercased[k] = false; + } + // Convert numeric attribute values: "3"→3 + const numKeys = ['times']; + for (const k of numKeys) { + if (typeof lowercased[k] === 'string' && /^\d+$/.test(lowercased[k])) { + lowercased[k] = parseInt(lowercased[k], 10); + } + } + const node: Record = { type: tagName }; + + // Copy all attributes as lowercase keys + for (const [key, value] of Object.entries(lowercased)) { + if (key === 'type') continue; + node[key] = value; + } + + // Process child elements recursively + if (children.length > 0) { + // Check for #text content + const textNode = children.find(c => (c as Record)['#text']); + if (textNode) { + node.text = (textNode as Record)['#text'] as string; + } else { + const childNodes = convertNodes(children); + if (childNodes.length > 0) { + node._children = childNodes; + } + } + } + + return node; +} + +function convertDirRecursive(srcDir: string, outDir: string): number { + let count = 0; + mkdirSync(outDir, { recursive: true }); + + const entries = readdirSync(srcDir); + for (const entry of entries) { + if (entry.startsWith('.')) continue; + const srcPath = join(srcDir, entry); + + if (!existsSync(srcPath)) continue; + + if (entry.endsWith('.xml')) { + const data = parseFile(srcPath); + if (!data) continue; + + // Convert and flatten + const nodes = flattenNodes(convertNodes(data)); + const outName = entry.replace('.xml', '.json'); + writeFileSync(join(outDir, outName), JSON.stringify(nodes)); + count++; + } else { + // Subdirectory (e.g. "Tone 1", "KONTAKION", "TROPARION") + const subOut = join(outDir, entry); + count += convertDirRecursive(srcPath, subOut); + } + } + + return count; +} + +/** + * Flatten nodes that have _children into a single flat array. + */ +function flattenNodes(nodes: unknown[]): unknown[] { + const result: unknown[] = []; + for (const node of nodes) { + if (typeof node !== 'object' || node === null) { + result.push(node); + continue; + } + const n = node as Record; + if (n._children && Array.isArray(n._children)) { + // This node has children — push the node itself (without _children) then flatten children + const { _children, ...rest } = n; + result.push(rest); + result.push(...flattenNodes(_children)); + } else { + result.push(n); + } + } + return result; +} + +export async function convertServices(SRC: string, OUT: string) { + console.log('Converting service templates...'); + + const SERVICE_SOURCES = [ + { id: 'en', prefix: 'en' }, + { id: 'cu', prefix: 'cu' }, + { id: 'ru', prefix: 'cu/ru' }, + { id: 'el', prefix: 'el' }, + { id: 'fr', prefix: 'fr' }, + { id: 'zh/Hans', prefix: 'zh/Hans' }, + { id: 'zh/Hant', prefix: 'zh/Hant' }, + { id: 'zh', prefix: 'zh' }, + { id: 'shared', prefix: '' }, + ]; + + for (const { id, prefix } of SERVICE_SOURCES) { + const servicesDir = join(SRC, 'languages', prefix, 'xml', 'Services'); + if (!existsSync(servicesDir)) continue; + + const outDir = join(OUT, id, 'services'); + const count = convertDirRecursive(servicesDir, outDir); + console.log(` ${id}: ${count} service files`); + } +} diff --git a/scripts/generate-icons.js b/scripts/generate-icons.js new file mode 100644 index 0000000..5708b2c --- /dev/null +++ b/scripts/generate-icons.js @@ -0,0 +1,84 @@ +import { writeFileSync, mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import zlib from 'zlib'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const iconsDir = join(__dirname, '..', 'static', 'icons'); +mkdirSync(iconsDir, { recursive: true }); + +function crc32(buf) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createChunk(type, data) { + const typeBytes = Buffer.from(type, 'ascii'); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([typeBytes, data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +function generatePNG(size) { + const bg = [0x1a, 0x1a, 0x2e]; + const gold = [0xc5, 0xa5, 0x5a]; + + const pixels = []; + for (let y = 0; y < size; y++) { + pixels.push(0); + for (let x = 0; x < size; x++) { + if (isCross(x, y, size)) { + pixels.push(...gold); + } else { + pixels.push(...bg); + } + } + } + + const raw = Buffer.from(pixels); + const compressed = zlib.deflateSync(raw); + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; + ihdr[9] = 2; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + + return Buffer.concat([ + signature, + createChunk('IHDR', ihdr), + createChunk('IDAT', compressed), + createChunk('IEND', Buffer.alloc(0)), + ]); +} + +function isCross(x, y, size) { + const cx = Math.floor(size / 2); + const cy = Math.floor(size / 2); + const arm = Math.max(2, Math.floor(size * 0.06)); + const halfLen = Math.floor(size * 0.32); + const topLen = Math.floor(size * 0.38); + + const inVert = Math.abs(x - cx) <= arm && y >= cy - topLen && y <= cy + halfLen; + const inHoriz = Math.abs(y - cy) <= arm && x >= cx - halfLen && x <= cx + halfLen; + + return inVert || inHoriz; +} + +writeFileSync(join(iconsDir, 'icon-192.png'), generatePNG(192)); +writeFileSync(join(iconsDir, 'icon-512.png'), generatePNG(512)); +console.log('Icons generated.'); diff --git a/scripts/generate-icons.py b/scripts/generate-icons.py new file mode 100644 index 0000000..9a66c70 --- /dev/null +++ b/scripts/generate-icons.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Generate PWA icons from OrthodoxCross.svg using rsvg-convert + Pillow.""" + +import os +import subprocess +import tempfile +from PIL import Image + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.join(SCRIPT_DIR, '..') +SVG_PATH = os.path.join(PROJECT_DIR, 'static', 'OrthodoxCross.svg') +ICONS_DIR = os.path.join(PROJECT_DIR, 'static', 'icons') +os.makedirs(ICONS_DIR, exist_ok=True) + +GOLD = (0xC5, 0xA5, 0x5A) +NAVY = (0x1A, 0x1A, 0x2E) +RENDER_SIZE = 2048 + +def generate_icon(size): + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + tmp_path = tmp.name + try: + subprocess.run([ + 'rsvg-convert', + '-w', str(RENDER_SIZE), + '-h', str(RENDER_SIZE), + SVG_PATH, + '-o', tmp_path, + ], check=True) + img = Image.open(tmp_path).convert('RGBA') + result = Image.new('RGBA', (size, size), NAVY + (255,)) + cross = img.resize((size, size), Image.LANCZOS) + gold_layer = Image.new('RGBA', (size, size), GOLD + (255,)) + result.paste(gold_layer, mask=cross.split()[3]) + return result + finally: + os.unlink(tmp_path) + +for size in [192, 512]: + icon = generate_icon(size) + out = os.path.join(ICONS_DIR, f'icon-{size}.png') + icon.save(out, 'PNG') + print(f'Saved {out} ({size}x{size})') + +print('Icons generated.') diff --git a/src/core/bible-reader.ts b/src/core/bible-reader.ts new file mode 100644 index 0000000..279ae44 --- /dev/null +++ b/src/core/bible-reader.ts @@ -0,0 +1,203 @@ +/** + * Bible Reader — parses .text files and manages Bible navigation. + * Port of key Bible.java functionality. + * + * Bible.java is part of the Ponomar program. + * Copyright 2007, 2008 Aleksandr Andreev. + * GPL v3 — see LICENSE. + */ + +// Bible types used for version metadata + +export interface BiblePassage { + bookId: string; + chapter: number; + verses: { num: number; text: string }[]; +} + +export interface ParsedReading { + chapters: number[]; + verses: number[]; +} + +/** + * Parse a passage string like "2:11-3:2, 5, 13-14" into structured data. + */ +export function parsePassage(passage: string): ParsedReading { + const chapters: number[] = []; + const verses: number[] = []; + + if (!passage.includes(':')) { + // Just a chapter, e.g. "1" + const ch = parseInt(passage, 10); + chapters.push(ch); + verses.push(1); + return { chapters, verses }; + } + + // Composite passage: "2:11-3:2, 5, 13-14" + const parts = passage.split(','); + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed.includes('-')) { + // Single verse or chapter:verse + if (trimmed.includes(':')) { + const [ch, v] = trimmed.split(':'); + chapters.push(parseInt(ch, 10)); + verses.push(parseInt(v, 10)); + } else { + const v = parseInt(trimmed, 10); + chapters.push(0); // current chapter + verses.push(v); + } + } else { + // Range: "2:11-3:2" or "13-14" + const [start, end] = trimmed.split('-'); + if (start.includes(':')) { + const [ch, v] = start.split(':'); + chapters.push(parseInt(ch, 10)); + verses.push(parseInt(v, 10)); + } else { + chapters.push(0); + verses.push(parseInt(start, 10)); + } + if (end.includes(':')) { + const [ch, v] = end.split(':'); + chapters.push(parseInt(ch, 10)); + verses.push(parseInt(v, 10)); + } else { + chapters.push(0); + verses.push(parseInt(end, 10)); + } + } + } + + return { chapters, verses }; +} + +/** + * Parse a .text file's content into structured verses. + * Format: "#N" = chapter marker, "N|" = verse marker followed by text. + */ +export function parseTextFile(content: string): Map> { + const chapters = new Map>(); + let currentChapter = 0; + let currentVerses = new Map(); + + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.startsWith('#')) { + // Chapter marker + if (currentChapter > 0) { + chapters.set(currentChapter, currentVerses); + } + currentChapter = parseInt(trimmed.substring(1), 10); + currentVerses = new Map(); + } else if (trimmed.includes('|')) { + // Verse marker + const pipeIdx = trimmed.indexOf('|'); + const verseNum = parseInt(trimmed.substring(0, pipeIdx), 10); + const verseText = trimmed.substring(pipeIdx + 1).trim(); + currentVerses.set(verseNum, verseText); + } + } + + // Don't forget the last chapter + if (currentChapter > 0) { + chapters.set(currentChapter, currentVerses); + } + + return chapters; +} + +/** + * Extract a passage from parsed chapter data. + */ +export function extractPassage( + chapters: Map>, + reading: ParsedReading +): { chapter: number; verse: number; text: string }[] { + const result: { chapter: number; verse: number; text: string }[] = []; + + for (let i = 0; i < reading.chapters.length; i += 2) { + const startCh = reading.chapters[i]; + const startV = reading.verses[i]; + const endCh = reading.chapters[i + 1] ?? startCh; + const endV = reading.verses[i + 1] ?? -1; + + for (const [chNum, verses] of chapters) { + if (chNum < startCh) continue; + if (chNum > endCh) break; + + for (const [vNum, text] of verses) { + if (chNum === startCh && vNum < startV) continue; + if (chNum === endCh && endV > 0 && vNum > endV) continue; + result.push({ chapter: chNum, verse: vNum, text }); + } + } + } + + return result; +} + +/** + * Format a passage for display. + */ +export function formatPassageHTML( + passages: { chapter: number; verse: number; text: string }[], + options?: { showVerseNumbers?: boolean; verseNewLine?: boolean } +): string { + const showVerseNum = options?.showVerseNumbers ?? true; + const verseNewLine = options?.verseNewLine ?? false; + + let html = ''; + let lastChapter = 0; + + for (const p of passages) { + if (p.chapter !== lastChapter) { + if (lastChapter > 0 && !verseNewLine) html += '

'; + if (!verseNewLine) html += '

'; + lastChapter = p.chapter; + } + + let text = p.text; + text = text.replace(/\*\*(.*?)\*\*/g, '$1'); + text = text.replace(/\*\*/g, ''); + + if (verseNewLine && p.verse > 0) { + const num = showVerseNum ? `${p.verse} ` : ''; + html += `${num}${text.charAt(0).toUpperCase() + text.slice(1)}`; + } else { + if (showVerseNum && p.verse > 0) { + html += `${p.verse} `; + } + html += text + ' '; + } + } + + if (!verseNewLine && lastChapter > 0) html += '

'; + + return html; +} + +/** + * Create a hyperlink for a Bible reading reference. + */ +export function createReadingLink( + bookId: string, + passage: string, + abbrevFormat: string, + abbreviations: Record +): string { + const abbrev = abbreviations[bookId] ?? bookId; + const display = abbrevFormat + .replace('^NAME', abbrev) + .replace('^CNN', passage); + + return `${display}`; +} diff --git a/src/core/day-computer.ts b/src/core/day-computer.ts new file mode 100644 index 0000000..69a27ef --- /dev/null +++ b/src/core/day-computer.ts @@ -0,0 +1,147 @@ +/** + * Day Computer — computes all liturgical variables for a given Julian date. + * Replaces the core of Main.write() in the Java version. + */ + +import { JDate } from './jdate'; +import { Paschalion } from './paschalion'; +import type { EvalContext, DayInfo } from './types'; + +export interface ComputedDay { + jdate: JDate; + gregorian: Date; + evalContext: EvalContext; + dayInfo: DayInfo; +} + +/** + * Compute all liturgical variables for a given Julian date. + * Returns both the EvalContext (for expression evaluator) and structured DayInfo. + */ +export function computeDay(date: JDate): ComputedDay { + const year = date.getYear(); + const dow = date.getDayOfWeek(); + const doy = date.getDoy(); + const month = date.getMonth(); + const day = date.getDay(); + + const gregorian = date.toGregorian(); + + // Paschal distances + const pascha = Paschalion.getPascha(year); + const nday = date.difference(pascha); + const ndayP = date.difference(Paschalion.getPascha(year - 1)); + const ndayF = date.difference(Paschalion.getPascha(year + 1)); + + const pentecost = Paschalion.getPentecost(year); + const lentStart = Paschalion.getLentStart(year); + + // Determine file indices for triodion/pentecostarion + let triodionFile: number | null = null; + let pentecostarionFile: number | null = null; + + if (nday >= -70 && nday < 0) { + // Great Lent period — triodion + triodionFile = Math.abs(nday); + } else if (nday < -70) { + // Before Lent — pentecostarion from last year + const lastPascha = Paschalion.getPascha(year - 1); + pentecostarionFile = date.difference(lastPascha) + 1; + } else { + // After Pascha — pentecostarion + pentecostarionFile = nday + 1; + } + + // Determine liturgical state + const isLent = nday >= -48 && nday < 0; + const isBrightWeek = nday >= 0 && nday <= 6; + const isApostlesFast = nday >= 57 && nday <= Paschalion.getApostlesFastLength(year); + const isDormitionFast = month === 8 && day >= 1 && day <= 14; + const isNativityFast = (month === 11 && day >= 15) || (month === 12 && day <= 24); + + // Tone computation — 8-week cycle after Pentecost + // Matches Java pentecostarion XML: tone = (⌊(nday - 7) / 7⌋ % 8) + 1 + // Exceptions: Ascension (nday 39-40) and Pentecost week (nday 48-55) have no tone + let tone = -1; + if (nday >= 7) { + if ((nday >= 39 && nday <= 40) || (nday >= 48 && nday <= 55)) { + tone = -1; // Ascension or Pentecost week — no octoechos tone + } else { + tone = (Math.floor((nday - 7) / 7) % 8) + 1; + } + } + + // Rank computation + let dRank = 0; + // Major feasts get higher rank + if (month === 9 && day === 14) dRank = 6; // Exaltation + else if (month === 8 && day === 29) dRank = 6; // Beheading + else if (nday === 0) dRank = 8; // Pascha + else if (nday === 49) dRank = 8; // Pentecost + else if (nday === 39) dRank = 8; // Ascension + else if (nday === -7) dRank = 8; // Palm Sunday + else if (month === 12 && day === 25) dRank = 6; // Nativity + else if (month === 1 && day === 6) dRank = 6; // Theophany + else if (month === 8 && day === 15) dRank = 6; // Dormition + else if (month === 3 && day === 25) dRank = 6; // Annunciation + else if (nday >= -48 && nday < 0) dRank = 3; // Lent + else if (isBrightWeek || nday >= 49 && nday <= 56) dRank = 5; // Bright/Pentecost week + + // Paschal flag + const PFlag = nday >= 0 ? 1 : 0; + const PFlag1 = 1; // Default service inclusion + const PFlag2 = isBrightWeek || (nday >= -7 && nday < 0) ? 2 : (isLent && dow !== 0 ? 1 : 0); + + // Priest status (1 = priest required, 0 = reader) + const PS = (dRank >= 3 || dow === 0) ? 1 : 0; + + const dayInfo: DayInfo = { + dow, + doy, + month, + day, + year, + nday, + ndayP, + ndayF, + Easter: pascha.jdn, + Pentecost: pentecost.jdn, + LentStart: lentStart.jdn, + Tone: tone, + dRank, + PFlag, + PFlag1, + PFlag2, + PS, + isLent, + isBrightWeek, + isApostlesFast, + isDormitionFast, + isNativityFast, + triodionFile, + pentecostarionFile, + }; + + // Build EvalContext from DayInfo (all values as numbers for the expression evaluator) + const evalContext: EvalContext = { + dow, + doy, + nday, + ndayP, + ndayF, + Year: year, + dRank, + PFlag, + PFlag1, + PFlag2, + PS, + Tone: tone, + Easter: pascha.jdn, + Pentecost: pentecost.jdn, + LentStart: lentStart.jdn, + LS: 1, // Default language + GS: 1, // Default gospel selector + }; + + return { jdate: date, gregorian, evalContext, dayInfo }; +} diff --git a/src/core/evaluator.ts b/src/core/evaluator.ts new file mode 100644 index 0000000..82042ef --- /dev/null +++ b/src/core/evaluator.ts @@ -0,0 +1,294 @@ +/** + * Expression evaluator — recursive descent parser. + * Faithful port of Ponomar/StringOp.java to TypeScript. + * Supports: ||, &&, ==, !=, <, >, <=, >=, +, -, *, /, %, unary !, unary -, true/false, variables. + * + * StringOp.java is part of the Ponomar program. + * Copyright 2007 Aleksandr Andreev and Yuri Shardt. + * See LICENSE for redistribution terms. + */ + +import type { EvalContext } from './types'; + +function bool2double(expr: boolean): number { + return expr ? 1.0 : 0.0; +} + +/** + * Evaluate a boolean expression using dayInfo variable context. + * Returns true if the expression evaluates to a non-zero number. + */ +export function evalBool(expression: string, ctx: EvalContext): boolean { + return evalNum(expression, ctx) !== 0; +} + +/** + * Evaluate a numeric expression. + * Fully obeys correct order of operations (recursive descent). + */ +export function evalNum(expression: string, ctx: EvalContext): number { + const parser = new ExprParser(expression.trim(), ctx); + return parser.parseOr(); +} + +class ExprParser { + private pos = 0; + private readonly src: string; + private readonly ctx: EvalContext; + + constructor(expression: string, ctx: EvalContext) { + // Strip all spaces for tokenization (matches Java behavior) + this.src = expression.replace(/\s/g, ''); + this.ctx = ctx; + } + + private peek(): string { + return this.pos < this.src.length ? this.src[this.pos] : ''; + } + + private advance(): string { + const ch = this.src[this.pos]; + this.pos++; + return ch; + } + + private peekTwo(): string { + return this.src.substring(this.pos, this.pos + 2); + } + + /** + * Read a token: number, variable name, or keyword (true/false). + */ + private readToken(): string { + // Number + if (/[0-9.]/.test(this.peek())) { + let num = ''; + while (this.pos < this.src.length && /[0-9.eE+\-]/.test(this.peek())) { + num += this.advance(); + } + return num; + } + // Variable or keyword + if (/[a-zA-Z_]/.test(this.peek())) { + let name = ''; + while (this.pos < this.src.length && /[a-zA-Z_0-9]/.test(this.peek())) { + name += this.advance(); + } + return name; + } + return ''; + } + + /** + * OR operator: lowest precedence. + * expr || expr + */ + parseOr(): number { + let left = this.parseAnd(); + while (this.pos < this.src.length && this.peekTwo() === '||') { + this.pos += 2; + const right = this.parseAnd(); + left = bool2double(evalBool(String(left !== 0), this.ctx) || evalBool(String(right !== 0), this.ctx)); + } + return left; + } + + /** + * AND operator. + */ + parseAnd(): number { + let left = this.parseEquality(); + while (this.pos < this.src.length && this.peekTwo() === '&&') { + this.pos += 2; + const right = this.parseEquality(); + left = bool2double(evalBool(String(left !== 0), this.ctx) && evalBool(String(right !== 0), this.ctx)); + } + return left; + } + + /** + * Equality: == and != + */ + parseEquality(): number { + let left = this.parseComparison(); + while (this.pos < this.src.length) { + if (this.peekTwo() === '==') { + this.pos += 2; + const right = this.parseComparison(); + left = bool2double(left === right); + } else if (this.peekTwo() === '!=') { + this.pos += 2; + const right = this.parseComparison(); + left = bool2double(left !== right); + } else { + break; + } + } + return left; + } + + /** + * Comparison: <, >, <=, >= + */ + parseComparison(): number { + let left = this.parseAddSub(); + while (this.pos < this.src.length) { + const ch = this.peek(); + if (ch === '<' || ch === '>') { + const next = this.pos + 1 < this.src.length ? this.src[this.pos + 1] : ''; + if (next === '=') { + // <= or >= + if (ch === '<') { + this.pos += 2; + const right = this.parseAddSub(); + left = bool2double(left <= right); + } else { + this.pos += 2; + const right = this.parseAddSub(); + left = bool2double(left >= right); + } + } else { + // < or > + if (ch === '<') { + this.pos += 1; + const right = this.parseAddSub(); + left = bool2double(left < right); + } else { + this.pos += 1; + const right = this.parseAddSub(); + left = bool2double(left > right); + } + } + } else { + break; + } + } + return left; + } + + /** + * Addition and subtraction. + * Must distinguish binary + from unary +/-. + */ + parseAddSub(): number { + let left = this.parseMulDiv(); + + while (this.pos < this.src.length) { + const ch = this.peek(); + if (ch === '+') { + this.pos += 1; + const right = this.parseMulDiv(); + left = left + right; + } else if (ch === '-') { + // Check if this is a binary minus (preceded by a value) or unary + // If preceded by operator or start, it's unary — handled by parseMulDiv → parseUnary + // Here we need to check if this is a binary minus + // A minus is binary if preceded by: number, ), variable, true, false + if (this.pos > 0) { + const prevCh = this.src[this.pos - 1]; + if (/[0-9)TrueFals]/.test(prevCh) || prevCh === 'e' || prevCh === 'E') { + this.pos += 1; + const right = this.parseMulDiv(); + left = left - right; + } else { + break; + } + } else { + break; + } + } else { + break; + } + } + return left; + } + + /** + * Multiplication, division, modulus. + */ + parseMulDiv(): number { + let left = this.parseUnary(); + + while (this.pos < this.src.length) { + const ch = this.peek(); + if (ch === '*') { + this.pos += 1; + const right = this.parseUnary(); + left = left * right; + } else if (ch === '/') { + this.pos += 1; + const right = this.parseUnary(); + left = left / right; + } else if (ch === '%') { + this.pos += 1; + const right = this.parseUnary(); + left = left % right; + } else { + break; + } + } + return left; + } + + /** + * Unary operators: !, - + */ + parseUnary(): number { + // Handle repeated unary operators (e.g., "!!", "---") + while (this.pos < this.src.length) { + const ch = this.peek(); + if (ch === '!') { + this.pos += 1; + const val = this.parseUnary(); + return bool2double(val === 0); + } else if (ch === '-') { + this.pos += 1; + const val = this.parseUnary(); + return -val; + } else if (ch === '+') { + this.pos += 1; + continue; + } else { + break; + } + } + return this.parsePrimary(); + } + + /** + * Primary: number, variable, parenthesized expression, true/false. + */ + parsePrimary(): number { + // Parenthesized expression + if (this.peek() === '(') { + this.pos++; // skip ( + const val = this.parseOr(); + if (this.peek() === ')') { + this.pos++; // skip ) + } + return val; + } + + const token = this.readToken(); + + if (token === 'true' || token === 'True') { + return 1.0; + } + if (token === 'false' || token === 'False') { + return 0.0; + } + + // Try as number + const num = Number(token); + if (!isNaN(num)) { + return num; + } + + // Variable lookup + if (token in this.ctx) { + return Number(this.ctx[token]); + } + + throw new Error(`Unknown variable: ${token}`); + } +} diff --git a/src/core/fasting.ts b/src/core/fasting.ts new file mode 100644 index 0000000..8465c25 --- /dev/null +++ b/src/core/fasting.ts @@ -0,0 +1,156 @@ +/** + * Fasting rules engine. + * Evaluates Fasting.xml rules against dayInfo to determine fasting level for a given day. + * + * Fasting.java is part of the Ponomar program. + * Copyright 2009 Yuri Shardt. + * GPL v3 — see LICENSE. + */ + +import { evalBool } from './evaluator'; +import type { EvalContext } from './types'; + +/** 7-bit fasting case string. Each bit: 1=permitted, 0=forbidden. */ +export type FastingCase = string; + +export interface FastingPeriod { + cmd: string; + rules: FastingRule[]; +} + +export interface FastingRule { + case: FastingCase; + cmd: string; +} + +export interface FastingResult { + case: FastingCase; + level: number; // 0-6 + description: string; + allowed: string[]; + forbidden: string[]; +} + +const FOOD_NAMES_LOCALIZED: Record = { + en: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'], + ru: ['мясо', 'молочные продукты', 'рыба', 'рыбная икра', 'растительное масло', 'вино', 'сухоядение'], + cu: ['мѧ́со', 'мла҆чные продꙋ́кты', 'ры́ба', 'ры́бнаѧ и҆́кра', 'є҆ле́й', 'ві́но̀', 'сухоѧденїе'], + fr: ['le viande', 'les produits lactières', 'le poisson', 'le caviar', "l'huile", 'le vin', 'des nourritures secs'], + el: ['meat', 'dairy products', 'fish', 'caviar', 'oil', 'wine', 'dry food'], + zh: ['肉类和动物制品', '乳制品', '鱼', '鱼子', '油', '酒', '干食'], + 'zh-Hant': ['肉類和動物製品', '乳製品', '魚', '魚子', '油', '酒', '幹食'], +}; + +/** + * Parse fasting XML data. The XML is pre-converted to JSON at build time. + */ +export function parseFastingData(data: FastingPeriod[]): FastingPeriod[] { + return data; +} + +/** + * Evaluate fasting rules for a given day. + * Returns the fasting case (7-bit string) based on matching rules. + */ +export function evaluateFasting( + periods: FastingPeriod[], + ctx: EvalContext, + lang: string = 'en' +): FastingResult { + let currentCase: FastingCase = '1111111'; // Default: no fast + + // Process periods in order; later periods can override earlier ones + for (const period of periods) { + // Check if this period applies to today + if (period.cmd && !evalBool(period.cmd, ctx)) { + continue; + } + + // Evaluate rules within this period + for (const rule of period.rules) { + if (rule.cmd && !evalBool(rule.cmd, ctx)) { + continue; + } + // This rule matches — apply it + currentCase = rule.case; + } + } + + return formatFastingResult(currentCase, lang); +} + +/** + * Format a fasting case into a human-readable result. + */ +function formatFastingResult(fastCase: FastingCase, lang: string = 'en'): FastingResult { + const names = FOOD_NAMES_LOCALIZED[lang] || FOOD_NAMES_LOCALIZED.en; + const allowed: string[] = []; + const forbidden: string[] = []; + + // Case format: meat,cheese,fish,caviar,oil,wine,dryFood + // Index 0 = meat, 6 = dry food (leftmost = highest food, rightmost = lowest) + for (let i = 0; i < 7; i++) { + const permitted = fastCase[i] === '1'; + if (permitted) { + allowed.push(names[i]); + } else { + forbidden.push(names[i]); + } + } + + // Determine fasting level + let level = 0; + switch (fastCase) { + case '1111111': level = 0; break; // No fast + case '0111111': level = 1; break; // Meat excluded + case '0011111': level = 2; break; // Fish permitted + case '0001111': level = 3; break; // Caviar permitted + case '0000111': level = 4; break; // Wine and oil + case '0000011': level = 5; break; // Without oil + case '0000001': level = 6; break; // Dry food only (xerophagy) + case '0000000': level = 7; break; // Complete fast + default: level = 0; + } + + // Generate description + const description = generateDescription(fastCase, allowed, forbidden, lang); + + return { + case: fastCase, + level, + description, + allowed, + forbidden, + }; +} + +function generateDescription( + fastCase: FastingCase, + allowed: string[], + forbidden: string[], + lang: string +): string { + if (fastCase === '1111111') { + return lang === 'ru' ? 'Поста нет' : lang === 'cu' ? 'Поста нѣтъ' : 'No fast'; + } + if (fastCase === '0000000') { + return lang === 'ru' ? 'Полное воздержание от пищи' : lang === 'cu' ? 'Полное воздержаніе отъ пищи' : 'Complete fast (no food)'; + } + + const labels: Record = { + en: { permitted: 'Permitted', forbidden: 'Forbidden', is: 'is', are: 'are' }, + ru: { permitted: 'Разрешено', forbidden: 'Запрещено', is: '', are: '' }, + cu: { permitted: 'Позволено', forbidden: 'Запрещено', is: '', are: '' }, + }; + const L = labels[lang] || labels.en; + + if (allowed.length === 0) { + return `${L.forbidden}: ${forbidden.join(', ')}`; + } + if (forbidden.length === 0) { + return `${L.permitted}: ${allowed.join(', ')}`; + } + const verb = allowed.length === 1 ? L.is : L.are; + const verbF = forbidden.length === 1 ? L.is : L.are; + return `${L.permitted} ${verb} ${allowed.join(', ')}. ${L.forbidden} ${verbF} ${forbidden.join(', ')}`; +} diff --git a/src/core/i18n.ts b/src/core/i18n.ts new file mode 100644 index 0000000..b88bffe --- /dev/null +++ b/src/core/i18n.ts @@ -0,0 +1,751 @@ +/** + * Internationalization (i18n) module for Ponomar PWA. + * Provides translations for UI strings in English, Russian, and Church Slavonic. + */ + +export type LanguageCode = 'en' | 'ru' | 'cu'; + +export interface Translations { + // General + appName: string; + loading: string; + error: string; + notAvailable: string; + + // Navigation + nav: { + calendar: string; + service: string; + bible: string; + settings: string; + }; + + // Calendar + calendar: { + months: string[]; + monthsGenitive: string[]; + dayNames: string[]; + dayNamesFull: string[]; + rankLabels: Record; + rankIcons: Record; + pascha: string; + pentecost: string; + cleanMonday: string; + greatLent: string; + brightWeek: string; + weekAfterPentecost: string; + weekOrdSuffix: string; + apostlesFast: string; + dormitionFast: string; + nativityFast: string; + commemorations: string; + fastingRule: string; + readings: string; + noCommemorations: string; + noReadings: string; + readingTypes: Record; + defaultBibleVersion: string; + dayReading: string[]; + readSep: string; + colon: string; + dayOfYear: string; + ofYear: string; + tone: string; + gregorianDate: string; + churchDate: string; + annoMundi: string; + yearOfWorld: string; + fromYear: string; + fromAdam: string; + }; + + // Services + services: { + title: string; + selectService: string; + serviceProducedNoOutput: string; + errorLoading: string; + nodesAssembled: string; + greatLent: string; + brightWeek: string; + apostlesFast: string; + dormitionFast: string; + nativityFast: string; + tone: string; + roleLabel: string; + rolePriest: string; + roleReader: string; + roleAuto: string; + serviceNames: { + primes: string; + third: string; + sixth: string; + ninth: string; + vespers: string; + liturgy: string; + royalhours: string; + compline: string; + }; + serviceDescriptions: { + primes: string; + third: string; + sixth: string; + ninth: string; + vespers: string; + liturgy: string; + royalhours: string; + compline: string; + }; + }; + + // Bible + bible: { + selectBook: string; + selectChapter: string; + read: string; + loading: string; + noTextFound: string; + notAvailableOffline: string; + ensureDataConverted: string; + }; + + // Settings + settings: { + title: string; + language: string; + fontSettings: string; + usePonomarFont: string; + usePonomarFontDesc: string; + fontPreview: string; + fontPreviewNoteActive: string; + fontPreviewNoteInactive: string; + fontSize: string; + systemFont: string; + bibleSettings: string; + defaultTranslation: string; + showVerseNumbers: string; + verseNewLine: string; + bibleComments: { + fullBible: string; + ntOnly: string; + otOnly: string; + otProphets: string; + }; + calendarType: string; + julian: string; + gregorian: string; + about: string; + aboutText: string; + aboutLicense: string; + installPwa: string; + }; + + // Common + common: { + priest: string; + reader: string; + deacon: string; + choir: string; + all: string; + }; +} + +const translations: Record = { + en: { + appName: '☦ Ponomar', + loading: 'Loading...', + error: 'Error', + notAvailable: 'Not available', + + nav: { + calendar: '📅 Calendar', + service: '📖 Service', + bible: '☦ Bible', + settings: '⚙ Settings', + }, + + calendar: { + months: [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ], + monthsGenitive: [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ], + dayNames: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + dayNamesFull: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + rankLabels: { + 1: 'Troparion', + 2: 'Antiphon', + 3: 'Hexahymn', + 4: 'Doxology', + 5: 'Polyeleos', + 6: 'Great Saint', + 7: 'Great Feast of the Theotokos', + 8: 'Great Feast of the Lord', + }, + rankIcons: { + 2: '🕃', + 3: '🕃', + 4: '🕂', + 5: '🕁', + 6: '🕀', + }, + pascha: 'Pascha: ', + pentecost: 'Pentecost: ', + cleanMonday: 'Clean Monday: ', + greatLent: ' · Great Lent', + brightWeek: ' · Bright Week', + weekAfterPentecost: 'week after Pentecost', + weekOrdSuffix: 'th', + apostlesFast: " · Apostles' Fast", + dormitionFast: ' · Dormition Fast', + nativityFast: ' · Nativity Fast', + commemorations: 'Commemorations', + fastingRule: 'Fasting Rule', + readings: 'Readings', + noCommemorations: 'No commemorations for this day', + noReadings: 'No readings assigned', + readingTypes: { + apostol: 'Apostol', + gospel: 'Gospel', + matins: 'Matins', + vespers: 'Vespers', + primes: '1st Hour', + terce: '3rd Hour', + sext: '6th Hour', + none: '9th Hour', + liturgy: 'Liturgy', + '0': 'Vespers', + '1': 'Reading', + '2': 'Reading', + '3': 'Reading', + '4': '4th Reading', + '5': '5th Reading', + '6': '6th Reading', + '7': '7th Reading', + '8': '8th Reading', + '9': '9th Reading', + '10': '10th Reading', + '11': '11th Reading', + '12': '12th Reading', + '13': '13th Reading', + '14': '14th Reading', + '15': '15th Reading', + A: 'Reading', + }, + defaultBibleVersion: 'en/bible/kjv', + dayReading: ['for Sunday', 'for Monday', 'for Tuesday', 'for Wednesday', 'for Thursday', 'for Friday', 'for Saturday'], + readSep: '; ', + colon: ': ', + dayOfYear: ' · Day ', + ofYear: ' of year', + tone: 'Tone ', + gregorianDate: 'Civil (Gregorian) Date', + churchDate: 'Church (Julian) Date', + annoMundi: 'Year of the World', + yearOfWorld: 'year of the world', + fromYear: 'from Christ', + fromAdam: 'from Adam', + }, + + services: { + title: 'Services', + selectService: 'Select a service to view its content.', + serviceProducedNoOutput: 'Service produced no output.', + errorLoading: 'Error loading service.', + nodesAssembled: 'nodes assembled.', + greatLent: ' · Great Lent', + brightWeek: ' · Bright Week', + apostlesFast: " · Apostles' Fast", + dormitionFast: ' · Dormition Fast', + nativityFast: ' · Nativity Fast', + tone: ' · Tone ', + roleLabel: 'Role:', + rolePriest: 'Priest', + roleReader: 'Reader', + roleAuto: 'Auto', + serviceNames: { + primes: 'First Hour', + third: 'Third Hour', + sixth: 'Sixth Hour', + ninth: 'Ninth Hour', + vespers: 'Vespers', + liturgy: 'Divine Liturgy', + royalhours: 'Royal Hours', + compline: 'Compline', + }, + serviceDescriptions: { + primes: 'Prime (First Hour)', + third: 'Third Hour', + sixth: 'Sixth Hour', + ninth: 'Ninth Hour', + vespers: 'Evening service (Vespers)', + liturgy: 'Principal service (Divine Liturgy)', + royalhours: 'Royal Hours', + compline: 'Night office (Compline)', + }, + }, + + bible: { + selectBook: 'Select a book', + selectChapter: 'Select a chapter', + read: 'Read', + loading: 'Loading...', + noTextFound: 'No text found for this passage.', + notAvailableOffline: 'Text not available for offline viewing.', + ensureDataConverted: 'Bible text files are lazy-loaded on demand. Please ensure the data has been converted and is available.', + }, + + settings: { + title: 'Settings', + language: 'Interface Language', + fontSettings: 'Church Slavonic Font', + usePonomarFont: 'Use Ponomar font for Church Slavonic texts', + usePonomarFontDesc: 'Use Ponomar font for Church Slavonic texts', + fontPreview: 'Preview:', + fontPreviewNoteActive: 'Ponomar font active — liturgical rank icons will render correctly', + fontPreviewNoteInactive: 'System font active — rank icons will use Ponomar font regardless', + fontSize: 'Liturgical Text Size: ', + systemFont: 'System Default', + bibleSettings: 'Bible', + defaultTranslation: 'Default Translation', + showVerseNumbers: 'Show verse numbers', + verseNewLine: 'Each verse on a new line', + bibleComments: { + fullBible: 'Full Bible', + ntOnly: 'NT only', + otOnly: 'OT only', + otProphets: 'OT Prophets', + }, + calendarType: 'Default Calendar', + julian: 'Julian', + gregorian: 'Gregorian', + about: 'About Ponomar', + aboutText: 'Ponomar is an Orthodox Church calendar application providing liturgical information, service texts, and Bible readings in multiple languages.', + aboutLicense: 'Based on the Ponomar Java desktop application by Aleksandr Andreev and Yuri Shardt. Licensed under GPL v3.', + installPwa: 'Install Ponomar', + }, + + common: { + priest: 'Priest', + reader: 'Reader', + deacon: 'Deacon', + choir: 'Choir', + all: 'All', + }, + }, + + ru: { + appName: '☦ Понома́рь', + loading: 'Загрузка...', + error: 'Ошибка', + notAvailable: 'Недоступно', + + nav: { + calendar: '📅 Календарь', + service: '📖 Устав', + bible: '☦ Библия', + settings: '⚙ Настройки', + }, + + calendar: { + months: [ + 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' + ], + monthsGenitive: [ + 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', + 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' + ], + dayNames: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'], + dayNamesFull: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'], + rankLabels: { + 1: 'Тропарь', + 2: 'Антифон', + 3: 'Шестипеснец', + 4: 'Славословие', + 5: 'Полиелей', + 6: 'Великий святой', + 7: 'Великий праздник Богородичный', + 8: 'Великий праздник Господень', + }, + rankIcons: { + 2: '🕃', + 3: '🕃', + 4: '🕂', + 5: '🕁', + 6: '🕀', + }, + pascha: 'Пасха: ', + pentecost: 'Пятидесятница: ', + cleanMonday: 'Чистый понедельник: ', + greatLent: ' · Великий пост', + brightWeek: ' · Светлая седмица', + weekAfterPentecost: 'седмица по Пятидесятнице', + weekOrdSuffix: 'я', + apostlesFast: ' · Петров пост', + dormitionFast: ' · Успенский пост', + nativityFast: ' · Рождественский пост', + commemorations: 'Память святых', + fastingRule: 'Правило поста', + readings: 'Чтения', + noCommemorations: 'Нет памятей на этот день', + noReadings: 'Чтения не назначены', + readingTypes: { + apostol: 'Апостол', + gospel: 'Евангелие', + matins: 'Утреня', + vespers: 'Вечерня', + primes: '1-й час', + terce: '3-й час', + sext: '6-й час', + none: '9-й час', + liturgy: 'Литургия', + '0': 'Вечерня', + '1': 'Вечерня', + '2': 'Вечерня', + '3': 'Вечерня', + '4': '4-е чтение', + '5': '5-е чтение', + '6': '6-е чтение', + '7': '7-е чтение', + '8': '8-е чтение', + '9': '9-е чтение', + '10': '10-е чтение', + '11': '11-е чтение', + '12': '12-е чтение', + '13': '13-е чтение', + '14': '14-е чтение', + '15': '15-е чтение', + A: 'Чтение', + }, + defaultBibleVersion: 'ru/bible/synod', + dayReading: ['для воскресенья', 'для понедельника', 'для вторника', 'для среды', 'для четверга', 'для пятницы', 'для субботы'], + readSep: '; ', + colon: ': ', + dayOfYear: ' · День ', + ofYear: ' года', + tone: 'Глас ', + gregorianDate: 'По гражданскому стилю', + churchDate: 'По церковному стилю', + annoMundi: 'Лета от сотворения мира', + yearOfWorld: 'год от сотворения мира', + fromYear: 'от РХ', + fromAdam: 'от Адама', + }, + + services: { + title: 'Богослужения', + selectService: 'Выберите богослужение для просмотра.', + serviceProducedNoOutput: 'Богослужение не дало текста.', + errorLoading: 'Ошибка загрузки богослужения.', + nodesAssembled: 'узлов собрано.', + greatLent: ' · Великий пост', + brightWeek: ' · Светлая седмица', + apostlesFast: ' · Петров пост', + dormitionFast: ' · Успенский пост', + nativityFast: ' · Рождественский пост', + tone: ' · Глас ', + roleLabel: 'Чин:', + rolePriest: 'Священник', + roleReader: 'Чтец', + roleAuto: 'Авто', + serviceNames: { + primes: 'Первый час', + third: 'Третий час', + sixth: 'Шестой час', + ninth: 'Девятый час', + vespers: 'Вечерня', + liturgy: 'Божественная Литургия', + royalhours: 'Царские часы', + compline: 'Повечерие', + }, + serviceDescriptions: { + primes: 'Час первый', + third: 'Час третий', + sixth: 'Час шестой', + ninth: 'Час девятый', + vespers: 'Вечернее богослужение (Вечерня)', + liturgy: 'Главное богослужение (Божественная Литургия)', + royalhours: 'Царские часы', + compline: 'Ночное богослужение (Повечерие)', + }, + }, + + bible: { + selectBook: 'Выберите книгу', + selectChapter: 'Выберите главу', + read: 'Читать', + loading: 'Загрузка...', + noTextFound: 'Текст не найден для этого отрывка.', + notAvailableOffline: 'Текст недоступен для просмотра в автономном режиме.', + ensureDataConverted: 'Файлы библейского текста загружаются по требованию. Убедитесь, что данные сконвертированы и доступны.', + }, + + settings: { + title: 'Настройки', + language: 'Язык интерфейса', + fontSettings: 'Шрифт церковнославянского', + usePonomarFont: 'Использовать шрифт Понома́рь для церковнославянских текстов', + usePonomarFontDesc: 'Использовать шрифт Понома́рь для церковнославянских текстов', + fontPreview: 'Предпросмотр:', + fontPreviewNoteActive: 'Шрифт Понома́рь активен — иконы чинов будут отображаться правильно', + fontPreviewNoteInactive: 'Системный шрифт активен — иконы чинов будут использовать Понома́рь в любом случае', + fontSize: 'Размер литургического текста: ', + systemFont: 'Системный шрифт', + bibleSettings: 'Библия', + defaultTranslation: 'Перевод по умолчанию', + showVerseNumbers: 'Показывать номера стихов', + verseNewLine: 'Каждый стих с новой строки', + bibleComments: { + fullBible: 'Полная Библия', + ntOnly: 'Новый Завет', + otOnly: 'Ветхий Завет', + otProphets: 'Пророки', + }, + calendarType: 'Календарь по умолчанию', + julian: 'Юлианский', + gregorian: 'Григорианский', + about: 'О Понома́ре', + aboutText: 'Понома́рь — приложение православного календаря, предоставляющее литургическую информацию, тексты служб и библейские чтения на нескольких языках.', + aboutLicense: 'Основано на Java-приложении Понома́рь от Александра Андреева и Юрия Шардт. Лицензия GPL v3.', + installPwa: 'Установить Пономарь', + }, + + common: { + priest: 'Свящ.', + reader: 'Чтец', + deacon: 'Диакон', + choir: 'Хор', + all: 'Все', + }, + }, + + cu: { + appName: '☦ Понома́рь', + loading: 'Загрузка...', + error: 'Ошибка', + notAvailable: 'Недоступно', + + nav: { + calendar: '📅 Календарь', + service: '📖 Уставъ', + bible: '☦ Библия', + settings: '⚙ Настройки', + }, + + calendar: { + months: [ + 'Январь', 'Февраль', 'Мартъ', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Августъ', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' + ], + monthsGenitive: [ + 'января', 'февраля', 'марта', 'апрѣля', 'мая', 'іюня', + 'іюля', 'августа', 'септемврія', 'октоврія', 'ноемврія', 'декаврія' + ], + dayNames: ['Нед', 'Пон', 'Вто', 'Сре', 'Чет', 'Пят', 'Суб'], + dayNamesFull: ['Неделя', 'Понедельникъ', 'Вторникъ', 'Среда', 'Четвергъ', 'Пятница', 'Суббота'], + rankLabels: { + 1: 'Тропарь', + 2: 'Антифонъ', + 3: 'Шестопѣснецъ', + 4: 'Славословіе', + 5: 'Полиелей', + 6: 'Великій святой', + 7: 'Великій праздникъ Богородичный', + 8: 'Великій праздникъ Господень', + }, + rankIcons: { + 2: '🕃', + 3: '🕃', + 4: '🕂', + 5: '🕁', + 6: '🕀', + }, + pascha: 'Пасха: ', + pentecost: 'Пятидесятница: ', + cleanMonday: 'Чистый понедельникъ: ', + greatLent: ' · Великій постъ', + brightWeek: ' · Светлая седмица', + weekAfterPentecost: 'седмица по Пѧтидесѧтницѣ', + weekOrdSuffix: 'ѧ', + apostlesFast: ' · Петровъ постъ', + dormitionFast: ' · Успенскій постъ', + nativityFast: ' · Рождественскій постъ', + commemorations: 'Память святыхъ', + fastingRule: 'Правило поста', + readings: 'Чтения', + noCommemorations: 'Нетъ памятей на сей день', + noReadings: 'Чтения не назначаются', + readingTypes: { + apostol: 'А҆пⷭ҇лъ', + gospel: 'Є҆ѵⷢ҇лїе', + matins: 'Оу҆́тренѧ', + vespers: 'Вече́рнѧ', + primes: 'Ча́съ пе́рвый', + terce: 'Ча́съ тре́тїй', + sext: 'Ча́съ шесты́й', + none: 'Ча́съ девѧ́тый', + liturgy: 'Лїтꙋргі́ѧ', + '0': 'Вече́рнѧ', + '1': 'Вече́рнѧ', + '2': 'Вече́рнѧ', + '3': 'Вече́рнѧ', + '4': '4-е чте́ніе', + '5': '5-е чте́ніе', + '6': '6-е чте́ніе', + '7': '7-е чте́ніе', + '8': '8-е чте́ніе', + '9': '9-е чте́ніе', + '10': '10-е чте́ніе', + '11': '11-е чте́ніе', + '12': '12-е чте́ніе', + '13': '13-е чте́ніе', + '14': '14-е чте́ніе', + '15': '15-е чте́ніе', + A: 'Чте́ніе', + }, + defaultBibleVersion: 'cu/bible/elis', + dayReading: ['длѧ̀ недѣ́ли', 'длѧ̀ понедѣ́льника', 'длѧ̀ вто́рника', 'длѧ̀ сре́ды', 'длѧ̀ четвертка̀', 'длѧ̀ пѧткѝ', 'длѧ̀ сꙋббѡ́ты'], + readSep: ': ', + colon: ': ', + dayOfYear: ' · День ', + ofYear: ' года', + tone: 'Гласъ ', + gregorianDate: 'По гражданскому стилю', + churchDate: 'По церковному стилю', + annoMundi: 'Лѣта отъ сотворенiя мiра', + yearOfWorld: 'лѣта отъ сотворенiя мiра', + fromYear: 'от РХ', + fromAdam: 'от Адама', + }, + + services: { + title: 'Богослуженiя', + selectService: 'Изберете богослуженiе для просмотра.', + serviceProducedNoOutput: 'Богослуженiе не дало текста.', + errorLoading: 'Ошибка загрузки богослуженiя.', + nodesAssembled: 'узлов собрано.', + greatLent: ' · Великій постъ', + brightWeek: ' · Светлая седмица', + apostlesFast: ' · Петровъ постъ', + dormitionFast: ' · Успенскій постъ', + nativityFast: ' · Рождественскій постъ', + tone: ' · Гласъ ', + roleLabel: 'Чинъ:', + rolePriest: 'Священникъ', + roleReader: 'Чтецъ', + roleAuto: 'Авто', + serviceNames: { + primes: 'Первый часъ', + third: 'Третий часъ', + sixth: 'Шестой часъ', + ninth: 'Девятый часъ', + vespers: 'Вечерня', + liturgy: 'Божественная Литургия', + royalhours: 'Царскія часъ', + compline: 'Повечерiе', + }, + serviceDescriptions: { + primes: 'Часъ первый', + third: 'Часъ третий', + sixth: 'Часъ шестой', + ninth: 'Часъ девятый', + vespers: 'Вечернее богослуженiе (Вечерня)', + liturgy: 'Главное богослуженiе (Божественнаꙗ Литургiꙗ)', + royalhours: 'Царскія часъ', + compline: 'Ночное богослуженiе (Повечерiе)', + }, + }, + + bible: { + selectBook: 'Изберете книгу', + selectChapter: 'Изберете главу', + read: 'Читати', + loading: 'Загрузка...', + noTextFound: 'Текстъ не найденъ для сего отрывка.', + notAvailableOffline: 'Текстъ недоступенъ для просмотра въ автономномъ режиме.', + ensureDataConverted: 'Файлы библейскаго текста загружаются по требованiю. Убедитесь, что данные сконвертированы и доступны.', + }, + + settings: { + title: 'Настройки', + language: 'Языкъ интерфейса', + fontSettings: 'Шрифтъ церковнославянскаго', + usePonomarFont: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ', + usePonomarFontDesc: 'Использовать шрифтъ Понома́рь для церковнославянскихъ текстовъ', + fontPreview: 'Предпросмотръ:', + fontPreviewNoteActive: 'Шрифтъ Понома́рь активенъ — иконы чинѣвъ будутъ отображатися правильно', + fontPreviewNoteInactive: 'Системный шрифтъ активенъ — иконы чинѣвъ будутъ использовать Понома́рь въ любомъ случаи', + fontSize: 'Размеръ литургическаго текста: ', + systemFont: 'Системный шрифтъ', + bibleSettings: 'Библия', + defaultTranslation: 'Переводъ по умолчанiю', + showVerseNumbers: 'Показывать номера стиховъ', + verseNewLine: 'Каждый стихъ съ новыя строки', + bibleComments: { + fullBible: 'Полная Библія', + ntOnly: 'Новый Завѣтъ', + otOnly: 'Ветхій Завѣтъ', + otProphets: 'Пророцы', + }, + calendarType: 'Календарь по умолчанию', + julian: 'Юліанскій', + gregorian: 'Григоріанскій', + about: 'О Понома́ри', + aboutText: 'Понома́рь — приложенiе православнаго календаря, предоставляющее литургическiю информацию, тексты службъ и библейскія чтения на нескольких языкахъ.', + aboutLicense: 'Основано на Java-приложенiи Понома́рь отъ Александра Андреева и Юрия Шардт. Лицензия GPL v3.', + installPwa: 'Оустановити Понома́рь', + }, + + common: { + priest: 'Свящ.', + reader: 'Чтецъ', + deacon: 'Диаконъ', + choir: 'Хоръ', + all: 'Все', + }, + }, +}; + +/** + * Get translations for a specific language. + * Falls back to English if language not found. + */ +export function getTranslations(lang: LanguageCode): Translations { + return translations[lang] || translations.en; +} + +/** + * Get a specific translation key with fallback. + */ +export function t(lang: LanguageCode, key: string, ...args: string[]): string { + const tr = getTranslations(lang); + const keys = key.split('.'); + let value: unknown = tr; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = (value as Record)[k]; + } else { + // Fallback to English + value = translations.en; + for (const k2 of keys) { + if (value && typeof value === 'object' && k2 in value) { + value = (value as Record)[k2]; + } else { + return key; // Return key if not found + } + } + break; + } + } + + if (typeof value === 'string') { + return args.length > 0 ? value.replace(/{(\d+)}/g, (_, i) => args[i] ?? '') : value; + } + + return key; +} \ No newline at end of file diff --git a/src/core/jdate.ts b/src/core/jdate.ts new file mode 100644 index 0000000..aa95076 --- /dev/null +++ b/src/core/jdate.ts @@ -0,0 +1,183 @@ +/** + * JDate — Julian calendar date engine. + * Faithful port of Ponomar/JDate.java to TypeScript. + * All dates are Julian calendar. Internally stored as Julian Day Number (JDN). + * + * JDate.java is part of the Ponomar program. + * Copyright 2006, 2007 Aleksandr Andreev. + * GPL v3 — see LICENSE. + */ + +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +const DAYS_IN_MONTH_LEAP = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/** + * 1-based modulo. If result is 0, returns divisor. + * Matches Java JDate.mod() exactly. + */ +function mod1(divisor: number, modulo: number): number { + let temp = divisor % modulo; + if (temp === 0) temp = modulo; + return temp; +} + +export class JDate { + readonly jdn: number; + + /** Create from Julian month/day/year. */ + constructor(monthOrJdn: number, day?: number, year?: number) { + if (day === undefined && year === undefined) { + // Constructor from JDN + this.jdn = monthOrJdn; + return; + } + + const month = monthOrJdn; + if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`); + if (day! < 0) throw new Error(`Invalid day: ${day}`); + + const leap = year! % 4 === 0; + const maxDays = leap ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1]; + if (day! > maxDays) throw new Error(`Invalid day ${day} for month ${month} (year ${year})`); + + // Fliegel-Van Flandern formula (Julian calendar) + const a = Math.floor((14 - month) / 12); + const y = year! + 4800 - a; + const m = month + 12 * a - 3; + this.jdn = day! + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - 32083; + } + + /** Create from JavaScript Date (Gregorian). Converts to Julian calendar. */ + static fromGregorian(date: Date): JDate { + return JDate.fromGregorianParts(date.getFullYear(), date.getMonth() + 1, date.getDate()); + } + + /** Create from Gregorian year/month/day. */ + static fromGregorianParts(gYear: number, gMonth: number, gDay: number): JDate { + const a = Math.floor((14 - gMonth) / 12); + const y = gYear + 4800 - a; + const m = gMonth + 12 * a - 3; + const jd = gDay + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045; + return new JDate(jd); + } + + /** Create from today's date. */ + static today(): JDate { + return JDate.fromGregorian(new Date()); + } + + /** Get year (Julian calendar). */ + getYear(): number { + const jbar = this.jdn + 32083; + const n1 = Math.floor(jbar / 1461) * 4; + const n2 = Math.floor((jbar % 1461) / 365); + const da = jbar % 1461; + const adj = (da % 365) > 306 ? 1 : 0; + return Math.floor(n1 + n2) - 4800 + adj; + } + + /** Get month (1=Jan ... 12=Dec, Julian calendar). */ + getMonth(): number { + const jbar = this.jdn + 32083; + const da = jbar % 1461; + let m = mod1(da, 365); + let j = 2; + while (m > DAYS_IN_MONTH_LEAP[j]) { + m -= DAYS_IN_MONTH_LEAP[j]; + j++; + if (j === 12) j = 0; + } + return j + 1; + } + + /** Get day of month (Julian calendar). */ + getDay(): number { + const jbar = this.jdn + 32083; + const da = mod1(jbar, 1461); + if (da === 1461) return 29; // Feb 29 + let m = mod1(da, 365); + let k = 2; + while (m > DAYS_IN_MONTH[k]) { + m -= DAYS_IN_MONTH[k]; + k++; + if (k === 12) k = 0; + } + return m; + } + + /** Day of week: 0=Sunday, 1=Monday, ..., 6=Saturday. */ + getDayOfWeek(): number { + let temp = (this.jdn % 7) + 1; + if (temp === 7) temp = 0; + return temp; + } + + /** Day of year: 0=Jan 1, ..., 364/365=Dec 31. */ + getDoy(): number { + const jbar = this.jdn + 32083; + const da = jbar % 1461; + return da === 1461 ? 366 : mod1(da + 59, 365) - 1; + } + + /** Convert to JavaScript Date (Gregorian calendar). */ + toGregorian(): Date { + let j1: number; + if (this.jdn >= 2299160.5) { + const tmp = Math.floor(((this.jdn - 1867216.0) - 0.25) / 36524.25); + j1 = this.jdn + 1 + tmp - Math.floor(0.25 * tmp); + } else { + j1 = this.jdn; + } + const j2 = j1 + 1524.0; + const j3 = Math.floor(6680.0 + ((j2 - 2439870.0) - 122.1) / 365.25); + const j4 = Math.floor(j3 * 365.25); + const j5 = Math.floor((j2 - j4) / 30.6001); + let d = Math.floor(j2 - j4 - Math.floor(j5 * 30.6001)); + let m = Math.floor(j5 - 1.0); + if (m > 12) m -= 12; + let y = Math.floor(j3 - 4715.0); + if (m > 2) --y; + if (y <= 0) --y; + return new Date(y, m - 1, d); + } + + /** Days to add to get to another date. Positive if this > other. */ + difference(other: JDate): number { + return this.jdn - other.jdn; + } + + /** Add n days, returning a new JDate. */ + addDays(n: number): JDate { + return new JDate(this.jdn + n); + } + + /** Subtract n days, returning a new JDate. */ + subtractDays(n: number): JDate { + return new JDate(this.jdn - n); + } + + /** Days in month for this date's month/year. */ + static maxDaysInMonth(month: number, year: number): number { + return (year % 4 === 0) ? DAYS_IN_MONTH_LEAP[month - 1] : DAYS_IN_MONTH[month - 1]; + } + + equals(other: JDate): boolean { + return this.jdn === other.jdn; + } + + compareTo(other: JDate): number { + return this.jdn - other.jdn; + } + + toString(): string { + const y = this.getYear(); + const m = this.getMonth(); + const d = this.getDay(); + return `${m < 10 ? '0' + m : m}/${d < 10 ? '0' + d : d}/${y}`; + } + + /** Clone — returns a new JDate with the same JDN. */ + clone(): JDate { + return new JDate(this.jdn); + } +} diff --git a/src/core/paschalion.ts b/src/core/paschalion.ts new file mode 100644 index 0000000..656ebf0 --- /dev/null +++ b/src/core/paschalion.ts @@ -0,0 +1,257 @@ +/** + * Paschalion — Orthodox Church paschal and feast day computations. + * Faithful port of Ponomar/Paschalion.java to TypeScript. + * + * Paschalion.java is part of the Ponomar program. + * Copyright 2006, 2007 Aleksandr Andreev. + * GPL v3 — see LICENSE. + */ + +import { JDate } from './jdate'; + +const LUNAR_MONTH = 29.52916667; + +/** Age of the moon on March 1 for each year in the 19-year Metonic cycle (hours since midnight). */ +const FOUNDATION = [ + 14.042016807, 25.462184874, 6.084033613, 17.966386555, 28.336134454, + 9.210084034, 20.504201681, 1.420168067, 12.294117647, 23.168067227, + 4.546218487, 15.042016807, 26.294117647, 7.630252101, 18.546218487, + 29.420168067, 11.756302521, 26.210084034, 3.042016807, +]; + +/** 1-based modulo. If result is 0, returns divisor. */ +function mod1(divisor: number, modulo: number): number { + let temp = divisor % modulo; + if (temp === 0) temp = modulo; + return temp; +} + +/** Modulo for decimal numbers. */ +function modF(divisor: number, modulo: number): number { + return divisor - Math.floor(divisor / modulo) * modulo; +} + +export const Paschalion = { + /** + * Get the date of Julian Pascha (Easter) for a given year. + * Uses Gaussian Easter formulae. + */ + getPascha(year: number): JDate { + if (year < 33) throw new Error('Invalid year'); + const a = year % 4; + const b = year % 7; + const c = year % 19; + const d = (19 * c + 15) % 30; + const e = (2 * a + 4 * b - d + 34) % 7; + const month = Math.floor((d + e + 114) / 31); // 3=March, 4=April + const day = ((d + e + 114) % 31) + 1; + return new JDate(month, day, year); + }, + + /** Pentecost: Pascha + 49 days. */ + getPentecost(year: number): JDate { + return this.getPascha(year).addDays(49); + }, + + /** Clean Monday (Great Lent starts): Pascha - 48 days. */ + getLentStart(year: number): JDate { + return this.getPascha(year).subtractDays(48); + }, + + /** Apostles' Fast starts: Pascha + 57 days (Monday after All Saints). */ + getApostlesFastStart(year: number): JDate { + return this.getPascha(year).addDays(57); + }, + + /** Length of Apostles' Fast in days. Ends June 28 (Julian). */ + getApostlesFastLength(year: number): number { + const start = this.getApostlesFastStart(year); + const end = new JDate(6, 28, year); + return end.difference(start); + }, + + /** Key of Boundaries (Ключ границ) — letter of the Paschal boundary. 1-30. */ + getKeyOfBoundaries(year: number): number { + const date = this.getPascha(year); + const m = date.getMonth(); + let g = date.getDay(); + if (m === 3) { + g -= 21; + } else { + g += 10; + } + return g; + }, + + /** Indiction cycle. */ + getIndiction(year: number): number { + if (year < 33) throw new Error('Invalid year'); + return mod1(year - 312, 15); + }, + + /** Solar cycle. */ + getSolarCycle(year: number): number { + if (year < 33) throw new Error('Invalid year'); + return mod1(year + 5508, 28); + }, + + /** Lunar cycle (1-19). */ + getLunarCycle(year: number): number { + if (year < 33) throw new Error('Invalid year'); + let temp = (year + 1) % 19 - 3; + if (temp <= 0) temp += 19; + return temp; + }, + + /** Lunar phase for a date: 0=new moon, 0.5=full moon. */ + getLunarPhase(date: JDate): number { + const year = date.getYear(); + const cycle = this.getLunarCycle(year); + let diff = date.difference(new JDate(3, 1, year)); + if (diff < 0) { + diff = date.difference(new JDate(3, 1, year - 1)); + } + let remainder = modF(diff, LUNAR_MONTH); + remainder += FOUNDATION[cycle - 1]; + while (remainder >= LUNAR_MONTH) { + remainder -= LUNAR_MONTH; + } + return remainder / LUNAR_MONTH; + }, + + /** + * Annual fasting array: 0=no fast, 1=fast day, 2=cheesefare. + * One entry per day of the year (index 0=Jan 1). + */ + getFasts(year: number): number[] { + if (year < 33) throw new Error('Invalid year'); + + const pascha = this.getPascha(year); + const numDays = (year % 4 === 0) ? 366 : 365; + + // Mandatory fast days (JDN → reason string) + const mustFast = new Set(); + mustFast.add(new JDate(1, 5, year).jdn); + mustFast.add(new JDate(8, 29, year).jdn); + mustFast.add(new JDate(9, 14, year).jdn); + + // Mandatory fast-free days + const cantFast = new Set(); + cantFast.add(new JDate(1, 6, year).jdn); + + // Period boundaries + const SVIATKI_START = new JDate(12, 25, year); + const SVIATKI_END = new JDate(1, 4, year); + const PUB_PHAR_START = pascha.subtractDays(70); + const PUB_PHAR_END = pascha.subtractDays(63); + const CHEESE_START = pascha.subtractDays(55); + const CHEESE_END = pascha.subtractDays(49); + const LENT_START = pascha.subtractDays(48); + const LENT_END = pascha.subtractDays(1); + const BRIGHT_START = pascha; + const BRIGHT_END = pascha.addDays(6); + const PENT_START = pascha.addDays(49); + const PENT_END = pascha.addDays(56); + const APOSTLES_START = pascha.addDays(57); + const APOSTLES_END = new JDate(6, 28, year); + const DORM_START = new JDate(8, 1, year); + const DORM_END = new JDate(8, 14, year); + const ADVENT_START = new JDate(11, 15, year); + const ADVENT_END = new JDate(12, 24, year); + + const retval = new Array(numDays); + let dummy = new JDate(1, 1, year); + + for (let i = 0; i < numDays; i++) { + let fast = 0; + const jdn = dummy.jdn; + + if (mustFast.has(jdn)) { + fast = 1; + } else if (cantFast.has(jdn)) { + fast = 0; + } else if (dummy.compareTo(SVIATKI_START) >= 0 || dummy.compareTo(SVIATKI_END) <= 0) { + fast = 0; + } else if (dummy.compareTo(PUB_PHAR_START) >= 0 && dummy.compareTo(PUB_PHAR_END) <= 0) { + fast = 0; + } else if (dummy.compareTo(CHEESE_START) >= 0 && dummy.compareTo(CHEESE_END) <= 0) { + fast = 2; + } else if (dummy.compareTo(LENT_START) >= 0 && dummy.compareTo(LENT_END) <= 0) { + fast = 1; + } else if (dummy.compareTo(BRIGHT_START) >= 0 && dummy.compareTo(BRIGHT_END) <= 0) { + fast = 0; + } else if (dummy.compareTo(PENT_START) >= 0 && dummy.compareTo(PENT_END) <= 0) { + fast = 0; + } else if (dummy.compareTo(APOSTLES_START) >= 0 && dummy.compareTo(APOSTLES_END) <= 0) { + fast = 1; + } else if (dummy.compareTo(DORM_START) >= 0 && dummy.compareTo(DORM_END) <= 0) { + fast = 1; + } else if (dummy.compareTo(ADVENT_START) >= 0 && dummy.compareTo(ADVENT_END) <= 0) { + fast = 1; + } else { + const dow = dummy.getDayOfWeek(); + if (dow === 3 || dow === 5) fast = 1; + } + + retval[i] = fast; + dummy = dummy.addDays(1); + } + + return retval; + }, + + /** + * Major feasts for the year. + * Returns Map< JDN, feastName >. + */ + getFeasts(year: number): Map { + if (year < 33) throw new Error('Invalid year'); + + const feastNames = [ + 'New Year', 'Theophany', 'Nativity of the Forerunner', + 'Peter and Paul', 'Transfiguration', 'Dormition', + 'Beheading of the Forerunner', 'Nativity of the Theotokos', + 'Exaltation of the Cross', 'Protection of the Theotokos', + 'Entrance of the Theotokos', 'Nativity of Christ', + 'Pascha and Annunciation', 'Pascha', 'Annunciation', + 'Pentecost', 'Ascension', 'Palm Sunday', 'Meeting of the Lord', + ]; + + const feasts = new Map(); + + // Fixed feasts + feasts.set(new JDate(1, 1, year).jdn, feastNames[0]); + feasts.set(new JDate(1, 6, year).jdn, feastNames[1]); + feasts.set(new JDate(6, 24, year).jdn, feastNames[2]); + feasts.set(new JDate(6, 29, year).jdn, feastNames[3]); + feasts.set(new JDate(8, 6, year).jdn, feastNames[4]); + feasts.set(new JDate(8, 15, year).jdn, feastNames[5]); + feasts.set(new JDate(8, 29, year).jdn, feastNames[6]); + feasts.set(new JDate(9, 8, year).jdn, feastNames[7]); + feasts.set(new JDate(9, 14, year).jdn, feastNames[8]); + feasts.set(new JDate(10, 1, year).jdn, feastNames[9]); + feasts.set(new JDate(11, 21, year).jdn, feastNames[10]); + feasts.set(new JDate(12, 25, year).jdn, feastNames[11]); + + // Moveable feasts + const pascha = this.getPascha(year); + if (pascha.equals(new JDate(3, 25, year))) { + feasts.set(pascha.jdn, feastNames[12]); + } else { + feasts.set(pascha.jdn, feastNames[13]); + feasts.set(new JDate(3, 25, year).jdn, feastNames[14]); + } + feasts.set(pascha.addDays(49).jdn, feastNames[15]); + feasts.set(pascha.addDays(39).jdn, feastNames[16]); + feasts.set(pascha.subtractDays(7).jdn, feastNames[17]); + + // Meeting of the Lord — Feb 2, but transferred to Forgiveness Sunday if it falls on Clean Monday + let meeting = new JDate(2, 2, year); + if (pascha.difference(meeting) === 48) { + meeting = meeting.subtractDays(1); + } + feasts.set(meeting.jdn, feastNames[18]); + + return feasts; + }, +}; diff --git a/src/core/service-assembler.ts b/src/core/service-assembler.ts new file mode 100644 index 0000000..3d35c14 --- /dev/null +++ b/src/core/service-assembler.ts @@ -0,0 +1,597 @@ +/** + * Service Assembler — recursive template processor for liturgical services. + * Processes service XML templates (pre-converted to JSON) and renders HTML. + * + * Service.java is part of the Ponomar program. + * Copyright 2008, 2009, 2011 Yuri Shardt. + * See LICENSE for redistribution terms. + */ + +import { evalBool } from './evaluator'; +import type { EvalContext, ServiceNode } from './types'; + +export interface CommonTranslations { + priest: string; + reader: string; + deacon: string; + choir: string; +} + +export interface ServiceContext { + evalCtx: EvalContext; + lang: string; + fetchText: (path: string) => Promise; + fetchServiceNodes: (path: string) => Promise; + fetchBibleText: (book: string, passage: string, showVerseNumbers?: boolean, verseNewLine?: boolean) => Promise; + fetchLives: (id: string) => Promise>> | null>; + fetchCommandText: (name: string) => Promise; + resolveTimes: (times: number) => Promise; + t?: { common: CommonTranslations }; +} + +function localizeWho(who: string, ctx: ServiceContext): string { + const t = ctx.t?.common; + if (!t) return who; + const map: Record = { + P: t.priest, + Priest: t.priest, + R: t.reader, + Reader: t.reader, + SR: t.reader, + D: t.deacon, + Deacon: t.deacon, + C: t.choir, + Choir: t.choir, + }; + return map[who] ?? who; +} + +// Localized reading intro names (getreading case) +// Values from Java bible.xml Intro field +const READING_NAMES: Record> = { + en: { + Isa: 'A reading from the Prophecy of Isaiah', + Mica: 'A reading from the Prophecy of Micah', + Zech: 'A reading from the Prophecy of Zechariah', + Jerem: 'A reading from the Prophecy of Jeremiah', + Baruch: 'A reading from the Prophecy of Jeremiah', + Mal: 'A reading from the Prophecy of Malachi', + Zeph: 'A reading from the Prophecy of Zephaniah', + Mt: 'A reading from the Holy Gospel according to Matthew', + Mk: 'A reading from the Holy Gospel according to Mark', + Lk: 'A reading from the Holy Gospel according to Luke', + Jn: 'A reading from the Holy Gospel according to John', + Acts: 'A reading from the Acts of the Apostles', + Rom: 'A reading from the Epistle of Paul to the Romans', + Gal: 'A reading from the Epistle of Paul to the Galatians', + Tit: 'A reading from the Epistle to Titus', + Heb: 'A reading from the Epistle to the Hebrews', + }, + cu: { + Isa: 'Прⷪ҇ро́чества и҆са́їина чте́нїе', + Mica: 'Прⷪ҇ро́чества мїхе́ина чте́нїе', + Zech: 'Прⷪ҇ро́чества заха́рїина чте́нїе', + Jerem: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе', + Baruch: 'Прⷪ҇ро́чества і҆еремі́ина чте́нїе', + Mal: 'Прⷪ҇ро́чества малахі́ина чте́нїе', + Zeph: 'Прⷪ҇ро́чества софо́нїева чте́нїе', + Mt: 'Ѿ матѳе́а ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе', + Mk: 'Ѿ ма́рка ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе', + Lk: 'Ѿ лꙋкѝ ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе', + Jn: 'Ѿ і҆ѡа́нна ст҃а́гѡ є҆ѵⷢ҇лїа чте́нїе', + Acts: 'Дѣѧ̑нїй ст҃ы́хъ а҆пⷭ҇лъ чте́нїе', + Rom: 'Къ ри́млѧнѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе', + Gal: 'Къ гала́тѡмъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе', + Tit: 'Къ ті́тꙋ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе', + Heb: 'Ко є҆вре́ємъ посла́нїѧ ст҃гѡ а҆пⷭ҇ла па́ѵла чте́нїе', + }, + fr: { + Isa: 'Une lection d\'Ésaïe', + Mica: 'Une lection de la prophétie de Michée', + Zech: 'Une lection de la prophétie de Zacharie', + Jerem: 'Une lection de la prophétie de Jérémie', + Baruch: 'Une lection de la prophétie de Jérémie', + Mal: 'Une lection de la prophétie de Malachie', + Zeph: 'Une lection de la prophétie de Sophonie', + Mt: 'Une lection du Saint Évangile selon Saint Matthieu', + Mk: 'Une lection du Saint Évangile selon Saint Marc', + Lk: 'Une lection du Saint Évangile selon Saint Luc', + Jn: 'Une lection du Saint Évangile selon Saint Jean', + Acts: 'Une lection des Actes des Apôtres', + Rom: 'Une lection de l\'Épître de Paul aux Romains', + Gal: 'Une lection de l\'Épître de Paul aux Galates', + Tit: 'Une lection de l\'Épître de Paul au Tite', + Heb: 'Une lection de l\'Épître de Paul aux Hébreux', + }, + el: { + Isa: 'Προφητείας Ἡσαΐου τὸ Ἀνάγνωσμα', + Mica: 'Προφητείας Μιχαίου τὸ Ἀνάγνωσμα', + Zech: 'Προφητείας Ζαχαρίου τὸ Ἀνάγνωσμα', + Jerem: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα', + Baruch: 'Προφητείας Ἱερεμίου τὸ Ἀνάγνωσμα', + Mal: 'Προφητείας Μαλαχίου τὸ ἀνάγνωσμα', + Zeph: 'Προφητείας Σοφονίου τὸ Ἀνάγνωσμα', + Mt: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ματθαῖον', + Mk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Μᾶρκον', + Lk: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Λουκᾶν', + Jn: 'Εὐαγγέλιον ἐκ τοῦ κατὰ Ἰωάννην', + Acts: 'Πράξεων τῶν Ἀποστολων', + Rom: 'Πρὸς Ῥωμαίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα', + Gal: 'Πρὸς Γαλάτας ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα', + Tit: 'Πρὸς Τίτον ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα', + Heb: 'Πρὸς Ἑβραίους Ἐπιστολῆς Παύλου τὸ Ἀνάγνωσμα', + }, +}; + +// Localized book names for passage headers +const HEADER_NAMES: Record> = { + en: { + Psalm: 'Psalm', Isa: 'Isaiah', Jr: 'Jeremiah', Jerem: 'Jeremiah', + Ez: 'Ezekiel', Dn: 'Daniel', Mic: 'Micah', Mica: 'Micah', + Na: 'Nahum', Hab: 'Habakkuk', Zeph: 'Zephaniah', Hag: 'Haggai', + Zech: 'Zechariah', Mal: 'Malachi', Mt: 'Matthew', Mk: 'Mark', + Lk: 'Luke', Jn: 'John', Rom: 'Romans', Heb: 'Hebrews', + Gal: 'Galatians', Acts: 'Acts', Baruch: 'Baruch', + }, + cu: { + Psalm: 'Ѱалти́рь', Isa: 'И҆са́їи', Jr: 'І҆еремі́и', Jerem: 'І҆еремі́и', + Ez: 'І҆езекі́ль', Dn: 'Дані́илъ', Mic: 'Мїхе́й', Mica: 'Мїхе́й', + Na: 'Наꙋ́мъ', Hab: 'Аввакꙋ́мъ', Zeph: 'Софо́нїа', Hag: 'Аггѣ́й', + Zech: 'Заха́рїа', Mal: 'Малахі́а', Mt: 'Ѿ Матѳе́а', Mk: 'Ѿ Ма́рка', + Lk: 'Ѿ лꙋкѝ', Jn: 'Ѿ і҆ѡа́нна', Rom: 'Къ ри́млѧнѡмъ', Heb: 'Ко є҆вре́ємъ', + Gal: 'Къ гала́тѡмъ', Acts: 'Дѣѧ̑нїѧ ст҃ы́хъ а҆пⷭ҇лъ', Baruch: 'Варꙋ́хъ', + }, + fr: { + Psalm: 'Psaumes', Isa: 'Isaïe / Ésaïe', Jr: 'Jérémie', Jerem: 'Jérémie', + Ez: 'Ézéchiel', Dn: 'Daniel', Mic: 'Michée', Mica: 'Michée', + Na: 'Nahum', Hab: 'Habacuc', Zeph: 'Sophonie', Hag: 'Aggée', + Zech: 'Zacharie', Mal: 'Malachie', Mt: 'Matthieu', Mk: 'Marc', + Lk: 'Luc', Jn: 'Jean', Rom: 'Romains', Heb: 'Hébreux', + Gal: 'Galates', Acts: 'Actes des Apôtres', Baruch: 'Baruch', + }, + el: { + Psalm: 'Ψαλμοί', Isa: 'Ἡσαΐας', Jr: 'Ἱερεμίας', Jerem: 'Ἱερεμίας', + Ez: 'Ἰεζεκιήλ', Dn: 'Δανιήλ', Mic: 'Μιχαίας', Mica: 'Μιχαίας', + Na: 'Ναούμ', Hab: 'Ἀμβακούμ', Zeph: 'Σοφονίας', Hag: 'Ἀγγαῖος', + Zech: 'Ζαχαρίας', Mal: 'Μαλαχίας', Mt: 'Εὐαγγέλιον κατὰ Ματθαῖον', Mk: 'Εὐαγγέλιον κατὰ Μᾶρκον', + Lk: 'Εὐαγγέλιον κατὰ Λουκᾶν', Jn: 'Εὐαγγέλιον κατὰ Ἰωάννην', + Rom: 'Πρὸς Ῥωμαίους', Heb: 'Πρὸς Ἑβραίους', + Gal: 'Πρὸς Γαλάτας', Acts: 'Πράξεων τῶν Ἀποστολων', Baruch: 'Βαρούχ', + }, +}; + +function getReadingName(bookId: string, lang: string): string { + const lookupLang = lang === 'ru' ? 'cu' : lang; + const map = READING_NAMES[lookupLang] ?? READING_NAMES.en; + return map[bookId] ?? bookId; +} + +function getHeaderName(bookId: string, lang: string): string { + const lookupLang = lang === 'ru' ? 'cu' : lang; + const map = HEADER_NAMES[lookupLang] ?? HEADER_NAMES.en; + return map[bookId] ?? bookId; +} + +interface ImplementOptions { + header?: string; + who: string; + command?: string; + commandB?: string; + commandText?: string; + commandBText?: string; + timesText?: string; + redFirst?: boolean; + newLine?: boolean; + headerLevel?: number; +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' ? v : undefined; +} +function bool(v: unknown): boolean | undefined { + if (typeof v === 'boolean') return v; + if (v === '1' || v === 'true') return true; + if (v === '0' || v === 'false') return false; + return undefined; +} +function num(v: unknown): number | undefined { + if (typeof v === 'number') return v; + if (typeof v === 'string' && /^\d+$/.test(v)) return parseInt(v, 10); + return undefined; +} + +/** + * Assemble a service from pre-parsed JSON template nodes. + * Returns HTML string. + */ +export async function assembleService( + nodes: ServiceNode[], + ctx: ServiceContext, + depth: number = 0 +): Promise<{ html: string; whoLast: string }> { + if (depth > 10) return { html: '', whoLast: '\x00' }; + + let html = ''; + let whoLast = '\x00'; + + // Buffer for grouping same-who lines into a single flex container + let groupWho: string | null = null; + let groupTexts: string[] = []; + + function flushGroup() { + if (!groupWho || groupTexts.length === 0) return; + const linesHtml = groupTexts.map((t, i) => { + const redPart = extractFirstLetter(t); + const line = redPart + ? `${redPart}${t.substring(redPart.length)}` + : t; + return (i > 0 ? '
' : '') + line; + }).join(''); + html += `
${groupWho}:${linesHtml}
\n`; + whoLast = groupWho; + groupWho = null; + groupTexts = []; + } + + for (const raw of nodes) { + const n = raw as Record; + const type = n.type as string; + + // Evaluate Cmd attribute if present + const cmd = str(n.cmd); + if (cmd) { + if (!evalBool(cmd, ctx.evalCtx)) continue; + } + + switch (type) { + case 'TITLE': { + flushGroup(); + const value = str(n.value); + const source = str(n.source); + const comment = str(n.comment); + if (value) { + const titleText = await ctx.fetchText(`Services/Text/${value}.xml`); + if (titleText) html += `

${titleText}

\n`; + } + if (source) { + const sourceText = await ctx.fetchText(`Services/Text/${source}.xml`); + if (sourceText) html += `

${sourceText}

`; + } + if (comment) { + const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`); + if (commentText) html += `

${commentText}

`; + } + break; + } + + case 'SUBTITLE': { + flushGroup(); + const value = str(n.value); + const comment = str(n.comment); + if (value) { + const subText = await ctx.fetchText(`Services/Text/${value}.xml`); + if (subText) html += `

${subText}

\n`; + } + if (comment) { + const commentText = await ctx.fetchText(`Services/Text/${comment}.xml`); + if (commentText) html += `

${commentText}

`; + } + break; + } + + case 'GET': { + flushGroup(); + const file = str(n.file); + if (!file) break; + const subNodes = await ctx.fetchServiceNodes(`Services/${file}.xml`); + if (subNodes) { + const subResult = await assembleService(subNodes, ctx, depth + 1); + if (n.null && !subResult.html.trim()) { + // Null check: skip if empty + } else { + html += subResult.html; + whoLast = subResult.whoLast; + } + } + break; + } + + case 'CREATE': { + const what = str(n.what) ?? ''; + const whoRaw = str(n.who); + const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : ''; + let text = ''; + let header = ''; + if (what) { + text = await ctx.fetchText(`Services/CommonPrayers/${what}.xml`) ?? ''; + // Fallback: if fetchText returns empty, try to use the 'what' field as a key + if (!text.trim()) { + text = what; // fallback to the key itself + } + header = await ctx.fetchText(`Services/CommonPrayers/${what}.xml.header`) ?? ''; + } + const commandName = str(n.command); + const commandBName = str(n.commandB); + const timesVal = num(n.times); + const [commandText, commandBText, timesText] = await Promise.all([ + commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''), + commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''), + timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''), + ]); + const opts = { + who, + commandText: commandText || undefined, + commandBText: commandBText || undefined, + timesText: timesText || undefined, + redFirst: bool(n.redFirst), + newLine: bool(n.newLine), + headerLevel: n.header ? 1 : 0, + }; + const fmt = formatServiceContent(text, opts); + const headerHtml = opts.headerLevel && header + ? `

${header}

` + : ''; + if (who) { + if (who !== groupWho || headerHtml) flushGroup(); + if (headerHtml) html += headerHtml + '\n'; + groupWho = who; + groupTexts.push(fmt); + } else { + flushGroup(); + const br = opts.newLine ? '
' : ''; + html += `${headerHtml}${br}

${fmt}

`; + } + if (whoRaw !== undefined) whoLast = who; + break; + } + + case 'TEXT': { + const whoRaw = str(n.who); + const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : ''; + // Support both 'what' (programmatic nodes) and 'value' (converted JSON) + const textContent = str(n.what) ?? str(n.value) ?? ''; + const opts = { + who, + redFirst: bool(n.redFirst), + newLine: bool(n.newLine), + headerLevel: n.header ? 1 : 0, + }; + const fmt = formatServiceContent(textContent, opts); + if (who) { + if (who !== groupWho) flushGroup(); + groupWho = who; + groupTexts.push(fmt); + } else { + flushGroup(); + html += `

${fmt}

`; + } + if (whoRaw !== undefined) whoLast = who; + break; + } + + case 'GETID': { + const whoRaw = str(n.who); + const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : ''; + let text = ''; + let header = ''; + const id = str(n.id); + const what = str(n.what); + const nodeType = str(n.type) ?? 'M'; + if (id && what) { + const lifeId = nodeType === 'T' ? `98${id}` : id; + const lives = await ctx.fetchLives(lifeId); + if (lives) { + const lastSlash = what.lastIndexOf('/'); + const nodePath = what.substring(0, lastSlash); + const typeKey = what.substring(lastSlash + 1); + const group = lives[nodePath]; + if (group) { + const entry = group[typeKey]; + if (entry) { + text = String(entry.text ?? ''); + header = String(entry.Header ?? ''); + } + } + } + } + const commandName = str(n.command); + const commandBName = str(n.commandB); + const timesVal = num(n.times); + const [commandText, commandBText, timesText] = await Promise.all([ + commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''), + commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''), + timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''), + ]); + const opts = { + who, + commandText: commandText || undefined, + commandBText: commandBText || undefined, + timesText: timesText || undefined, + redFirst: bool(n.redFirst), + newLine: bool(n.newLine), + headerLevel: n.header ? 1 : 0, + }; + const fmt = formatServiceContent(text, opts); + const headerHtml = opts.headerLevel && header + ? `

${header}

` + : ''; + if (who) { + if (who !== groupWho || headerHtml) flushGroup(); + groupWho = who; + if (headerHtml) html += headerHtml + '\n'; + groupTexts.push(fmt); + } else { + flushGroup(); + const br = opts.newLine ? '
' : ''; + html += `${headerHtml}${br}

${fmt}

`; + } + if (whoRaw !== undefined) whoLast = who; + break; + } + + case 'BIBLE': { + const whoRaw = str(n.who); + const who = whoRaw !== undefined ? localizeWho(whoRaw, ctx) : ''; + const verseNewLine = !who; + + // getreading: display a reading intro header (e.g. "Prophet Isaiah") + const getreading = str(n.getreading); + if (getreading && !str(n.verses)) { + flushGroup(); + const bookName = getReadingName(getreading, ctx.lang); + const readingHeader = `

${bookName}

`; + html += readingHeader; + if (whoRaw !== undefined) whoLast = who; + break; + } + const verses = str(n.verses) ?? ''; + const lastUnderscore = verses.lastIndexOf('_'); + const book = lastUnderscore > 0 ? verses.substring(0, lastUnderscore) : verses; + const passage = lastUnderscore > 0 ? verses.substring(lastUnderscore + 1) : ''; + + // Generate header from book+passage when header=true and no twostars + let header = ''; + if (n.header && !str(n.twostars)) { + const bookName = getHeaderName(book, ctx.lang); + header = passage ? `${bookName} ${passage}` : bookName; + } + + let text = await ctx.fetchBibleText(book, passage, false, verseNewLine); + + // Handle twostars: extract **-delimited instructions as header + const twostars = str(n.twostars); + if (twostars === '1' || twostars === '2') { + const starIdx = text.indexOf('**'); + if (starIdx !== -1) { + const lastStar = text.lastIndexOf('**'); + if (starIdx !== lastStar) { + const instruction = text.substring(starIdx + 2, lastStar); + if (twostars === '1') { + header = instruction; + } else if (twostars === '2') { + // Extract quoted text between ** markers + const quoteStart = instruction.indexOf('"'); + if (quoteStart !== -1) { + const quoteEnd = instruction.indexOf('"', quoteStart + 1); + if (quoteEnd !== -1) { + header = instruction.substring(quoteStart + 1, quoteEnd).replace(/\.\.\./g, ''); + } + } + } + // Strip ** markers from text + text = text.substring(0, starIdx) + text.substring(lastStar + 2); + } + } + } + // Strip all remaining ** markers + text = text.replace(/\*\*/g, ''); + + const commandName = str(n.command); + const commandBName = str(n.commandB); + const timesVal = num(n.times); + const [commandText, commandBText, timesText] = await Promise.all([ + commandName ? ctx.fetchCommandText(commandName) : Promise.resolve(''), + commandBName ? ctx.fetchCommandText(commandBName) : Promise.resolve(''), + timesVal ? ctx.resolveTimes(timesVal) : Promise.resolve(''), + ]); + const opts = { + who, + commandText: commandText || undefined, + commandBText: commandBText || undefined, + timesText: timesText || undefined, + redFirst: verseNewLine ? false : bool(n.redFirst), + newLine: bool(n.newLine), + headerLevel: n.header ? 1 : 0, + }; + const fmt = formatServiceContent(text, opts); + const headerHtml = opts.headerLevel && header + ? `

${header}

` + : ''; + if (who) { + if (who !== groupWho || headerHtml) flushGroup(); + groupWho = who; + if (headerHtml) html += headerHtml + '\n'; + groupTexts.push(fmt); + } else { + flushGroup(); + if (verseNewLine) { + const verses = fmt.split('
').filter(Boolean); + const linesHtml = verses.map(v => { + const redPart = extractFirstLetter(v); + return redPart ? `${redPart}${v.substring(redPart.length)}` : v; + }).join('
'); + if (headerHtml) html += headerHtml + '\n'; + html += `

${linesHtml}

`; + } else { + const br = opts.newLine ? '
' : ''; + html += `${headerHtml}${br}

${fmt}

`; + } + } + if (whoRaw !== undefined) whoLast = who; + break; + } + + case 'TIMES': { + flushGroup(); + break; + } + + default: { + flushGroup(); + // Skip wrapper nodes that aren't real service elements + if (type === 'SERVICES' || type === 'TONE') break; + const children = n.children; + if (Array.isArray(children) && children.length > 0) { + const subHtml = await assembleService(children, ctx, depth + 1); + html += subHtml; + } + break; + } + } + } + + flushGroup(); + return { html, whoLast }; +} + +/** + * Format service text content: redFirst, command rubrics, commandB, times. + * Returns formatted HTML string (no wrapping in

). + */ +function formatServiceContent(text: string, opts: ImplementOptions): string { + let result = text; + + if (opts.redFirst && result.length > 0) { + const redPart = extractFirstLetter(result); + if (redPart) { + result = `${redPart}${result.substring(redPart.length)}`; + } + } + + if (opts.commandText) { + if (opts.timesText) { + result += ` (${opts.timesText} ${opts.commandText})`; + } else { + result += ` (${opts.commandText})`; + } + } else if (opts.timesText) { + result += ` (${opts.timesText})`; + } + + if (opts.commandBText) { + result = `${opts.commandBText} ${result}`; + } + + return result; +} + +function extractFirstLetter(text: string): string { + const combiningMarks = /[\u0300-\u036f\u037a\u0384-\u0385\u0483-\u0489\u1fbd-\u1ffe\u2de0-\u2dff\ua66f-\ua67d]/; + let result = text[0] || ''; + let i = 1; + while (i < text.length && combiningMarks.test(text[i])) { + result += text[i]; + i++; + } + return result; +} diff --git a/src/core/service-rules.ts b/src/core/service-rules.ts new file mode 100644 index 0000000..e4172df --- /dev/null +++ b/src/core/service-rules.ts @@ -0,0 +1,53 @@ +/** + * Service Rules — determines which service template to use for a given day. + * Port of Ponomar/ServiceInfo.java. + */ + +import { evalBool } from './evaluator'; +import type { EvalContext } from './types'; + +export interface ServiceRuleEntry { + type: string; + troparion?: string; + pickT?: number; + kontakion?: string; + pickK?: number; + lentenk?: number; + [key: string]: string | number | undefined; +} + +export interface ServiceRulesData { + periods: { + cmd: string; + rules: { + type: string; + attrs: Record; + }[]; + }[]; +} + +/** + * Evaluate service rules for a given day. + * Returns the matching service configuration. + */ +export function evaluateServiceRules( + data: ServiceRulesData, + serviceType: string, // e.g., "VESPERS", "LITURGY", "MATINS" + ctx: EvalContext +): ServiceRuleEntry | null { + let result: ServiceRuleEntry | null = null; + + for (const period of data.periods) { + if (period.cmd && !evalBool(period.cmd, ctx)) { + continue; + } + + for (const rule of period.rules) { + if (rule.type === serviceType) { + result = { type: rule.type, ...rule.attrs }; + } + } + } + + return result; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..afc550e --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,188 @@ +/** + * Shared type definitions for the Ponomar liturgical engine. + */ + +/** All liturgical variables computed for a given day. */ +export interface DayInfo { + // Date components + dow: number; // Day of week (0=Sun, 1=Mon, ..., 6=Sat) + doy: number; // Day of year (0=Jan 1) + month: number; // Julian calendar month (1-12) + day: number; // Day of month + year: number; // Julian calendar year + + // Paschal cycle + nday: number; // Days from this year's Pascha (- before, 0 = Pascha, + after) + ndayP: number; // Days from last year's Pascha + ndayF: number; // Days to next year's Pascha (negative before, 0 at next Pascha) + Easter: number; // JDN of this year's Pascha + Pentecost: number; // JDN of this year's Pentecost + LentStart: number; // JDN of Clean Monday + + // Liturgical state + Tone: number; // Current Octoechos tone (1-8, -1 = none) + dRank: number; // Liturgical rank (0-8, higher = more important) + PFlag: number; // Paschal flag + PFlag1: number; // Service inclusion flag + PFlag2: number; // Service type (0=Normal, 1=Lenten, 2=Holy Week/Bright Week) + PS: number; // Priest status (0=Reader, 1=Priest) + + // Seasonal flags + isLent: boolean; + isBrightWeek: boolean; + isApostlesFast: boolean; + isDormitionFast: boolean; + isNativityFast: boolean; + + // Data file references + triodionFile: number | null; // Index in triodion (1-70) + pentecostarionFile: number | null; // Index in pentecostarion (1-315) +} + +/** Expression evaluator variable store. */ +export type EvalContext = Record; + +/** A saint/feast commemoration entry from monthly day XML. */ +export interface Commemoration { + sIds: string[]; // Saint IDs (comma-separated in source) + cId: string; // Commemoration ID (points to lives/{cId}.xml) + src?: string; // Source: G=Greek, R=Russian, U=Unsourced + tone?: string; // Tone expression (evaluated at runtime) + cmd?: string; // Conditional expression +} + +/** A day's worth of commemoration data. */ +export interface DayData { + commemorations: Commemoration[]; +} + +/** Saint life data (from lives/{cId}.xml). */ +export interface SaintData { + name?: { + nominative: string; + short?: string; + genitive?: string; + }; + grammar?: Record; + rank?: number; + life?: { + text: string; + id?: string; + copyright?: string; + }; + services?: Record; + troparion?: { tone: number; text: string }[]; + kontakion?: { tone: number; text: string }[]; + scriptures?: { type: string; reading: string }[]; + icon?: string; +} + +/** Service data within a saint's life file. */ +export interface ServiceData { + [path: string]: Record; +} + +/** Fasting rule for a day. */ +export interface FastingInfo { + level: FastingLevel; + description: string; + meatFree: boolean; + fishPermitted: boolean; + caviarPermitted: boolean; + oilPermitted: boolean; + winePermitted: boolean; + strictFast: boolean; + noFood: boolean; +} + +export enum FastingLevel { + NoFast = 0, + WineAndOil = 1, + Xerophagy = 2, + StrictWithOil = 3, + Strict = 4, + CompleteFast = 5, +} + +/** Service template node — dynamic JSON from converted XML. */ +export interface ServiceNode { + type: string; + [key: string]: unknown; +} + +/** Service rules for a given liturgical period. */ +export interface ServiceRule { + cmd: string; + services: Record; +} + +export interface ServiceTypeConfig { + type: string; + troparion?: string; + pickT?: number; + kontakion?: string; + pickK?: number; + lentenk?: number; +} + +/** Bible translation metadata. */ +export interface BibleVersion { + id: string; + name: string; + language: string; + books: BibleBook[]; + info: BibleInfo; +} + +export interface BibleBook { + id: string; + name: string; + chapters: number; + short: string; + intro?: string; +} + +export interface BibleInfo { + verseNumFormat: string; + verseNoNumFormat: string; + verseLink: string; + abbrevFormat: string; + chapterN: string; + headerFormat: string; + chapterNI: string; + verseNo: string[]; + chapterNo: string[]; + cvSep: string; + duration: string; + selectionSeparator: string; + bcSep: string; + fontFace: string; + fontSize: number; + parts: string; + orient: string; +} + +/** Language info. */ +export type LanguageCode = 'en' | 'ru' | 'cu'; + +export interface LanguageInfo { + code: LanguageCode; + name: string; + nameLocal: string; + dir: 'ltr' | 'rtl'; + fontFace: string; + liturgicalFont: string; + liturgicalSize: number; +} + +/** Fasting rule from Fasting.xml. */ +export interface FastingRule { + case: string; // 7-bit binary string + cmd: string; // boolean expression +} + +/** Fasting period from Fasting.xml. */ +export interface FastingPeriod { + cmd: string; + rules: FastingRule[]; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0820842 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import './styles/main.css'; +import { App } from './ui/app'; + +const app = new App(document.getElementById('app')!); +app.init(); diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 0000000..cc2b826 --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,149 @@ +@import "tailwindcss"; + +@theme { + --color-parchment: #f5f0e8; + --color-parchment-dark: #e8dfd0; + --color-navy: #1a1a2e; + --color-navy-light: #2a2a4e; + --color-gold: #c5a55a; + --color-gold-light: #d4b96e; + --color-red: #8b0000; + --color-red-light: #a52a2a; + --color-purple: #4a0e4e; + --color-green: #2d5a27; + --color-blue: #1a3a6e; +} + +@font-face { + font-family: 'Ponomar'; + src: url('/fonts/Ponomar-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Fedorovsk'; + src: url('/fonts/Fedorovsk-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Indiction'; + src: url('/fonts/Indiction-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Menaion'; + src: url('/fonts/Menaion-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Monomakh'; + src: url('/fonts/Monomakh-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Triodion'; + src: url('/fonts/Triodion-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Vilnius'; + src: url('/fonts/Vilnius-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Voskresensky'; + src: url('/fonts/Voskresensky-Regular.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +html, body { + margin: 0; + padding: 0; + background-color: var(--color-parchment); + color: var(--color-navy); + font-family: 'Noto Sans', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + overscroll-behavior: none; +} + +.font-slavonic { + font-family: 'Ponomar', 'Noto Serif', Georgia, serif; +} +.font-ponomar { font-family: 'Ponomar', 'Noto Serif', Georgia, serif; } +.font-fedrovsk { font-family: 'Fedorovsk', 'Noto Serif', Georgia, serif; } +.font-indiction { font-family: 'Indiction', 'Noto Serif', Georgia, serif; } +.font-menaion { font-family: 'Menaion', 'Noto Serif', Georgia, serif; } +.font-monomakh { font-family: 'Monomakh', 'Noto Serif', Georgia, serif; } +.font-triodion { font-family: 'Triodion', 'Noto Serif', Georgia, serif; } +.font-vilnius { font-family: 'Vilnius', 'Noto Serif', Georgia, serif; } +.font-voskresensky { font-family: 'Voskresensky', 'Noto Serif', Georgia, serif; } + +.font-system-slavonic { + font-family: 'Noto Serif', Georgia, serif; +} + +.rank-icon { + font-family: 'Ponomar', serif; + font-size: 130%; +} + +.liturgical-text { + font-size: var(--liturgical-font-size, 18px); + line-height: 1.6; +} +.liturgical-text > :first-child { + margin-top: 0; +} + +.speaker-label { + color: var(--color-red); + font-weight: 700; +} + +.rubric { + color: var(--color-red); + font-style: italic; +} + +.drop-cap::first-letter { + color: var(--color-red); + font-weight: 700; + font-size: 1.5em; + float: left; + line-height: 1; + margin-right: 0.1em; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-gold); + border-radius: 3px; +} + +/* PWA safe area insets */ +@supports (padding: env(safe-area-inset-bottom)) { + .nav-bar { + padding-bottom: calc(0.5rem + env(safe-area-inset-bottom)); + } +} diff --git a/src/ui/app.ts b/src/ui/app.ts new file mode 100644 index 0000000..dff0742 --- /dev/null +++ b/src/ui/app.ts @@ -0,0 +1,142 @@ +/** + * Main application shell — SPA router and layout. + */ + +import { CalendarView } from './calendar-view'; +import { BibleView } from './bible-view'; +import { SettingsView } from './settings-view'; +import { JDate } from '../core/jdate'; +import { loadSettings, type Settings } from './settings-view'; +import { getTranslations, type LanguageCode } from '../core/i18n'; + +type View = 'calendar' | 'bible' | 'settings'; + +export class App { + private container: HTMLElement; + private currentView: View = 'calendar'; + private currentDate: JDate; + private settings: Settings; + + constructor(container: HTMLElement) { + this.container = container; + this.currentDate = JDate.today(); + this.settings = loadSettings(); + document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px'); + } + + init() { + this.container.innerHTML = ''; + this.render(); + this.handleRoute(); + window.addEventListener('hashchange', () => this.handleRoute()); + + // Register service worker for PWA install + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + } + window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + (window as any).__deferredPrompt = e; + }); + } + + private handleRoute() { + const hash = window.location.hash.slice(1) || 'calendar'; + const parts = hash.split('/'); + this.currentView = parts[0] as View || 'calendar'; + this.renderView(); + } + + private resolveBibleVersion(shortId: string, lang: string): string { + // Map short version IDs like "kjv" to full paths like "en/bible/kjv" + const langMap: Record = { + en: 'en/bible/kjv', + ru: 'ru/bible/synod', + cu: 'cu/bible/elis', + }; + if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return `en/bible/${shortId}`; + if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return `ru/bible/${shortId}`; + if (shortId === 'elis') return 'cu/bible/elis'; + if (shortId === 'ls') return 'fr/bible/ls'; + if (shortId === 'spt') return 'el/bible/spt'; + if (shortId === 'vulgate') return 'la/bible/vulgate'; + if (shortId === 'cuv') return 'zh/Hans/bible/cuv'; + if (shortId === 'cuv-hant') return 'zh/Hant/bible/cuv'; + if (shortId === 'svd') return 'ar/bible/svd'; + return langMap[lang] || 'en/bible/kjv'; + } + + private render() { + const t = getTranslations(this.settings.language as LanguageCode); + this.container.innerHTML = ` +

+
+

${t.appName}

+ +
+
+
+ `; + } + + private renderView() { + const viewContainer = document.getElementById('view-container')!; + viewContainer.innerHTML = ''; + + // Update active nav + document.querySelectorAll('.nav-link').forEach(el => { + const v = el.getAttribute('data-view'); + el.classList.toggle('text-gold', v === this.currentView); + el.classList.toggle('font-bold', v === this.currentView); + }); + + // Re-read settings fresh on each navigation + this.settings = loadSettings(); + const lang = this.settings.language as LanguageCode; + const calendarType = this.settings.calendarType || 'julian'; + + switch (this.currentView) { + case 'calendar': + new CalendarView(viewContainer, this.currentDate, (date) => { + this.currentDate = date; + }, lang, calendarType).render(); + break; + case 'bible': { + // Parse route params: #bible/{lang~short}/{book}/{passage} + const hash = window.location.hash.slice(1); + const bp = hash.split('/'); + const versionSpec = bp[1] || ''; + const bibleBook = bp[2] || ''; + const biblePassage = bp[3] || ''; + let bibleVersion: string; + if (!versionSpec) { + bibleVersion = this.resolveBibleVersion(this.settings.defaultBibleVersion, lang); + } else if (versionSpec.includes('~')) { + // Format: lang~short e.g. "en~kjv" → "en/bible/kjv" + const [vl, vs] = versionSpec.split('~'); + bibleVersion = `${vl}/bible/${vs}`; + } else if (versionSpec.includes('/')) { + bibleVersion = versionSpec; // legacy full path + } else { + bibleVersion = this.resolveBibleVersion(versionSpec, lang); + } + new BibleView(viewContainer, lang, bibleVersion, bibleBook, biblePassage).render(); + break; + } + case 'settings': + new SettingsView(viewContainer, () => { + this.render(); + this.renderView(); + }).render(); + break; + } + } + + go(view: View) { + window.location.hash = view; + } +} diff --git a/src/ui/bible-view.ts b/src/ui/bible-view.ts new file mode 100644 index 0000000..e027467 --- /dev/null +++ b/src/ui/bible-view.ts @@ -0,0 +1,285 @@ +/** + * Bible view — reader with all translation variants. + */ + +import { + parsePassage, + parseTextFile, + extractPassage, + formatPassageHTML, +} from '../core/bible-reader'; +import { getTranslations, type LanguageCode } from '../core/i18n'; + +const SETTINGS_KEY = 'ponomar-settings'; +const BIBLE_STATE_KEY = 'ponomar-bible-state'; + +function loadBibleState(version: string): { book: string; passage: string } | null { + try { + const stored = localStorage.getItem(BIBLE_STATE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + const pos = parsed.positions?.[version]; + if (pos?.book && pos?.passage) return pos; + } + } catch { /* ignore */ } + return null; +} + +function saveBibleState(version: string, book: string, passage: string) { + try { + const stored = localStorage.getItem(BIBLE_STATE_KEY); + const state: { positions: Record } = stored ? JSON.parse(stored) : { positions: {} }; + state.positions[version] = { book, passage }; + localStorage.setItem(BIBLE_STATE_KEY, JSON.stringify(state)); + } catch { /* ignore */ } +} + +function loadSettings(): { cuFont: string; showVerseNumbers: boolean; verseNewLine: boolean } { + try { + const stored = localStorage.getItem(SETTINGS_KEY); + const defaults = { cuFont: 'Ponomar', showVerseNumbers: true, verseNewLine: false }; + if (stored) { + const parsed = JSON.parse(stored); + if (parsed.usePonomarFont !== undefined) { + parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : ''; + delete parsed.usePonomarFont; + } + return { ...defaults, ...parsed }; + } + } catch { /* ignore */ } + return { cuFont: 'Ponomar', showVerseNumbers: true, verseNewLine: false }; +} + +const FONT_CSS_MAP: Record = { + Ponomar: 'font-ponomar', + Fedorovsk: 'font-fedrovsk', + Indiction: 'font-indiction', + Menaion: 'font-menaion', + Monomakh: 'font-monomakh', + Triodion: 'font-triodion', + Vilnius: 'font-vilnius', + Voskresensky: 'font-voskresensky', +}; + +function fontClass(fontName: string): string { + return FONT_CSS_MAP[fontName] || (fontName ? '' : 'font-system-slavonic'); +} + +interface BibleVersion { + id: string; + name: string; + language: string; + books: { id: string; name: string; short: string; chapters: number }[]; +} + +export class BibleView { + private container: HTMLElement; + private currentVersion: string; + private currentBook: string; + private currentPassage: string; + private versions: BibleVersion[] = []; + private loadedText: Map = new Map(); + private t: ReturnType; + + constructor(container: HTMLElement, language: LanguageCode = 'en', version = '', book = '', passage = '') { + this.container = container; + this.t = getTranslations(language); + this.currentVersion = version || 'en/bible/kjv'; + + if (!book && !passage) { + const saved = loadBibleState(this.currentVersion); + if (saved) { book = saved.book; passage = saved.passage; } + } + + this.currentBook = book || 'Gen'; + this.currentPassage = passage || '1'; + this.loadVersions(); + } + + private async loadVersions() { + try { + const resp = await fetch('/data/bible/versions.json'); + this.versions = await resp.json(); + } catch { + this.versions = [ + { id: 'en/bible/kjv', name: 'English (KJV)', language: 'en', books: [] }, + { id: 'ru/bible/synod', name: 'Русский (Синодальный)', language: 'ru', books: [] }, + { id: 'cu/bible/elis', name: 'Церковнославянская', language: 'cu', books: [] }, + ]; + } + } + + async render() { + await this.loadVersions(); + + this.container.innerHTML = ` +
+
+ + +
+ + + +
+
+ +
+

${this.t.bible.selectBook}

+
+
+ `; + + this.updateChapterSelect(); + this.loadReading(); + + document.getElementById('version-select')?.addEventListener('change', (e) => { + this.currentVersion = (e.target as HTMLSelectElement).value; + const books = this.getBooksForVersion(); + const sameBook = books.find(b => b.id === this.currentBook); + if (sameBook) { + const ch = parseInt(this.currentPassage, 10); + if (ch > sameBook.chapters) this.currentPassage = '1'; + } else { + this.currentBook = books[0]?.id || 'Gen'; + this.currentPassage = '1'; + } + this.updateBookSelect(); + this.loadReading(); + }); + + document.getElementById('book-select')?.addEventListener('change', (e) => { + this.currentBook = (e.target as HTMLSelectElement).value; + this.currentPassage = '1'; + this.updateChapterSelect(); + this.loadReading(); + }); + + document.getElementById('chapter-select')?.addEventListener('change', (e) => { + this.currentPassage = (e.target as HTMLSelectElement).value; + this.loadReading(); + }); + + document.getElementById('prev-chapter')?.addEventListener('click', () => { + const ch = parseInt(this.currentPassage, 10); + if (ch > 1) { + this.currentPassage = String(ch - 1); + this.updateChapterSelect(); + this.loadReading(); + } else { + const books = this.getBooksForVersion(); + const idx = books.findIndex(b => b.id === this.currentBook); + if (idx > 0) { + const prevBook = books[idx - 1]; + this.currentBook = prevBook.id; + this.currentPassage = String(prevBook.chapters); + this.updateBookSelect(); + this.loadReading(); + } + } + }); + + document.getElementById('next-chapter')?.addEventListener('click', () => { + const ch = parseInt(this.currentPassage, 10); + const books = this.getBooksForVersion(); + const book = books.find(b => b.id === this.currentBook); + const maxCh = book?.chapters ?? 50; + if (ch < maxCh) { + this.currentPassage = String(ch + 1); + this.updateChapterSelect(); + this.loadReading(); + } else { + const idx = books.findIndex(b => b.id === this.currentBook); + if (idx < books.length - 1) { + const nextBook = books[idx + 1]; + this.currentBook = nextBook.id; + this.currentPassage = '1'; + this.updateBookSelect(); + this.loadReading(); + } + } + }); + } + + private getBooksForVersion(): { id: string; name: string; chapters: number }[] { + const version = this.versions.find(v => v.id === this.currentVersion); + return version?.books ?? []; + } + + private updateBookSelect() { + const select = document.getElementById('book-select') as HTMLSelectElement; + if (!select) return; + const books = this.getBooksForVersion(); + select.innerHTML = books.map(b => + `` + ).join(''); + this.updateChapterSelect(); + } + + private updateChapterSelect() { + const select = document.getElementById('chapter-select') as HTMLSelectElement; + if (!select) return; + const books = this.getBooksForVersion(); + const book = books.find(b => b.id === this.currentBook); + const chapters = book?.chapters ?? 50; + select.innerHTML = Array.from({ length: chapters }, (_, i) => + `` + ).join(''); + // Sync passage to selected chapter + this.currentPassage = select.value; + } + + private async loadReading() { + const textEl = document.getElementById('bible-text'); + if (!textEl) return; + + textEl.innerHTML = `

${this.t.loading}

`; + + try { + const cacheKey = `${this.currentVersion}/${this.currentBook}`; + let text = this.loadedText.get(cacheKey); + + if (!text) { + // Version IDs are full paths like "en/bible/kjv" + const resp = await fetch(`/data/${this.currentVersion}/${this.currentBook}.text`); + if (!resp.ok) throw new Error('Not found'); + text = await resp.text(); + this.loadedText.set(cacheKey, text); + } + + const chapters = parseTextFile(text!); + const reading = parsePassage(this.currentPassage); + const passages = extractPassage(chapters, reading); + const settings = loadSettings(); + const html = formatPassageHTML(passages, { showVerseNumbers: settings.showVerseNumbers, verseNewLine: settings.verseNewLine }); + + // Apply Ponomar font for Church Slavonic texts + const version = this.versions.find(v => v.id === this.currentVersion); + const isChurchSlavonic = version?.language === 'cu'; + const fnClass = isChurchSlavonic ? fontClass(settings.cuFont) : ''; + + textEl.className = `flex-1 overflow-auto p-2 max-w-3xl mx-auto liturgical-text ${fnClass}`; + textEl.innerHTML = html || `

${this.t.bible.noTextFound}

`; + } catch { + textEl.innerHTML = ` +
+

${this.t.bible.notAvailableOffline}

+

+ ${this.t.bible.ensureDataConverted} +

+
+ `; + } + + saveBibleState(this.currentVersion, this.currentBook, this.currentPassage); + } +} \ No newline at end of file diff --git a/src/ui/calendar-view.ts b/src/ui/calendar-view.ts new file mode 100644 index 0000000..cdde05e --- /dev/null +++ b/src/ui/calendar-view.ts @@ -0,0 +1,918 @@ +/** + * Calendar view — month grid + day detail panel with actual data loading. + */ + +import { JDate } from '../core/jdate'; +import { Paschalion } from '../core/paschalion'; +import { computeDay } from '../core/day-computer'; +import { evaluateFasting } from '../core/fasting'; +import type { FastingPeriod } from '../core/fasting'; +import { getTranslations, type LanguageCode } from '../core/i18n'; +import { fontClass } from './settings-view'; +import { ServiceView } from './service-view'; + +let cachedFastingPeriods: FastingPeriod[] | null = null; +const lifeCache = new Map(); +const menaionBundles = new Map>(); +let sharedScriptureIndex: Record | null = null; +let bookAbbrevMap: Record | null = null; +let bookCVSep = ':'; +let bookAbbrevLang = ''; + +function resolveDefaultBibleVersion(): string { + try { + const stored = localStorage.getItem('ponomar-settings'); + if (stored) { + const s = JSON.parse(stored); + return s.defaultBibleVersion || 'kjv'; + } + } catch {} + return 'kjv'; +} + +function shortToLang(shortId: string): string { + if (shortId === 'kjv' || shortId === 'brenton' || shortId === 'nonkjv') return 'en'; + if (shortId === 'synod' || shortId === 'kassian' || shortId === 'yungerov') return 'ru'; + if (shortId === 'elis') return 'cu'; + if (shortId === 'ls') return 'fr'; + if (shortId === 'spt') return 'el'; + if (shortId === 'vulgate') return 'la'; + if (shortId === 'cuv' || shortId === 'cuv-hant') return 'zh'; + if (shortId === 'svd') return 'ar'; + return 'en'; +} + +async function loadBookAbbrevs(lang: string): Promise { + if (bookAbbrevLang === lang && bookAbbrevMap) return; + bookAbbrevLang = lang; + bookAbbrevMap = null; + try { + const resp = await fetch('/data/bible/versions.json'); + if (!resp.ok) return; + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) return; + const versions = await resp.json() as any[]; + const ver = versions.find((v: any) => v.id.startsWith(lang + '/')) || versions[0]; + if (ver) { + const map: Record = {}; + if (ver.books) { + for (const book of ver.books) { + map[book.id] = book.short || book.id; + } + } + bookAbbrevMap = map; + if (ver.formatting?.CVSep) { + bookCVSep = ver.formatting.CVSep; + } + } + } catch {} +} + +function formatReadingRef(reading: string, _lang: string): string { + const idx = reading.indexOf('_'); + if (idx === -1) return reading.replace(/_/g, ' '); + const bookId = reading.substring(0, idx); + const passage = reading.substring(idx + 1); + const lookupId = bookId.replace(/ /g, '_'); + const abbrev = bookAbbrevMap?.[lookupId] || bookId; + const formatted = passage.replace(/:/g, bookCVSep === ',' ? ',' : bookCVSep === ':' ? ':' : ':'); + const display = `${abbrev} ${formatted}`; + const chMatch = passage.match(/^(\d+)/); + const chapter = chMatch ? chMatch[1] : '1'; + const defaultShort = resolveDefaultBibleVersion(); + const langPrefix = shortToLang(defaultShort); + return `${display}`; +} + +async function loadSharedScriptureIndex(): Promise | null> { + if (sharedScriptureIndex) return sharedScriptureIndex; + try { + const resp = await fetch('/data/shared/lives-index.json'); + if (!resp.ok) return null; + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) return null; + sharedScriptureIndex = await resp.json(); + return sharedScriptureIndex; + } catch { + return null; + } +} + +async function loadMenaionBundle(lang: string): Promise | null> { + const key = lang === 'cu' ? 'cu' : lang; + if (menaionBundles.has(key)) return menaionBundles.get(key)!; + try { + const resp = await fetch(`/data/${key}/menaion-bundle.json`); + if (!resp.ok) return null; + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) return null; + const bundle = await resp.json() as Record; + menaionBundles.set(key, bundle); + return bundle; + } catch { + return null; + } +} + +async function loadFastingPeriods(): Promise { + if (cachedFastingPeriods) return cachedFastingPeriods; + try { + const resp = await fetch('/data/shared/fasting.json'); + if (!resp.ok) return []; + cachedFastingPeriods = await resp.json(); + return cachedFastingPeriods!; + } catch { + return []; + } +} + +interface LifeData { + name?: { + nominative?: string; + short?: string; + '@_Nominative'?: string; + '@_Short'?: string; + }; + life?: { + text: string; + }; + scripture?: { + type?: string; + Type?: string; + reading?: string; + Reading?: string; + pericope?: string; + Pericope?: string; + cmd?: string; + }[]; + services?: Record; + [key: string]: unknown; +} + +interface CommemorationData { + NAME?: { Long?: string; ShortN?: string; ShortF?: string } | { text: string }; + LIFE?: { text: string }; + CHURCH?: { Rank?: string }; + SCRIPTURE?: { Type?: string; Reading?: string; Pericope?: string; Cmd?: string } | { Type?: string; Reading?: string; Pericope?: string; Cmd?: string }[]; + SERVICE?: Record; + TROPARION?: { Tone?: string; text?: string } | { Tone?: string; text?: string }[]; + [key: string]: unknown; +} + +interface ReadingItem { + ref: string; + purpose: string; +} + +interface ReadingsGroup { + service: string; + items: ReadingItem[]; +} + +/** Order of services for display (Vespers → Hours → Matins → Liturgy sub-types) */ +const SERVICE_ORDER = ['Вечерня', '1-й час', '3-й час', '6-й час', '9-й час', 'Утреня', 'Апостол', 'Евангелие']; + +/** Map Type/service name to display service label */ +function readingsTypeToService(type: string, t: ReturnType): string { + switch (type) { + case 'apostol': return t.calendar.readingTypes.apostol; + case 'gospel': return t.calendar.readingTypes.gospel; + case 'matins': return t.calendar.readingTypes.matins; + case 'vespers': return t.calendar.readingTypes.vespers; + case 'primes': return t.calendar.readingTypes.primes; + case 'terce': return t.calendar.readingTypes.terce; + case 'sext': return t.calendar.readingTypes.sext; + case 'none': return t.calendar.readingTypes.none; + case 'liturgy': return t.calendar.readingTypes.liturgy; + case 'A': return t.calendar.readings; + default: + if (/^[0-9]+$/.test(type)) { + const n = parseInt(type, 10); + if (n <= 3 || n === 0) return t.calendar.readingTypes.vespers; + return t.calendar.readingTypes.matins; + } + return t.calendar.readingTypes[type] || t.calendar.readings; + } +} + +function svcNameToService(svcName: string, scType: string, t: ReturnType): string { + const upper = svcName.toUpperCase(); + if (upper === 'LITURGY') { + return readingsTypeToService(scType, t); + } + return readingsTypeToService(upper.toLowerCase(), t); +} + +function findOrCreateGroup(groups: ReadingsGroup[], service: string): ReadingsGroup { + let group = groups.find(g => g.service === service); + if (!group) { + group = { service, items: [] }; + groups.push(group); + } + return group; +} + +/** Map of menaion CId to shared commemoration ID (for feast days with special commemorations) */ +const CID_TO_SHARED_COMM: Record = { + '010101': '134', // Circumcision + '010601': '35', // Theophany + '011901': '373', // Theophany (Julian) + '020201': '373', // Meeting of the Lord + '032501': '543', // Annunciation + '080601': '543', // Transfiguration + '081501': '373', // Dormition + '091401': 'F0', // Elevation of Cross + '092101': 'F6', // Nativity of Theotokos + '010701': '373', // Synaxis of John the Baptist + '070701': '373', // Nativity of John the Baptist + '082901': '373', // Beheading of John the Baptist +}; + +async function loadLife(lang: LanguageCode, cid: string): Promise { + const cacheKey = `${lang}/${cid}`; + if (lifeCache.has(cacheKey)) return lifeCache.get(cacheKey)!; + try { + const bundleName = /^(0[1-9]|1[0-2])\d+/.test(cid) ? cid.substring(0, 2) : 'misc'; + async function tryFetch(prefix: string): Promise { + const resp = await fetch(`/data/${prefix}/lives/${bundleName}.json`); + if (!resp.ok) return null; + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) return null; + return resp; + } + let resp = await tryFetch(lang); + if (!resp && lang !== 'cu') { + resp = await tryFetch('cu'); + } + if (!resp) { + lifeCache.set(cacheKey, null); + return null; + } + const bundle = await resp.json() as Record; + const life = (bundle[cid] || null) as LifeData | null; + if (!life) { + lifeCache.set(cacheKey, null); + return null; + } + + // Merge rank from cu life if language-specific life lacks it + if (!life.rank && lang !== 'cu') { + try { + const cuResp = await fetch(`/data/cu/lives/${bundleName}.json`); + if (cuResp.ok) { + const ct = cuResp.headers.get('content-type'); + if (!ct || !ct.includes('text/html')) { + const cuBundle = await cuResp.json() as Record; + const cuLife = cuBundle[cid] as LifeData | undefined; + if (cuLife?.rank) life.rank = cuLife.rank; + } + } + } catch {} + } + + // Merge scripture from shared lives index (one HTTP request for all) + const index = await loadSharedScriptureIndex(); + if (index && index[cid]) { + const raw = index[cid]; + const sharedArr = (Array.isArray(raw) ? raw : [raw]) as LifeData['scripture']; + if (sharedArr && sharedArr.length > 0) { + if (life.scripture) { + const existing = Array.isArray(life.scripture) ? life.scripture : [life.scripture]; + life.scripture = [...existing, ...sharedArr] as LifeData['scripture']; + } else { + life.scripture = sharedArr; + } + } + } + + lifeCache.set(cacheKey, life); + return life; + } catch { + lifeCache.set(cacheKey, null); + return null; + } +} + +async function loadCommemoration(sharedId: string): Promise { + try { + const resp = await fetch(`/data/shared/commemorations/${sharedId}.json`); + if (!resp.ok) return null; + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) return null; + return await resp.json(); + } catch { + return null; + } +} + +function getPeriodName(life: LifeData | undefined): string { + if (!life?.name) return ''; + return life.name['@_Nominative'] || life.name.nominative || life.name['@_Short'] || life.name.short || ''; +} + +function getShortFeastName(life: LifeData | undefined, shared: CommemorationData | null | undefined): string { + if (life?.name?.['@_Short']) return life.name['@_Short']; + if (life?.name?.short) return life.name.short; + return getCommsName(shared); +} + +function getCommsName(shared: CommemorationData | null | undefined): string { + if (!shared?.NAME) return ''; + if (typeof shared.NAME === 'object' && 'Long' in shared.NAME) { + return shared.NAME.Long || shared.NAME.ShortN || ''; + } + if (typeof shared.NAME === 'object' && 'text' in shared.NAME) { + return (shared.NAME as { text: string }).text || ''; + } + return ''; +} + +async function loadMenaion(lang: LanguageCode, month: number, day: number): Promise { + const mm = String(month).padStart(2, '0'); + const dd = String(day).padStart(2, '0'); + const dateKey = `${mm}-${dd}`; + try { + // Try lang-specific bundle first + let bundle = await loadMenaionBundle(lang); + if (!bundle && lang !== 'cu') { + bundle = await loadMenaionBundle('cu'); + } + if (bundle && bundle[dateKey]) { + const arr = bundle[dateKey]; + return Array.isArray(arr) ? arr : []; + } + return []; + } catch { + return []; + } +} + +async function loadTriodionFile(index: number): Promise { + try { + const resp = await fetch(`/data/shared/triodion/${String(index).padStart(2, '0')}.json`); + if (!resp.ok) return []; + return await resp.json(); + } catch { + return []; + } +} + +async function loadPentecostarionFile(index: number): Promise { + try { + const resp = await fetch(`/data/shared/pentecostarion/${String(index).padStart(2, '0')}.json`); + if (!resp.ok) return []; + return await resp.json(); + } catch { + return []; + } +} + +export class CalendarView { + private container: HTMLElement; + private currentDate: JDate; + private onDateChange: (date: JDate) => void; + private skipCids: Set = new Set(); + private language: LanguageCode; + private t: ReturnType; + private calendarType: 'julian' | 'gregorian'; + + constructor(container: HTMLElement, date: JDate, onDateChange: (d: JDate) => void, language: LanguageCode = 'en', calendarType: 'julian' | 'gregorian' = 'julian') { + this.container = container; + this.currentDate = date; + this.onDateChange = onDateChange; + this.language = language; + this.t = getTranslations(language); + this.calendarType = calendarType; + } + + private getFontClass(): string { + try { + const stored = localStorage.getItem('ponomar-settings'); + if (stored) { + const settings = JSON.parse(stored); + let cuFont = settings.cuFont || ''; + // Migrate old usePonomarFont + if (settings.usePonomarFont !== undefined) { + cuFont = settings.usePonomarFont ? 'Ponomar' : ''; + } + const useFont = cuFont !== '' || settings.usePonomarFont !== false; + return (this.language === 'cu') && useFont ? fontClass(cuFont) : ''; + } + } catch {} + return this.language === 'cu' ? 'font-ponomar' : ''; + } + + /** Lightweight dRank computation for calendar grid highlighting */ + private getDayRank(date: JDate, julianYear: number): number { + const month = date.getMonth(); + const day = date.getDay(); + const pascha = Paschalion.getPascha(julianYear); + const nday = date.difference(pascha); + const isBrightWeek = nday >= 0 && nday <= 6; + + if (month === 9 && day === 14) return 6; + if (month === 8 && day === 29) return 6; + if (nday === 0) return 8; + if (nday === 49) return 8; + if (nday === 39) return 8; + if (nday === -7) return 8; + if (month === 12 && day === 25) return 6; + if (month === 1 && day === 6) return 6; + if (month === 8 && day === 15) return 6; + if (month === 3 && day === 25) return 6; + if (nday >= -48 && nday < 0) return 3; + if (isBrightWeek || (nday >= 49 && nday <= 56)) return 5; + return 0; + } + + render() { + const useGregorian = this.calendarType === 'gregorian'; + const fnClass = this.getFontClass(); + + function daysInGregorianMonth(m: number, y: number): number { + const dim = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (m === 2 && ((y % 4 === 0 && y % 100 !== 0) || y % 400 === 0)) return 29; + return dim[m - 1]; + } + + const julianYear = this.currentDate.getYear(); + const julianMonth = this.currentDate.getMonth(); + const julianDay = this.currentDate.getDay(); + + // Grid display params: Julian or Gregorian based on setting + let month: number, year: number, day: number; + if (useGregorian) { + const g = this.currentDate.toGregorian(); + year = g.getFullYear(); + month = g.getMonth() + 1; + day = g.getDate(); + } else { + year = julianYear; + month = julianMonth; + day = julianDay; + } + + let firstDow: number; + let daysInMonth: number; + if (useGregorian) { + const firstJulian = JDate.fromGregorianParts(year, month, 1); + firstDow = firstJulian.getDayOfWeek(); + daysInMonth = daysInGregorianMonth(month, year); + } else { + const firstOfMonth = new JDate(month, 1, year); + firstDow = firstOfMonth.getDayOfWeek(); + daysInMonth = JDate.maxDaysInMonth(month, year); + } + + const fasts = Paschalion.getFasts(julianYear); + + let gridHtml = ''; + let col = 0; + + gridHtml += this.t.calendar.dayNames.map(d => + `
${d}
` + ).join(''); + + for (let i = 0; i < firstDow; i++) { + gridHtml += '
'; + col++; + } + + for (let d = 1; d <= daysInMonth; d++) { + let fast = 0; + let isSelected = false; + let isToday = false; + let dayRank = 0; + if (useGregorian) { + const jDate = JDate.fromGregorianParts(year, month, d); + const dayDoy = jDate.getDoy(); + fast = fasts[dayDoy] ?? 0; + isSelected = d === day; + dayRank = this.getDayRank(jDate, julianYear); + const todayGreg = JDate.today().toGregorian(); + isToday = todayGreg.getFullYear() === year && todayGreg.getMonth() + 1 === month && todayGreg.getDate() === d; + } else { + const date = new JDate(month, d, year); + const dayDoy = date.getDoy(); + fast = fasts[dayDoy] ?? 0; + isSelected = d === day; + dayRank = this.getDayRank(date, julianYear); + isToday = date.equals(JDate.today()); + } + + let classes = 'day-cell p-1 text-center text-sm cursor-pointer rounded transition-colors '; + if (isSelected) classes += 'bg-navy text-parchment font-bold '; + else if (isToday) classes += 'bg-gold/20 font-bold '; + else if (dayRank >= 6) classes += 'bg-red-100 text-red-800 font-bold '; + else if (fast === 1) classes += 'bg-purple-50 '; + else if (fast === 2) classes += 'bg-blue-50 '; + + gridHtml += `
${d}
`; + + col++; + } + + const computed = computeDay(this.currentDate); + + + + // Get Gregorian date for display + const gregorianDate = this.currentDate.toGregorian(); + const gregorianDayName = this.t.calendar.dayNamesFull[gregorianDate.getDay()]; + const gregorianStr = `${gregorianDayName}, ${gregorianDate.getDate()} ${this.t.calendar.monthsGenitive[gregorianDate.getMonth()]} ${gregorianDate.getFullYear()} г.`; + // Julian date (church calendar) + const julianDayName = this.t.calendar.dayNamesFull[this.currentDate.getDayOfWeek()]; + // Anno Mundi year (Old Slavonic year = year + 5508) + const annoMundi = julianYear + 5508; + const julianStr = `${julianDayName}, ${this.currentDate.getDay()} ${this.t.calendar.monthsGenitive[this.currentDate.getMonth() - 1]}, год ${julianYear} ${this.t.calendar.fromYear}, ${annoMundi} ${this.t.calendar.fromAdam}`; + + this.container.innerHTML = ` +
+
+
+ + ${this.t.calendar.months[month - 1]} ${year} + +
+ +
+ ${gridHtml} +
+ +
+ +
+ +
+
+

+ ${julianStr} +

+
+ ${this.t.calendar.gregorianDate[0].toLowerCase() + this.t.calendar.gregorianDate.slice(1)}: ${gregorianStr} +
+ +
+ +
+

${this.t.calendar.commemorations}

+

${this.t.loading}

+
+ +
+

${this.t.calendar.fastingRule}

+

${this.t.loading}

+
+ +
+

${this.t.calendar.readings}

+

${this.t.loading}

+
+ +
+
+
+ + `; + + // Load data asynchronously + this.loadData(computed); + + // Event listeners + document.getElementById('prev-month')?.addEventListener('click', () => { + const prevMonth = month <= 1 ? 12 : month - 1; + const prevYear = month <= 1 ? year - 1 : year; + if (useGregorian) { + const prevDay = Math.min(day, daysInGregorianMonth(prevMonth, prevYear)); + this.currentDate = JDate.fromGregorianParts(prevYear, prevMonth, prevDay); + } else { + const prevDay = Math.min(day, JDate.maxDaysInMonth(prevMonth, prevYear)); + this.currentDate = new JDate(prevMonth, prevDay, prevYear); + } + this.onDateChange(this.currentDate); + this.render(); + }); + + document.getElementById('next-month')?.addEventListener('click', () => { + const nextMonth = month >= 12 ? 1 : month + 1; + const nextYear = month >= 12 ? year + 1 : year; + if (useGregorian) { + const nextDay = Math.min(day, daysInGregorianMonth(nextMonth, nextYear)); + this.currentDate = JDate.fromGregorianParts(nextYear, nextMonth, nextDay); + } else { + const nextDay = Math.min(day, JDate.maxDaysInMonth(nextMonth, nextYear)); + this.currentDate = new JDate(nextMonth, nextDay, nextYear); + } + this.onDateChange(this.currentDate); + this.render(); + }); + + this.container.querySelectorAll('.day-cell').forEach(el => { + el.addEventListener('click', () => { + const d = parseInt(el.getAttribute('data-day')!, 10); + if (useGregorian) { + this.currentDate = JDate.fromGregorianParts(year, month, d); + } else { + this.currentDate = new JDate(month, d, year); + } + this.onDateChange(this.currentDate); + this.render(); + }); + }); + } + + private async loadData(computed: { dayInfo: import('../core/types').DayInfo; evalContext: import('../core/types').EvalContext }) { + const { dayInfo, evalContext } = computed; + this.skipCids = new Set(); + + // Load fasting + const fastingPeriods = await loadFastingPeriods(); + const fastingResult = evaluateFasting(fastingPeriods, evalContext, this.language); + const fastingPanel = document.getElementById('fasting-panel'); + if (fastingPanel) { + fastingPanel.innerHTML = ` +

${this.t.calendar.fastingRule}

+

${fastingResult.description || this.t.notAvailable}

+ `; + } + + // Load commemorations from menaion (language-specific) + const menaion = await loadMenaion(this.language, dayInfo.month, dayInfo.day); + + // Load triodion/pentecostarion entries (shared, language-agnostic) + let calendarEntries: unknown[] = []; + if (dayInfo.triodionFile !== null) { + calendarEntries = await loadTriodionFile(dayInfo.triodionFile); + } else if (dayInfo.pentecostarionFile !== null) { + calendarEntries = await loadPentecostarionFile(dayInfo.pentecostarionFile); + } + + // Collect CIds from all entries + const cids: string[] = []; + const allEntries = [...calendarEntries, ...menaion]; + for (const entry of allEntries) { + if (typeof entry === 'object' && entry !== null) { + const e = entry as Record; + if (e.CId) cids.push(String(e.CId)); + } + } + + // Load commemorations from BOTH sources: + // 1. Lives (language-specific, for names & life text) + // 2. Shared commemorations (for readings, rank, troparion/kontakion) + // Use CID_TO_SHARED_COMM mapping for known feast days + const livesData: Map = new Map(); + const sharedComms: Map = new Map(); + + for (const cid of cids.slice(0, 30)) { + const [life, shared] = await Promise.all([ + loadLife(this.language, cid), + loadCommemoration(CID_TO_SHARED_COMM[cid] || cid), + ]); + if (life) livesData.set(cid, life); + if (shared) sharedComms.set(cid, shared); + } + + // Build day title: period description + tone + great feast + const titleEl = document.getElementById('day-title'); + if (titleEl) { + let periodName = ''; + let periodCid = ''; + let feastCid = ''; + let feastName = ''; + let feastRank = 0; + + // Pass 1: Find period name from primary calendar entry (SId ''/'0' + CId 9[0-38]xxxx) + for (const entry of calendarEntries) { + const e = entry as Record; + const cid = String(e.CId || ''); + const sid = String(e.SId ?? ''); + if (!cid || !/^9[0-38]\d{2}$/.test(cid)) continue; + if (sid !== '' && sid !== '0') continue; + const name = getPeriodName(livesData.get(cid)) || getCommsName(sharedComms.get(cid)); + if (!name) continue; + periodName = name; + periodCid = cid; + break; + } + + // Pass 2: Find great feast (rank >= 6) from all entries + for (const entry of allEntries) { + const e = entry as Record; + const cid = String(e.CId || ''); + if (!cid || this.skipCids.has(cid)) continue; + const life = livesData.get(cid); + const shared = sharedComms.get(cid); + const name = getShortFeastName(life, shared); + if (!name) continue; + const rank = parseInt(shared?.CHURCH?.Rank || '0', 10) || (life?.rank as number) || 0; + if (rank >= 6 && rank > feastRank) { + feastRank = rank; + feastName = name; + feastCid = cid; + } + } + + // Skip period CId from commemorations if not a feast + if (periodCid && !feastCid) this.skipCids.add(periodCid); + + if (!periodName) periodName = this.t.calendar.rankLabels[dayInfo.dRank] ?? ''; + + if (periodName) { + const tone = dayInfo.Tone; + const toneSuffix = tone > 0 ? `; ${this.t.calendar.tone}${tone}` : ''; + const feastSuffix = feastName && feastCid !== periodCid ? `; ${feastName}` : ''; + const titleColor = feastName ? 'text-red-700' : 'text-navy'; + titleEl.innerHTML = `${periodName}${toneSuffix}${feastSuffix}`; + } + + if (feastCid) this.skipCids.add(feastCid); + } + + // Load commemorations for each CId + const commPanel = document.getElementById('commemorations-panel'); + if (commPanel) { + if (cids.length === 0) { + commPanel.innerHTML = ` +

${this.t.calendar.commemorations}

+

${this.t.calendar.noCommemorations}

+ `; + } else { + const commHtmlParts: string[] = []; + for (const cid of cids.slice(0, 15)) { + if (this.skipCids.has(cid)) continue; + // Prefer shared commemoration for name (feast days), fallback to lives (saints) + const shared = sharedComms.get(cid); + const life = livesData.get(cid); + + const lifeText = life?.life?.text || (shared?.LIFE as any)?.text || ''; + + let name = ''; + if (life?.name) { + name = life.name['@_Nominative'] || life.name.nominative || life.name['@_Short'] || life.name.short || ''; + } + if (!name && shared?.NAME) { + if (typeof shared.NAME === 'object' && 'Long' in shared.NAME) { + name = shared.NAME.Long || shared.NAME.ShortN || ''; + } else if (typeof shared.NAME === 'object' && 'text' in shared.NAME) { + name = (shared.NAME as { text: string }).text || ''; + } + } + if (name) { + // Determine rank: shared commemoration rank > day rank + const rank = parseInt(shared?.CHURCH?.Rank || '0', 10) || (life?.rank as number) || dayInfo.dRank || 0; + const icon = this.t.calendar.rankIcons[rank] || ''; + const iconHtml = icon ? `${icon}` : ''; + const lifeHtml = lifeText + ? `` + : ''; + commHtmlParts.push( + `
+
${iconHtml}${name}
+ ${lifeHtml} +
` + ); + } + } + if (commHtmlParts.length > 0) { + commPanel.innerHTML = ` +

${this.t.calendar.commemorations}

+
${commHtmlParts.join('')}
+ `; + commPanel.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const item = target.closest('.comm-item'); + if (item) { + const lifeDiv = item.querySelector('.life-text') as HTMLElement; + if (lifeDiv) lifeDiv.classList.toggle('hidden'); + } + }); + } else { + commPanel.innerHTML = ` +

${this.t.calendar.commemorations}

+

${this.t.calendar.noCommemorations}

+ `; + } + } + } + + // Collect readings grouped by service + const readingsGroups: ReadingsGroup[] = []; + const addReading = (service: string, reading: string, purpose: string) => { + const group = findOrCreateGroup(readingsGroups, service); + const ref = formatReadingRef(reading, this.language); + if (!group.items.some(i => i.ref === ref)) { + group.items.push({ ref, purpose }); + } + }; + const isPeriodCid = (cid: string): boolean => /^9[0-38]\d{2}$/.test(cid); + const readingContext = (shortName: string, cid?: string): string => { + if (!shortName) return ''; + if (cid && isPeriodCid(cid)) return ''; + return ` (${shortName})`; + }; + loadBookAbbrevs(this.language); + for (const [cid, shared] of sharedComms) { + if (shared?.SCRIPTURE) { + const shortN = typeof shared.NAME === 'object' && 'ShortN' in shared.NAME ? (shared.NAME.ShortN || '') : ''; + const tag = readingContext(shortN, cid); + const scripts = Array.isArray(shared.SCRIPTURE) ? shared.SCRIPTURE : [shared.SCRIPTURE]; + for (const s of scripts) { + if (s && typeof s === 'object') { + const sr = s as Record; + if (sr.Reading) { + const svc = readingsTypeToService(sr.Type || '', this.t); + addReading(svc, sr.Reading, tag); + } + } + } + } + } + // Also load readings from saint lives (from services structure) + for (const [cid, life] of livesData) { + const shortName = life?.name?.['@_Short'] || life?.name?.short || ''; + const tag = readingContext(shortName, cid); + + if (life?.services) { + const svcMap = life.services as Record>>; + for (const [svcName, scriptures] of Object.entries(svcMap)) { + for (const [, sc] of Object.entries(scriptures)) { + const reading = sc.Reading || sc.reading; + if (!reading) continue; + const scType = sc.Type || sc.type || ''; + const svc = svcNameToService(svcName, scType, this.t); + addReading(svc, reading, tag); + } + } + } else if (life?.scripture) { + for (const s of life.scripture) { + const sc = s as Record; + const reading = sc.Reading || sc.reading; + if (reading) { + const svc = readingsTypeToService(sc.Type || sc.type || '', this.t); + addReading(svc, reading, tag); + } + } + } + } + // Also check calendar entries for Scripture + for (const entry of allEntries) { + if (typeof entry === 'object' && entry !== null) { + const e = entry as Record; + if (e.Scripture && typeof e.Scripture === 'object') { + const s = e.Scripture as Record; + if (s.Reading) { + const svc = readingsTypeToService(s.Type || '', this.t); + addReading(svc, s.Reading, ''); + } + } + } + } + + const readingsPanel = document.getElementById('readings-panel'); + if (readingsPanel) { + if (readingsGroups.length === 0) { + readingsPanel.innerHTML = ` +

${this.t.calendar.readings}

+

${this.t.calendar.noReadings}

+ `; + } else { + const sorted = [...readingsGroups].sort((a, b) => { + const ai = SERVICE_ORDER.indexOf(a.service); + const bi = SERVICE_ORDER.indexOf(b.service); + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); + }); + const groupsHtml = sorted.map(g => { + // Group consecutive items with same purpose; tag only on last of each group + let refs = ''; + for (let i = 0; i < g.items.length; i++) { + const item = g.items[i]; + const next = g.items[i + 1]; + if (i > 0) refs += this.t.calendar.readSep; + refs += item.ref; + if (!next || next.purpose !== item.purpose) { + refs += item.purpose; + } + } + return `
${g.service}${this.t.calendar.colon}${refs}
`; + }).join(''); + readingsPanel.innerHTML = ` +

${this.t.calendar.readings}

+
${groupsHtml}
+ `; + } + } + + // Load services + const svcPanel = document.getElementById('services-panel'); + if (svcPanel) { + svcPanel.innerHTML = ''; + try { + const svcView = new ServiceView(svcPanel, this.currentDate, this.language); + await svcView.render(false); + } catch { + svcPanel.innerHTML = `

${this.t.loading}

`; + } + } + } +} \ No newline at end of file diff --git a/src/ui/service-view.ts b/src/ui/service-view.ts new file mode 100644 index 0000000..821afa7 --- /dev/null +++ b/src/ui/service-view.ts @@ -0,0 +1,712 @@ +/** + * Service view — displays assembled liturgical services. + * Loads XML templates (converted to JSON) and assembles them with + * dynamic Var nodes from Octoecheos tone data. + */ + +import { JDate } from '../core/jdate'; +import { computeDay } from '../core/day-computer'; +import { assembleService, type ServiceContext } from '../core/service-assembler'; +import { evalBool } from '../core/evaluator'; +import type { EvalContext, ServiceNode } from '../core/types'; +import { getTranslations, type LanguageCode } from '../core/i18n'; +import { loadSettings, fontClass } from './settings-view'; + +interface ServiceDef { + id: string; + name: string; + serviceType: string; + template: string; + description: string; + titleNode: ServiceNode; +} + +function title(value: string, header?: string, source?: string, comment?: string): ServiceNode { + return { type: 'TITLE', value, header: header ?? value, source, comment }; +} + +const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +export class ServiceView { + private container: HTMLElement; + private currentDate: JDate; + private evalContext: EvalContext; + private language: LanguageCode; + private t: ReturnType; + private jsonCache = new Map[]>(); + private nodeCache = new Map(); + private notFound = new Set(); + private bibleCache = new Map(); + private livesCache = new Map>>>(); + private commandCache = new Map(); + private timesCache = new Map(); + private activeService: string | null = null; + + constructor(container: HTMLElement, date: JDate, language: LanguageCode = 'en') { + this.container = container; + this.currentDate = date; + this.evalContext = computeDay(date).evalContext; + this.language = language; + this.t = getTranslations(language); + } + + private getFontClass(): string { + const settings = loadSettings(); + return (this.language === 'cu' || this.language === 'ru') && settings.cuFont !== undefined ? fontClass(settings.cuFont) : ''; + } + + private get serviceLang(): string { + return this.language === 'ru' ? 'cu' : this.language; + } + + async render(standalone = true) { + const computed = computeDay(this.currentDate); + const { dayInfo } = computed; + + const availableServices = this.getAvailableServices(dayInfo); + const fontClass = this.getFontClass(); + + const contentClass = `bg-white/50 border border-gold/20 rounded-lg p-6 ${this.activeService ? 'min-h-[300px]' : ''} ${fontClass}`; + + const tabHtml = ` +

${this.t.services.title}

+
+ ${availableServices.map(s => ` + + `).join('')} +
+ +
+

${this.t.services.selectService}

+
+ `; + + this.container.innerHTML = standalone + ? `
+

${this.t.services.title}

+

+ ${this.currentDate.toString()} + ${dayInfo.isLent ? this.t.services.greatLent : ''} + ${dayInfo.isBrightWeek ? this.t.services.brightWeek : ''} + ${dayInfo.isApostlesFast ? this.t.services.apostlesFast : ''} + ${dayInfo.isDormitionFast ? this.t.services.dormitionFast : ''} + ${dayInfo.isNativityFast ? this.t.services.nativityFast : ''} + ${dayInfo.Tone > 0 ? `${this.t.services.tone}${dayInfo.Tone}` : ''} +

+ ${tabHtml} +
` + : tabHtml; + + this.container.querySelectorAll('.service-tab').forEach(btn => { + btn.addEventListener('click', () => { + const serviceId = btn.getAttribute('data-service')!; + const service = availableServices.find(s => s.id === serviceId); + if (service) { + this.activeService = serviceId; + this.container.querySelectorAll('.service-tab').forEach(t => { + const id = t.getAttribute('data-service'); + if (id === serviceId) { + t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-gold text-navy font-bold'; + } else { + t.className = 'service-tab px-4 py-2 text-sm border-b-2 transition-colors whitespace-nowrap border-transparent text-navy-light hover:text-navy'; + } + }); + this.loadService(service); + } + }); + }); + } + + private getAvailableServices(dayInfo: import('../core/types').DayInfo): ServiceDef[] { + const services: ServiceDef[] = []; + + services.push({ + id: 'primes', name: this.t.services.serviceNames.primes, serviceType: 'PRIMES', + template: 'Prime', + description: this.t.services.serviceDescriptions.primes, + titleNode: title('Primes1', this.t.services.serviceNames.primes, 'PrimeSource'), + }); + + services.push({ + id: 'third', name: this.t.services.serviceNames.third, serviceType: 'TERCE', + template: 'ThirdHour', + description: this.t.services.serviceDescriptions.third, + titleNode: title('Third1', this.t.services.serviceNames.third), + }); + + services.push({ + id: 'sixth', name: this.t.services.serviceNames.sixth, serviceType: 'SEXTE', + template: 'SixthHour', + description: this.t.services.serviceDescriptions.sixth, + titleNode: title('Sixth1', this.t.services.serviceNames.sixth), + }); + + services.push({ + id: 'ninth', name: this.t.services.serviceNames.ninth, serviceType: 'NONE', + template: 'NinthHour', + description: this.t.services.serviceDescriptions.ninth, + titleNode: title('Ninth1', this.t.services.serviceNames.ninth), + }); + + if (dayInfo.dRank >= 6) { + services.push({ + id: 'royalhours', name: this.t.services.serviceNames.royalhours, serviceType: 'ROYALHOURS', + template: 'RoyalHours', + description: this.t.services.serviceDescriptions.royalhours, + titleNode: title('RoyalHours1', this.t.services.serviceNames.royalhours), + }); + } + + return services; + } + + private async loadToneData(serviceType: string): Promise<{ troparion1?: string; kontakion1?: string } | null> { + const computed = computeDay(this.currentDate); + const { dayInfo } = computed; + const tone = dayInfo.Tone; + const dow = dayInfo.dow; + const mm = String(dayInfo.month).padStart(2, '0'); + const dd = String(dayInfo.day).padStart(2, '0'); + const dateKey = `${mm}-${dd}`; + + let result: { troparion1?: string; kontakion1?: string } | null = null; + + if (tone > 0 && tone <= 8) { + const toneNum = tone === 8 ? 0 : tone; + const weekday = WEEKDAY_NAMES[dow]; + + try { + const tryFetch = async (lang: string) => { + const url = `/data/${lang}/services/Octoecheos/Tone ${toneNum}/${weekday}.json`; + const resp = await fetch(url); + if (!resp.ok) return null; + const nodes: ServiceNode[] = await resp.json(); + for (const node of nodes) { + if (node.type === serviceType && node.cmd) { + if (evalBool(node.cmd as string, this.evalContext)) { + return { + troparion1: node.troparion1 as string | undefined, + kontakion1: node.kontakion1 as string | undefined, + }; + } + } + } + return null; + }; + + result = await tryFetch(this.serviceLang); + if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu'); + if (!result) result = await tryFetch('shared'); + } catch { /* fall through to menologion */ } + } + + // Menologion fallback when Octoecheos has no valid entry + if (!result) { + try { + const tryLoad = async (url: string): Promise<{ cid: string }[] | null> => { + try { + const resp = await fetch(url); + if (!resp.ok) return null; + const all = await resp.json(); + if (Array.isArray(all)) return all.map((e: Record) => ({ cid: String(e.CId ?? '') })); + const entries = all[dateKey] as Record[] | undefined; + if (Array.isArray(entries)) return entries.map((e: Record) => ({ cid: String(e.CId ?? '') })); + return null; + } catch { return null; } + }; + + const allEntries: { cid: string }[] = []; + + const menaionEntries = await tryLoad(`/data/${this.serviceLang}/menaion-bundle.json`); + if (menaionEntries) allEntries.push(...menaionEntries); + + if (dayInfo.triodionFile !== null) { + const triodion = await tryLoad(`/data/shared/triodion/${dayInfo.triodionFile}.json`); + if (triodion) allEntries.push(...triodion); + } + if (dayInfo.pentecostarionFile !== null) { + const pent = await tryLoad(`/data/shared/pentecostarion/${dayInfo.pentecostarionFile}.json`); + if (pent) allEntries.push(...pent); + } + + const TONE_NUM = ['', 'а҃', 'в҃', 'г҃', 'д҃', 'є҃', 'ѕ҃', 'з҃', 'и҃']; + for (const entry of allEntries) { + if (!entry.cid) continue; + try { + const shmResp = await fetch(`/data/shared/commemorations/${entry.cid}.json`); + if (!shmResp.ok) continue; + const shm = await shmResp.json(); + const troparion = shm.TROPARION as { Tone?: string; text?: string } | undefined; + const kontakion = shm.KONTAKION as { Tone?: string; text?: string } | undefined; + if (troparion?.text || kontakion?.text) { + const key = `MEN_${entry.cid}`; + + if (troparion?.text) { + const tn = TONE_NUM[parseInt(troparion.Tone ?? '1', 10)] ?? troparion.Tone ?? '1'; + this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/TROPARION/${key}.json`, [ + { type: 'HEADER', value: `Тропа́рь, гла́съ ${tn}` }, + { type: 'TEXT', value: troparion.text }, + ]); + } + + if (kontakion?.text) { + const tn = TONE_NUM[parseInt(kontakion.Tone ?? '1', 10)] ?? kontakion.Tone ?? '1'; + this.jsonCache.set(`/data/${this.serviceLang}/services/CommonPrayers/KONTAKION/${key}.json`, [ + { type: 'HEADER', value: `Конда́къ, гла́съ ${tn}` }, + { type: 'TEXT', value: kontakion.text }, + ]); + } + + result = { troparion1: key, kontakion1: key }; + break; + } + } catch { continue; } + } + } catch { /* fall through to tertiary fallback */ } + } + + // Tertiary fallback: known feasts not yet in shared commemorations + if (!result) { + const MENAION_TROP: Record = { + '03-23': { troparion1: 'PROPHET', kontakion1: 'PROPHET' }, + '03-22': { troparion1: 'PROPHET', kontakion1: 'PROPHET' }, + }; + const m = MENAION_TROP[dateKey]; + if (m) result = m; + } + + return result; + } + + private generateVarNodes(toneData: { troparion1?: string; kontakion1?: string } | null, serviceType: string): Map { + const varNodes = new Map(); + + const prefix: Record = { PRIMES: '', TERCE: '3', SEXTE: '6', NONE: '9' }; + const p = prefix[serviceType] ?? ''; + + if (toneData?.troparion1) { + const key1 = `PTrop${p}1`; + varNodes.set(key1, [{ + type: 'CREATE', + what: `TROPARION/${toneData.troparion1}`, + who: '', + header: true, + redfirst: true, + newline: true, + }]); + } + + if (toneData?.kontakion1) { + const key = p ? `PKont${p}` : 'PKont1'; + varNodes.set(key, [{ + type: 'CREATE', + what: `KONTAKION/${toneData.kontakion1}`, + who: '', + header: true, + redfirst: true, + newline: true, + }]); + } + + return varNodes; + } + + private async loadService(service: ServiceDef) { + const contentEl = document.getElementById('service-content'); + if (!contentEl) return; + + contentEl.innerHTML = `

${this.t.loading}

`; + + try { + const templateNodes = await this.fetchServiceNodes(`Services/${service.template}.json`); + if (!templateNodes) { + contentEl.innerHTML = `

Service template not found

`; + return; + } + + const toneData = await this.loadToneData(service.serviceType); + const varNodes = this.generateVarNodes(toneData, service.serviceType); + + const nodes: ServiceNode[] = [...templateNodes]; + + const computed = computeDay(this.currentDate); + const isMenologionTone = toneData?.troparion1?.startsWith('PROPHET') || toneData?.troparion1?.startsWith('MEN_'); + const dayPFlag2 = computed.dayInfo.PFlag2; + const evalCtx: EvalContext = { + ...this.evalContext, + PFlag1: 0, + PFlag2: isMenologionTone && dayPFlag2 !== 0 ? 0 : dayPFlag2, + PFlag3: 0, + PS: 1, + }; + + const ctx: ServiceContext = { + evalCtx, + lang: this.serviceLang, + t: this.t, + fetchBibleText: (book, passage, showVerseNumbers, verseNewLine) => this.fetchBibleText(book, passage, showVerseNumbers, verseNewLine), + fetchLives: (id) => this.fetchLives(id), + fetchCommandText: (name) => this.fetchCommandText(name), + resolveTimes: (times) => this.resolveTimes(times), + fetchText: this.createFetchText(), + fetchServiceNodes: async (path: string): Promise => { + const varMatch = path.match(/Services\/Var\/(\w+)\.xml$/); + if (varMatch) { + return varNodes.get(varMatch[1]) || null; + } + return this.fetchServiceNodes(path); + }, + }; + + const { html } = await assembleService(nodes, ctx); + + if (html.trim()) { + const fontClass = this.getFontClass(); + contentEl.className = `prose prose-sm max-w-none liturgical-text ${fontClass}`; + contentEl.innerHTML = html; + } else { + contentEl.className = 'prose prose-sm max-w-none liturgical-text'; + contentEl.innerHTML = ` +
+

${this.t.services.serviceProducedNoOutput}

+

${nodes.length} ${this.t.services.nodesAssembled}

+
+ `; + } + } catch (err) { + contentEl.innerHTML = ` +
+

${this.t.services.errorLoading}

+

${err instanceof Error ? err.message : this.t.error}

+
+ `; + } + } + + private bibleVersion(lang: string): string { + const map: Record = { + en: 'kjv', + cu: 'elis', + fr: 'ls', + el: 'spt', + la: 'vulgate', + zh: 'cuv', + ar: 'svd', + }; + return map[lang] || 'kjv'; + } + + private async fetchBibleText(book: string, passage: string, showVerseNumbers = true, verseNewLine = false): Promise { + const cacheKey = `${this.language}/${book}/${passage}/${showVerseNumbers ? 'vn' : 'nn'}/${verseNewLine ? 'vb' : 'nvb'}`; + const cached = this.bibleCache.get(cacheKey); + if (cached) return cached; + + const version = this.bibleVersion(this.serviceLang); + const tryFetch = async (lang: string): Promise => { + const url = `/data/${lang}/bible/${version}/${book}.text`; + if (this.notFound.has(url)) return null; + const resp = await fetch(url); + if (!resp.ok) { this.notFound.add(url); return null; } + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; } + return resp.text(); + }; + + let text = await tryFetch(this.serviceLang); + if (!text && this.serviceLang !== 'cu') text = await tryFetch('cu'); + if (!text) text = await tryFetch('shared'); + if (!text) return ''; + + const lines = text.split('\n'); + // Parse comma-separated passage parts, each potentially: Chapter[:VerseStart[-VerseEnd]] + const parts = passage.split(','); + const ranges: { chapter: number; vStart: number; vEnd: number }[] = []; + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + let chapter: number; + let verseSpec: string; + + if (trimmed.includes(':')) { + const colonIdx = trimmed.indexOf(':'); + chapter = parseInt(trimmed.substring(0, colonIdx), 10); + verseSpec = trimmed.substring(colonIdx + 1); + } else { + // Bare number = chapter number (e.g. "50" = Psalm 50, read all verses) + chapter = parseInt(trimmed, 10); + verseSpec = '1-'; + } + + let vStart: number; + let vEnd: number; + + if (verseSpec.includes('-')) { + const dashIdx = verseSpec.indexOf('-'); + vStart = parseInt(verseSpec.substring(0, dashIdx), 10); + const after = verseSpec.substring(dashIdx + 1); + // Check if after contains chapter:verse (cross-chapter range) + if (after.includes(':')) { + const afterColon = after.indexOf(':'); + const nextChapter = parseInt(after.substring(0, afterColon), 10); + vEnd = parseInt(after.substring(afterColon + 1), 10); + ranges.push({ chapter, vStart, vEnd: -1 }); // all remaining verses in current chapter + chapter = nextChapter; + vStart = 1; + vEnd = vEnd; + } else { + vEnd = after ? parseInt(after, 10) : -1; // empty end = all remaining verses + } + } else { + vStart = parseInt(verseSpec, 10); + vEnd = vStart; + } + + ranges.push({ chapter, vStart, vEnd }); + } + + // Scan file and collect matching verses + let result = ''; + let curChapter = 0; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const chapterMatch = trimmed.match(/^#(\d+)/); + if (chapterMatch) { + curChapter = parseInt(chapterMatch[1], 10); + continue; + } + + const verseMatch = trimmed.match(/^(\d+)\|(.*)/); + if (verseMatch) { + const curVerse = parseInt(verseMatch[1], 10); + const verseText = verseNewLine ? verseMatch[2].replace(/^ /, '') : verseMatch[2]; + + for (const range of ranges) { + if (range.chapter !== curChapter) continue; + if (curVerse < range.vStart) continue; + if (range.vEnd !== -1 && curVerse > range.vEnd) continue; + if (result) result += verseNewLine ? '
' : ' '; + result += showVerseNumbers ? `${curVerse}${verseText}` : verseText; + break; + } + } else if (trimmed.startsWith('*')) { + if (result) result += ' '; + result += trimmed; + } + } + + this.bibleCache.set(cacheKey, result); + return result; + } + + private async fetchLives(id: string): Promise>> | null> { + const cacheKey = `${this.language}/${id}`; + const cached = this.livesCache.get(cacheKey); + if (cached) return cached; + + const bundleName = /^(0[1-9]|1[0-2])\d+/.test(id) ? id.substring(0, 2) : 'misc'; + const tryFetch = async (lang: string): Promise>> | null> => { + const url = `/data/${lang}/lives/${bundleName}.json`; + if (this.notFound.has(url)) return null; + const resp = await fetch(url); + if (!resp.ok) { this.notFound.add(url); return null; } + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; } + const bundle = await resp.json() as Record>; + const entry = bundle[id] as { services?: Record>> } | undefined; + return entry?.services || null; + }; + + let result = await tryFetch(this.serviceLang); + if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu'); + if (!result) result = await tryFetch('shared'); + if (result) this.livesCache.set(cacheKey, result); + return result; + } + + private async fetchCommandText(name: string): Promise { + const cacheKey = `${this.language}/Command/${name}`; + const cached = this.commandCache.get(cacheKey); + if (cached) return cached; + + const tryFetch = async (lang: string): Promise => { + const url = `/data/${lang}/services/Command/${name}.json`; + if (this.notFound.has(url)) return null; + const resp = await fetch(url); + if (!resp.ok) { this.notFound.add(url); return null; } + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; } + const json = await resp.json() as Record[]; + return json + .filter(n => n.type === 'TEXT') + .map(n => String(n.value ?? '')) + .filter(Boolean) + .join(' ') || null; + }; + + let result = await tryFetch(this.serviceLang); + if (!result && this.serviceLang !== 'cu') result = await tryFetch('cu'); + if (!result) result = await tryFetch('shared'); + if (result) this.commandCache.set(cacheKey, result); + return result || ''; + } + + private async resolveTimes(times: number): Promise { + const cacheKey = this.language; + let rules = this.timesCache.get(cacheKey); + if (!rules) { + const tryFetch = async (lang: string): Promise<{ cmd: string; value: string }[] | null> => { + const url = `/data/${lang}/commands/Times.json`; + if (this.notFound.has(url)) return null; + const resp = await fetch(url); + if (!resp.ok) { this.notFound.add(url); return null; } + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; } + const json = await resp.json() as { DATA: { TIMES: { '@_Value': string; '@_Cmd': string }[] } }; + const arr = json?.DATA?.TIMES; + if (!Array.isArray(arr)) return null; + return arr.map(r => ({ value: r['@_Value'], cmd: r['@_Cmd'] })); + }; + let loaded = await tryFetch(this.serviceLang); + if (!loaded && this.serviceLang !== 'cu') loaded = await tryFetch('cu'); + if (!loaded) loaded = await tryFetch('shared'); + if (loaded) { + this.timesCache.set(cacheKey, loaded); + rules = loaded; + } + } + + if (!rules) return `${times}×`; + + for (const rule of rules) { + if (rule.cmd) { + if (evalBool(rule.cmd, { Times: times } as EvalContext)) { + return rule.value.replace('^#', String(times)); + } + } else { + return rule.value.replace('^#', String(times)); + } + } + return `${times}×`; + } + + private createFetchText() { + return async (path: string): Promise => { + const wantsHeader = path.endsWith('.header'); + const basePath = wantsHeader ? path.slice(0, -'.header'.length) : path; + + const resolvePath = (lang: string): string | null => { + let fetchPath = basePath; + if (fetchPath.startsWith('Services/')) { + fetchPath = fetchPath.replace('Services/', '').replace('.xml', '.json'); + } + return `/data/${lang}/services/${fetchPath}`; + }; + + const fetchJson = async (url: string): Promise[] | null> => { + if (this.notFound.has(url)) return null; + const cached = this.jsonCache.get(url); + if (cached) return cached; + try { + const resp = await fetch(url); + if (!resp.ok) { this.notFound.add(url); return null; } + const ct = resp.headers.get('content-type'); + if (ct && ct.includes('text/html')) { this.notFound.add(url); return null; } + const json = JSON.parse(await resp.text()); + if (Array.isArray(json)) { + this.jsonCache.set(url, json); + return json; + } + this.notFound.add(url); + return null; + } catch { + this.notFound.add(url); + return null; + } + }; + + const filterNodes = (json: Record[], header: boolean): string | null => { + if (header) { + return json + .filter(n => n.type === 'HEADER') + .map(n => String(n.value ?? '')) + .filter(Boolean) + .join(' ') || null; + } + return json + .filter(n => n.type === 'TEXT') + .map(n => String(n.value ?? '').replace(/\n/g, ' ')) + .filter(Boolean) + .join('
') || null; + }; + + try { + let url = resolvePath(this.serviceLang); + let json = url ? await fetchJson(url) : null; + if (!json && this.serviceLang !== 'cu') { + url = resolvePath('cu'); + json = url ? await fetchJson(url) : null; + } + if (!json) { + url = resolvePath('shared'); + json = url ? await fetchJson(url) : null; + } + return json ? filterNodes(json, wantsHeader) : null; + } catch (err) { + console.error('Service fetchText error:', err); + return null; + } + }; + } + + private async fetchServiceNodes(path: string): Promise { + const resolvePath = (lang: string): string | null => { + let fetchPath = path; + if (fetchPath.startsWith('Services/')) { + fetchPath = fetchPath.replace('Services/', '').replace('.xml', '.json'); + } + return `/data/${lang}/services/${fetchPath}`; + }; + const tryFetch = async (url: string): Promise => { + if (this.notFound.has(url)) return null; + const cached = this.nodeCache.get(url); + if (cached) return cached; + try { + const resp = await fetch(url); + if (!resp.ok) { this.notFound.add(url); return null; } + const ct = resp.headers.get('content-type') || ''; + if (ct.includes('text/html')) { this.notFound.add(url); return null; } + const nodes = await resp.json(); + this.nodeCache.set(url, nodes); + return nodes; + } catch { this.notFound.add(url); return null; } + }; + let url = resolvePath(this.serviceLang); + if (url) { + const result = await tryFetch(url); + if (result) return result; + } + if (this.serviceLang !== 'cu') { + const fbUrl = resolvePath('cu'); + if (fbUrl) { + const result = await tryFetch(fbUrl); + if (result) return result; + } + } + const sharedUrl = resolvePath('shared'); + if (sharedUrl) { + return await tryFetch(sharedUrl); + } + return null; + } +} diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts new file mode 100644 index 0000000..0f5e4d0 --- /dev/null +++ b/src/ui/settings-view.ts @@ -0,0 +1,307 @@ +/** + * Settings view — language, font, and display preferences. + */ + +import { getTranslations, type LanguageCode } from '../core/i18n'; + +const LANGUAGES = [ + { code: 'en', name: 'English', local: 'English' }, + { code: 'ru', name: 'Russian', local: 'Русский' }, + { code: 'cu', name: 'Church Slavonic', local: 'Церковнославянский' }, +]; + +const CU_FONTS = [ + { id: '', name: 'System Default' }, + { id: 'Ponomar', name: 'Ponomar' }, + { id: 'Fedorovsk', name: 'Fedorovsk' }, + { id: 'Menaion', name: 'Menaion' }, + { id: 'Monomakh', name: 'Monomakh' }, + { id: 'Triodion', name: 'Triodion' }, + { id: 'Vilnius', name: 'Vilnius' }, + { id: 'Voskresensky', name: 'Voskresensky' }, +]; + +const SETTINGS_KEY = 'ponomar-settings'; + +interface Settings { + language: string; + cuFont: string; + fontSize: number; + defaultBibleVersion: string; + showVerseNumbers: boolean; + verseNewLine: boolean; + calendarType: 'julian' | 'gregorian'; + serviceRole: 'priest' | 'reader' | 'auto'; +} + +const DEFAULT_SETTINGS: Settings = { + language: 'en', + cuFont: 'Ponomar', + fontSize: 16, + defaultBibleVersion: 'kjv', + showVerseNumbers: true, + verseNewLine: false, + calendarType: 'julian', + serviceRole: 'priest', +}; + +function detectBrowserLanguage(): LanguageCode { + const lang = navigator.language || (navigator as any).userLanguage || ''; + if (lang.startsWith('ru')) return 'ru'; + if (lang.startsWith('cu')) return 'cu'; + return 'en'; +} + +function loadSettings(): Settings { + try { + const stored = localStorage.getItem(SETTINGS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + // Migrate old usePonomarFont to cuFont + if (parsed.usePonomarFont !== undefined) { + parsed.cuFont = parsed.usePonomarFont ? 'Ponomar' : ''; + delete parsed.usePonomarFont; + } + return { ...DEFAULT_SETTINGS, ...parsed }; + } + } catch { /* ignore */ } + return { ...DEFAULT_SETTINGS, language: detectBrowserLanguage() }; +} + +function saveSettings(settings: Settings) { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +function fontClass(fontName: string): string { + if (!fontName) return 'font-system-slavonic'; + const lower = fontName.toLowerCase(); + const map: Record = { + ponomar: 'font-ponomar', + fedorovsk: 'font-fedrovsk', + indiction: 'font-indiction', + menaion: 'font-menaion', + monomakh: 'font-monomakh', + triodion: 'font-triodion', + vilnius: 'font-vilnius', + voskresensky: 'font-voskresensky', + }; + return map[lower] || 'font-slavonic'; +} + +export { loadSettings, saveSettings, fontClass }; +export type { Settings }; + +export class SettingsView { + private container: HTMLElement; + private settings: Settings; + private onLanguageChange?: () => void; + + constructor(container: HTMLElement, onLanguageChange?: () => void) { + this.container = container; + this.settings = loadSettings(); + this.onLanguageChange = onLanguageChange; + } + + render() { + const t = getTranslations(this.settings.language as LanguageCode); + + this.container.innerHTML = ` +
+

${t.settings.title}

+ + +
+ + +
+ + +
+

${t.settings.fontSettings}

+ +
+ +
+ +
+

${t.settings.fontPreview}

+
+ Сохрани́ мѧ, гдⷭ҇и, ꙗ҆́кѡ на тѧ̀ ᲂу҆пова́хъ +
+
+
+ + +
+ + +
+ + +
+

${t.settings.calendarType}

+
+ + +
+
+ + +
+

${t.settings.bibleSettings}

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+

${t.settings.about}

+

+ ${t.settings.aboutText} +

+

+ + ${t.settings.aboutLicense} + +

+
+
+ `; + + // Event listeners + document.getElementById('language')?.addEventListener('change', (e) => { + this.settings.language = (e.target as HTMLSelectElement).value; + saveSettings(this.settings); + this.onLanguageChange?.(); + }); + + document.getElementById('cu-font')?.addEventListener('change', (e) => { + this.settings.cuFont = (e.target as HTMLSelectElement).value; + saveSettings(this.settings); + this.updateFontPreview(); + }); + + document.getElementById('font-size')?.addEventListener('input', (e) => { + this.settings.fontSize = parseInt((e.target as HTMLInputElement).value, 10); + document.getElementById('font-size-value')!.textContent = `${this.settings.fontSize}px`; + document.documentElement.style.setProperty('--liturgical-font-size', this.settings.fontSize + 'px'); + saveSettings(this.settings); + }); + + document.getElementById('default-bible')?.addEventListener('change', (e) => { + this.settings.defaultBibleVersion = (e.target as HTMLSelectElement).value; + saveSettings(this.settings); + }); + + document.getElementById('show-verses')?.addEventListener('change', (e) => { + this.settings.showVerseNumbers = (e.target as HTMLInputElement).checked; + saveSettings(this.settings); + }); + + document.getElementById('verse-newline')?.addEventListener('change', (e) => { + this.settings.verseNewLine = (e.target as HTMLInputElement).checked; + saveSettings(this.settings); + }); + + document.querySelectorAll('input[name="calendar-type"]').forEach(el => { + el.addEventListener('change', (e) => { + this.settings.calendarType = (e.target as HTMLInputElement).value as 'julian' | 'gregorian'; + saveSettings(this.settings); + }); + }); + document.getElementById('install-pwa')?.addEventListener('click', async () => { + const prompt = (window as any).__deferredPrompt; + if (prompt) { + prompt.prompt(); + await prompt.userChoice; + (window as any).__deferredPrompt = null; + document.getElementById('install-pwa')?.remove(); + } + }); + this.updateFontPreview(); + } + + private updateFontPreview() { + const preview = document.getElementById('font-preview'); + if (preview) { + preview.className = `${fontClass(this.settings.cuFont)} text-lg`; + } + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/static/OrthodoxCross.svg b/static/OrthodoxCross.svg new file mode 100644 index 0000000..087c9c9 --- /dev/null +++ b/static/OrthodoxCross.svg @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/static/fonts/Fedorovsk-Regular.woff b/static/fonts/Fedorovsk-Regular.woff new file mode 100644 index 0000000..28d1336 Binary files /dev/null and b/static/fonts/Fedorovsk-Regular.woff differ diff --git a/static/fonts/Indiction-Regular.woff b/static/fonts/Indiction-Regular.woff new file mode 100644 index 0000000..e2a4143 Binary files /dev/null and b/static/fonts/Indiction-Regular.woff differ diff --git a/static/fonts/Menaion-Regular.woff b/static/fonts/Menaion-Regular.woff new file mode 100644 index 0000000..4365f75 Binary files /dev/null and b/static/fonts/Menaion-Regular.woff differ diff --git a/static/fonts/Monomakh-Regular.woff b/static/fonts/Monomakh-Regular.woff new file mode 100644 index 0000000..c79c2da Binary files /dev/null and b/static/fonts/Monomakh-Regular.woff differ diff --git a/static/fonts/Ponomar-Regular.woff b/static/fonts/Ponomar-Regular.woff new file mode 100644 index 0000000..f4f1289 Binary files /dev/null and b/static/fonts/Ponomar-Regular.woff differ diff --git a/static/fonts/Triodion-Regular.woff b/static/fonts/Triodion-Regular.woff new file mode 100644 index 0000000..c68b85b Binary files /dev/null and b/static/fonts/Triodion-Regular.woff differ diff --git a/static/fonts/Vilnius-Regular.woff b/static/fonts/Vilnius-Regular.woff new file mode 100644 index 0000000..a411144 Binary files /dev/null and b/static/fonts/Vilnius-Regular.woff differ diff --git a/static/fonts/Voskresensky-Regular.woff b/static/fonts/Voskresensky-Regular.woff new file mode 100644 index 0000000..fd3547c Binary files /dev/null and b/static/fonts/Voskresensky-Regular.woff differ diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..ee84491 Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..7b46ecd Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..43c297b --- /dev/null +++ b/static/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "Ponomar — Orthodox Calendar", + "short_name": "Ponomar", + "description": "Orthodox Church calendar and liturgical information", + "start_url": "/", + "display": "standalone", + "background_color": "#f5f0e8", + "theme_color": "#1a1a2e", + "orientation": "portrait", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..ec214b9 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,16 @@ +const CACHE = 'ponomar-v1'; + +self.addEventListener('install', (e) => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/index.html']))); + self.skipWaiting(); +}); + +self.addEventListener('activate', (e) => { + e.waitUntil(clients.claim()); +}); + +self.addEventListener('fetch', (e) => { + e.respondWith( + caches.match(e.request).then(r => r || fetch(e.request)) + ); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..021c290 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": ["src/**/*.ts", "src/**/*.css"], + "exclude": ["node_modules", "dist", "scripts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b43d6fc --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; +import { resolve } from 'path'; + +export default defineConfig({ + plugins: [tailwindcss()], + server: { + port: 5173, + strictPort: false + }, + build: { + outDir: 'dist', + sourcemap: true + }, + publicDir: 'static' +});