diff --git a/README.md b/README.md index 791d047..6d57380 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,18 @@ http://localhost:5173. Dev mode is much slower, so don't use it for real users. --- +## Optional keys (set in `.env` or the environment) + +| Variable | What it turns on | +| --- | --- | +| `SESSION_SECRET` | **Required in production.** A long random string; the server refuses to start without it. | +| `ADMIN_EMAIL` | The one admin account. | +| `ESV_API_KEY`, `API_BIBLE_KEY` | ESV, NLT, CSB and NASB in the Reader. | +| `IQBIBLE_API_KEY` | IQ Bible: more commentaries, lexicons (BDB, LSJ, Abbott-Smith), places, topics, people, textual notes, Gospel parallels, daily verse and Spurgeon devotional, plan calendar export, more audio narrations, and background for podcast prompts. Hidden when unset. | +| `IQBIBLE_CACHE_HOURS` | Leave unset unless IQ Bible has given written permission to cache non-verse data; only verse/chapter text is cached by default (24h), as their terms require. | +| `BACKUP_KEEP`, `BACKUP_DISABLED=1` | The server saves a snapshot of the whole database once a day to `DATA_DIR/backups` (default: keep 7). Restore with `scripts/restore-data.sh`. | +| `ALLOW_PRIVATE_NTFY=1` | Lets reading reminders use an ntfy server on your own network (blocked by default). | + ## Production Install (Ubuntu / systemd) Installs the app as a persistent systemd service under `/opt/study-app`. @@ -134,3 +146,13 @@ See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.s ## License Private / personal use. + + +## Development + +```bash +npm run test:run # all tests +npm run lint # ESLint (errors fail; warnings are advice) +``` + +Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c452fd7 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; + +export default [ + { ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'server/data/**', 'public/**'] }, + js.configs.recommended, + { + files: ['src/**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { ...globals.browser }, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { 'react-hooks': reactHooks }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'no-unused-vars': ['warn', { varsIgnorePattern: '^_|^[A-Z]', argsIgnorePattern: '^_', caughtErrors: 'none' }], + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, + { + files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'], + languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } }, + rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] }, + }, + { + files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'], + languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 71beb73..0febdb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -31,6 +32,10 @@ "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.4.19", "csv-parse": "^6.2.1", + "eslint": "^10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", "jsdom": "^29.1.1", "postcss": "^8.4.35", "tailwindcss": "^3.4.4", @@ -423,6 +428,30 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -988,6 +1017,134 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -1006,6 +1163,72 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1056,6 +1279,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -2023,6 +2270,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2030,6 +2284,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -2206,6 +2467,46 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2348,6 +2649,16 @@ "postcss": "^8.1.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2506,6 +2817,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2586,6 +2910,20 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2972,6 +3310,21 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -3101,6 +3454,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3381,6 +3741,259 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3391,6 +4004,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3558,6 +4181,13 @@ ], "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3588,6 +4218,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3598,6 +4242,16 @@ "reusify": "^1.0.4" } }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3663,6 +4317,25 @@ "node": ">=8" } }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3800,6 +4473,19 @@ "node": ">=10.13.0" } }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3843,6 +4529,19 @@ "minimalistic-assert": "^1.0.1" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -3855,6 +4554,30 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3927,12 +4650,32 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -4048,6 +4791,13 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -4168,6 +4918,20 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4193,6 +4957,30 @@ "setimmediate": "^1.0.5" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -4748,6 +5536,22 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", "license": "ISC" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -4806,6 +5610,13 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -4938,6 +5749,24 @@ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "license": "BSD-2-Clause" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/otplib": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", @@ -5031,6 +5860,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -5296,6 +6135,16 @@ "node": ">=10" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -5351,6 +6200,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -5923,6 +6792,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.8.4", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", @@ -6503,6 +7395,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -6590,6 +7495,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6930,6 +7845,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -6953,6 +7884,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7077,6 +8018,42 @@ "engines": { "node": ">=12" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/package.json b/package.json index 32f8e13..a172154 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "test:run": "vitest run", "coverage": "vitest run --coverage", "backup": "bash scripts/backup-data.sh", - "restore": "bash scripts/restore-data.sh" + "restore": "bash scripts/restore-data.sh", + "lint": "eslint ." }, "dependencies": { "bcryptjs": "^3.0.3", @@ -33,6 +34,7 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -40,6 +42,10 @@ "@vitest/coverage-v8": "^4.1.7", "autoprefixer": "^10.4.19", "csv-parse": "^6.2.1", + "eslint": "^10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.13.0", "jsdom": "^29.1.1", "postcss": "^8.4.35", "tailwindcss": "^3.4.4", diff --git a/server/accountExport.js b/server/accountExport.js new file mode 100644 index 0000000..0b6a6ec --- /dev/null +++ b/server/accountExport.js @@ -0,0 +1,32 @@ +// Everything one account holds on the server, in the same file format the app's own "Download backup" +// makes, so it can be restored from Settings on any device. +export const BACKUP_APP = 'bible-study-project'; +export const BACKUP_VERSION = 1; + +// `store` supplies the account's data: { projects(), prefs(), marks(), ink(), plans() }. +export function buildAccountExport(user, store, now = new Date()) { + const prefs = store.prefs() ?? {}; + const marks = store.marks() ?? {}; + const plans = store.plans() ?? {}; + return { + app: BACKUP_APP, + version: BACKUP_VERSION, + exportedAt: now.toISOString(), + account: user.email, + source: 'server', + projects: store.projects(), + reader: { + bookmarks: marks.bookmarks ?? {}, + highlights: marks.highlights ?? [], + colorLabels: prefs.colorLabels?.v ?? {}, + ink: store.ink() ?? {}, + }, + readingPlans: { + progress: plans.progress ?? [], + customPlans: plans.customPlans ?? [], + activity: plans.activity ?? [], + }, + }; +} + +export const accountExportName = (now = new Date()) => `bible-study-account-${now.toISOString().slice(0, 10)}.json`; diff --git a/server/accountExport.test.js b/server/accountExport.test.js new file mode 100644 index 0000000..c67ff50 --- /dev/null +++ b/server/accountExport.test.js @@ -0,0 +1,33 @@ +// @vitest-environment node +import { test, expect } from 'vitest'; +import { buildAccountExport, accountExportName, BACKUP_APP } from './accountExport.js'; +import { listSnapshots, pruneSnapshots } from './backups.js'; +import { mkdtempSync, writeFileSync, readdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +test('the account export has the same shape as the in-app backup', () => { + const out = buildAccountExport({ email: 'a@b.co' }, { + projects: () => [{ id: 'p', title: 'T', chapters: [] }], + prefs: () => ({ colorLabels: { v: { '#fef08a': 'Promises' }, at: 1 } }), + marks: () => ({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [{ id: 'h' }] }), + ink: () => ({ JHN_3: [[1, 2]] }), + plans: () => ({ progress: [{ id: 'x' }] }), + }, new Date('2026-10-06T10:00:00Z')); + expect(out.app).toBe(BACKUP_APP); + expect(out.account).toBe('a@b.co'); + expect(out.reader.colorLabels).toEqual({ '#fef08a': 'Promises' }); + expect(out.reader.bookmarks['JHN-3-16']).toBe('#fff'); + expect(out.readingPlans).toEqual({ progress: [{ id: 'x' }], customPlans: [], activity: [] }); + expect(accountExportName(new Date('2026-10-06T10:00:00Z'))).toBe('bible-study-account-2026-10-06.json'); +}); + +test('only the newest snapshots are kept, and always at least one', () => { + const dir = mkdtempSync(join(tmpdir(), 'snap-')); + ['2026-10-01-03-00', '2026-10-02-03-00', '2026-10-03-03-00', '2026-10-04-03-00'].forEach((d) => writeFileSync(join(dir, `projects-${d}.db`), 'x')); + writeFileSync(join(dir, 'notes.txt'), 'not a snapshot'); + expect(listSnapshots(dir).map((s) => s.name)[0]).toBe('projects-2026-10-04-03-00.db'); + expect(pruneSnapshots(2, dir)).toEqual(['projects-2026-10-02-03-00.db', 'projects-2026-10-01-03-00.db']); + expect(pruneSnapshots(0, dir)).toHaveLength(1); // keep at least the newest + expect(readdirSync(dir).sort()).toEqual(['notes.txt', 'projects-2026-10-04-03-00.db']); +}); diff --git a/server/auth.js b/server/auth.js index a08ba98..1e759fb 100644 --- a/server/auth.js +++ b/server/auth.js @@ -92,3 +92,10 @@ export async function consumeBackupCode(submitted, hashes) { export function generateTemporaryPassword() { return randomBytes(6).toString('hex'); } + +// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L). +const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +export function generateResetCode() { + return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join(''); +} +export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, ''); diff --git a/server/backups.js b/server/backups.js new file mode 100644 index 0000000..2b57976 --- /dev/null +++ b/server/backups.js @@ -0,0 +1,45 @@ +// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups. +// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with +// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off. +import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs'; +import { join } from 'path'; +import { backupDatabase, dataDir } from './db.js'; + +const DAY = 24 * 60 * 60 * 1000; +export const backupDir = () => join(dataDir(), 'backups'); +const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16); +const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name); + +export function listSnapshots(dir = backupDir()) { + if (!existsSync(dir)) return []; + return readdirSync(dir).filter(isSnapshot).sort().reverse() + .map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs })); +} + +// Deletes all but the newest `keep` snapshots; returns the names removed. +export function pruneSnapshots(keep = 7, dir = backupDir()) { + const old = listSnapshots(dir).slice(Math.max(1, keep)); + old.forEach((s) => unlinkSync(join(dir, s.name))); + return old.map((s) => s.name); +} + +export async function runBackup(now = new Date()) { + const dir = backupDir(); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `projects-${stamp(now)}.db`); + await backupDatabase(file); + pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir); + return file; +} + +// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up). +export function startBackupScheduler() { + if (process.env.BACKUP_DISABLED === '1') return; + const tick = () => { + const latest = listSnapshots()[0]; + if (latest && Date.now() - latest.at < DAY) return; + runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message)); + }; + setTimeout(tick, 30_000).unref(); + setInterval(tick, 60 * 60 * 1000).unref(); +} diff --git a/server/bibleApi.js b/server/bibleApi.js index d972fae..e62e47f 100644 --- a/server/bibleApi.js +++ b/server/bibleApi.js @@ -53,7 +53,7 @@ const cache = new Map(); // "NLT/PSA/23" -> { at, payload } // breaks, and stray "#" / nbsp characters. Split it back into { number, text }. export function parseChapterText(content) { const clean = String(content ?? '') - .replace(/ /g, ' ') + .replace(/\u00a0/g, ' ') .replace(/#/g, ''); const parts = clean.split(/\[(\d+)\]/); const verses = []; diff --git a/server/db.js b/server/db.js index 4985e65..61226ed 100644 --- a/server/db.js +++ b/server/db.js @@ -9,6 +9,13 @@ const DB_PATH = join(DATA_DIR, 'projects.db'); let db; +export const dataDir = () => DATA_DIR; + +/** A consistent copy of the whole database, made while it is in use. */ +export function backupDatabase(destPath) { + return db.backup(destPath); +} + // --------------------------------------------------------------------------- // Init — create tables if they don't exist, migrate older schemas // --------------------------------------------------------------------------- @@ -89,6 +96,16 @@ export function initDb() { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL'); + // One-time codes for "forgot password": hashed, short-lived, limited tries. + db.exec(` + CREATE TABLE IF NOT EXISTS password_resets ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + expires INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0 + ); + `); + // Reader ink — one row per user + book + chapter, stored as JSON db.exec(` CREATE TABLE IF NOT EXISTS reader_ink ( @@ -232,6 +249,30 @@ export function setLastReminderDate(userId, dateString) { db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId); } +// --------------------------------------------------------------------------- +// Password reset codes +// --------------------------------------------------------------------------- + +export function setPasswordReset(userId, codeHash, expires) { + db.prepare(` + INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0) + ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0 + `).run(userId, codeHash, expires); +} + +export function getPasswordReset(userId) { + const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId); + return row && row.expires > Date.now() ? row : null; +} + +export function bumpPasswordResetAttempts(userId) { + db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId); +} + +export function clearPasswordReset(userId) { + db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId); +} + /** Rewrites the remaining backup-code hashes after one is used (single-use codes). */ export function setBackupCodeHashes(userId, backupCodeHashes) { db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); @@ -373,7 +414,9 @@ export function upsertProject(project, userId) { return null; } - const lastEdited = project.lastEdited ?? Date.now(); + // The server's clock decides the version, so a device with a wrong clock can't hide or fake changes. + const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id); + const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1); const chapterSummary = buildSummary(project); const updated = { ...project, lastEdited }; @@ -468,7 +511,11 @@ export function adminGetAllUsers() { /** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */ export function adminDeleteUser(userId) { - db.prepare('DELETE FROM users WHERE id = ?').run(userId); + // projects.user_id is a foreign key, so release the projects first (they stay as unowned rows). + db.transaction(() => { + db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId); + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + })(); } /** Every project across every user, with the owner's email, for the admin projects list. */ diff --git a/server/index.js b/server/index.js index 8b65269..2614353 100644 --- a/server/index.js +++ b/server/index.js @@ -16,15 +16,20 @@ import { getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, getReadingProgress, setReadingProgress, setReminderSettings, + setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail, generateTotpSecret, totpKeyUri, verifyTotpToken, generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, + generateResetCode, normalizeResetCode, } from './auth.js'; -import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js'; +import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js'; import { registerBibleRoutes } from './bibleApi.js'; +import { registerIqBibleRoutes } from './iqbible.js'; +import { buildAccountExport, accountExportName } from './accountExport.js'; +import { startBackupScheduler } from './backups.js'; import { cleanMarks } from './readerMarks.js'; import { cleanPrefsPatch, mergePrefs } from './prefs.js'; import { isSaveConflict, parseBase } from './projectSync.js'; @@ -46,26 +51,46 @@ const PORT = process.env.PORT || 3001; const isProd = process.env.NODE_ENV === 'production'; if (isProd && !process.env.SESSION_SECRET) { - console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.'); + console.error('SESSION_SECRET is not set. Set it to a long random string before running in production.'); + process.exit(1); } // Trust the reverse proxy (needed for secure cookies to work behind nginx/etc). app.set('trust proxy', 1); -// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP. -const _authAttempts = new Map(); +// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per +// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out. +const _failures = new Map(); +const FAIL_WINDOW = 15 * 60 * 1000; +const FAIL_MAX = 10; setInterval(() => { const now = Date.now(); - for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip); -}, 60 * 60 * 1000); -function checkAuthRateLimit(ip) { + for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k); +}, 60 * 60 * 1000).unref(); +function isLimited(...keys) { const now = Date.now(); - const window = 15 * 60 * 1000; - const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window }; - if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; } - e.count += 1; - _authAttempts.set(ip, e); - return e.count > 10; + return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; }); +} +function recordFailure(...keys) { + const now = Date.now(); + for (const k of keys) { + const e = _failures.get(k); + if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW }); + else e.count += 1; + } +} +function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); } +const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' }; + +// Compared against when an email isn't registered, so a login takes the same time either way. +const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK'; + +// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window. +const _usedTotp = new Map(); +function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; } +function markTotpUsed(userId, token) { + _usedTotp.set(userId, token); + setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref(); } // Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too. @@ -101,7 +126,8 @@ app.get('/api/health', (_req, res) => { app.post('/api/auth/register', async (req, res) => { try { - if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); + if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY); + recordFailure(`ip:${req.ip}`); // every registration attempt counts const email = String(req.body?.email ?? '').trim().toLowerCase(); const password = String(req.body?.password ?? ''); @@ -139,15 +165,18 @@ app.post('/api/auth/register', async (req, res) => { app.post('/api/auth/login', async (req, res) => { try { - if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); const email = String(req.body?.email ?? '').trim().toLowerCase(); const password = String(req.body?.password ?? ''); + const keys = [`ip:${req.ip}`, `acct:${email}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); const user = getUserByEmail(email); - const valid = user && await verifyPassword(password, user.passwordHash); + const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user; if (!valid) { + recordFailure(...keys); return res.status(401).json({ error: 'Incorrect email or password.' }); } + clearFailures(`acct:${email}`); req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); @@ -168,11 +197,12 @@ app.post('/api/auth/login', async (req, res) => { app.post('/api/auth/mfa/verify', async (req, res) => { try { - if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); const pendingUserId = req.session?.pendingUserId; if (!pendingUserId) { return res.status(400).json({ error: 'No sign-in in progress.' }); } + const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); const user = getUserById(pendingUserId); if (!user || !user.totpEnabled) { return res.status(400).json({ error: 'No sign-in in progress.' }); @@ -180,7 +210,8 @@ app.post('/api/auth/mfa/verify', async (req, res) => { const token = req.body?.token; const backupCode = req.body?.backupCode; - let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false; + let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false; + if (ok) markTotpUsed(user.id, String(token)); if (!ok && backupCode) { const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes); @@ -191,8 +222,10 @@ app.post('/api/auth/mfa/verify', async (req, res) => { } if (!ok) { + recordFailure(...keys); return res.status(401).json({ error: 'Invalid code.' }); } + clearFailures(`mfa:${pendingUserId}`); req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); @@ -205,6 +238,66 @@ app.post('/api/auth/mfa/verify', async (req, res) => { } }); +// --------------------------------------------------------------------------- +// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same +// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the +// same whether or not the account exists, so this can't be used to find out who has one. +// --------------------------------------------------------------------------- +const RESET_MINUTES = 15; +const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' }; + +app.post('/api/auth/forgot', async (req, res) => { + try { + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const keys = [`ip:${req.ip}`, `forgot:${email}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); + recordFailure(...keys); // every request counts + const user = isValidEmail(email) ? getUserByEmail(email) : null; + if (user?.ntfyTopic) { + const code = generateResetCode(); + setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000); + sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' }) + .catch((err) => console.error('Password reset notification failed:', err.message)); + } + res.json(RESET_REPLY); + } catch (err) { + console.error('POST /api/auth/forgot error:', err); + res.status(500).json({ error: 'Failed to start the reset.' }); + } +}); + +app.post('/api/auth/reset', async (req, res) => { + try { + const email = String(req.body?.email ?? '').trim().toLowerCase(); + const keys = [`ip:${req.ip}`, `reset:${email}`]; + if (isLimited(...keys)) return res.status(429).json(TOO_MANY); + const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); }; + const newPassword = String(req.body?.newPassword ?? ''); + if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' }); + + const user = getUserByEmail(email); + const reset = user ? getPasswordReset(user.id) : null; + const code = normalizeResetCode(req.body?.code); + // Always spend one bcrypt compare, so timing doesn't show whether a code was issued + const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5; + if (reset && !codeOk) bumpPasswordResetAttempts(user.id); + if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.'); + if (user.totpEnabled) { + const token = String(req.body?.token ?? ''); + if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.'); + markTotpUsed(user.id, token); + } + setUserPassword(user.id, await hashPassword(newPassword)); + clearPasswordReset(user.id); + destroyAllSessionsForUser(user.id); + clearFailures(...keys); + res.json({ ok: true }); + } catch (err) { + console.error('POST /api/auth/reset error:', err); + res.status(500).json({ error: 'Failed to reset the password.' }); + } +}); + app.post('/api/auth/logout', (req, res) => { req.session.destroy(() => { res.clearCookie('connect.sid'); @@ -235,7 +328,7 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => { // --------------------------------------------------------------------------- // PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh // --------------------------------------------------------------------------- -app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => { +app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { try { const reminderEnabled = !!req.body?.reminderEnabled; const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim(); @@ -249,6 +342,9 @@ app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => { if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) { return res.status(400).json({ error: 'Server URL must start with http:// or https://.' }); } + if (ntfyUrl) { + try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); } + } if (reminderEnabled) { if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' }); if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) { @@ -281,14 +377,17 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => { const currentPassword = String(req.body?.currentPassword ?? ''); const newPassword = String(req.body?.newPassword ?? ''); + if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY); const valid = await verifyPassword(currentPassword, user.passwordHash); if (!valid) { + recordFailure(`pw:${user.id}`); return res.status(401).json({ error: 'Current password is incorrect.' }); } if (!isValidPassword(newPassword)) { return res.status(400).json({ error: 'New password must be at least 8 characters.' }); } + clearFailures(`pw:${user.id}`); const passwordHash = await hashPassword(newPassword); setUserPassword(user.id, passwordHash); // Sign out every other session (e.g. a stolen one) but keep this one logged in. @@ -327,7 +426,9 @@ app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => { if (!secret) { return res.status(400).json({ error: 'Start 2FA setup first.' }); } + if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY); if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) { + recordFailure(`mfaset:${req.session.userId}`); return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' }); } @@ -345,8 +446,10 @@ app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => { app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => { try { const user = getUserById(req.session.userId); + if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY); const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash); if (!valid) { + recordFailure(`pw:${user.id}`); return res.status(401).json({ error: 'Incorrect password.' }); } disableTotp(user.id); @@ -358,6 +461,28 @@ app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => { }); // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// GET /api/account/export — everything this account holds, as one restorable file +// --------------------------------------------------------------------------- +app.get('/api/account/export', requireAuth, (req, res) => { + try { + const userId = req.session.userId; + const user = getUserById(userId); + const backup = buildAccountExport(user, { + projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean), + prefs: () => getUserPrefs(userId), + marks: () => getReaderMarks(userId), + ink: () => getAllReaderInk(userId), + plans: () => getReadingProgress(userId), + }); + res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); + res.json(backup); + } catch (err) { + console.error('GET /api/account/export error:', err); + res.status(500).json({ error: 'Failed to export your data.' }); + } +}); + // GET /api/projects — list all project summaries owned by the current user // --------------------------------------------------------------------------- app.get('/api/projects', requireAuth, (req, res) => { @@ -448,8 +573,10 @@ app.get('/api/reader/ink', requireAuth, (req, res) => { app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => { try { const { book, chapter } = req.params; - const { strokes } = req.body; + const { strokes } = req.body ?? {}; + if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' }); if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' }); + if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' }); setReaderInkPage(req.session.userId, book, chapter, strokes); res.json({ ok: true }); } catch (err) { @@ -508,6 +635,7 @@ app.put('/api/reader/marks', requireAuth, (req, res) => { // GET /api/reading-plans — the current user's whole progress list registerBibleRoutes(app, requireAuth); +registerIqBibleRoutes(app, requireAuth); app.get('/api/reading-plans', requireAuth, (req, res) => { try { @@ -525,7 +653,12 @@ app.put('/api/reading-plans', requireAuth, (req, res) => { if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { return res.status(400).json({ error: 'Body must be an object.' }); } - setReadingProgress(req.session.userId, req.body); + const { progress, customPlans, activity } = req.body; + if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v)) + || (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) { + return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' }); + } + setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] }); res.json({ ok: true }); } catch (err) { console.error('PUT /api/reading-plans error:', err); @@ -677,6 +810,7 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => { return res.status(400).json({ error: "Can't delete your own admin account." }); } adminDeleteUser(req.params.id); + destroyAllSessionsForUser(req.params.id); res.json({ ok: true }); } catch (err) { console.error('DELETE /api/admin/users/:id error:', err); @@ -756,6 +890,7 @@ if (isProd) { // --------------------------------------------------------------------------- initDb(); startReminderScheduler(); +startBackupScheduler(); app.listen(PORT, () => { console.log(`Bible Study API running on http://localhost:${PORT}`); if (isProd) { diff --git a/server/iqbible.js b/server/iqbible.js new file mode 100644 index 0000000..c7f122c --- /dev/null +++ b/server/iqbible.js @@ -0,0 +1,143 @@ +// Proxy for the IQ Bible API (https://api.iqbible.com): commentaries, lexicons, original-language text, +// places, topics, devotionals, narrations and more. The API key stays on the server (IQBIBLE_API_KEY, +// from the environment or .env); the browser only talks to /api/iq/... and only to the routes listed here. +// Only verse/chapter text is cached (up to 24h, as the API's terms require); see isTextPath. +import { existsSync, readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const envPath = join(__dirname, '..', '.env'); +if (existsSync(envPath)) { + for (const line of readFileSync(envPath, 'utf8').split('\n')) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); + if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2'); + } +} + +const BASE = () => (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, ''); +const S = '[\\w.~%-]+'; // one path segment +const route = (pattern, opts = {}) => ({ re: new RegExp(`^${pattern.replace(/@/g, S)}/?$`), ...opts }); + +// Every upstream route the app may reach. Anything else is a 404 here, whatever the browser asks for. +export const ROUTES = [ + route('/commentaries'), route('/commentaries/@/@/@'), route('/commentaries/@/@/@/@'), + route('/books/@/info'), route('/books/@/chapters/@/info'), route('/books/@/commentaries'), route('/books/abbreviations'), + route('/lexicon/sources'), route('/lexicon/crosswalk/@'), + route('/lexicon/(?:greek|hebrew)/(?:strongs|bdb|lsj|abbott-smith)/@'), route('/lexicon/greek/lsj/word/@'), + route('/original-language/@/@'), route('/original-language/@/@/@'), route('/morphology/@'), + route('/geo/@/@'), route('/geo/@/@/@'), route('/geo/places/@'), + route('/topics'), route('/topics/@'), route('/topics/@/@'), route('/topics/@/@/@'), + route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'), + route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'), + route('/chronology/for/@/@'), route('/reference/weights-measures'), + route('/cross-references/@/@'), route('/cross-references/@/@/@'), + route('/dictionaries'), route('/dictionaries/@'), + route('/devotionals/today'), route('/devotionals/@/@'), + route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'), + route('/bibles/@/@/@/@'), route('/bibles/@/@/@'), + route('/audio'), route('/audio/@/@/@', { cache: false }), + route('/ai/context', { heavy: true }), route('/bibles/@/search', { heavy: true }), route('/parse/citations'), +]; + +// Verse and chapter text is the one thing the API's terms let (and require) a client to cache, for up to 24h. +// Everything else caps at 1 hour and needs written permission, so it is only cached when IQBIBLE_CACHE_HOURS +// is set (do that only once permission is on file). +const TEXT_PATH = /^\/bibles\/[\w]+\/(?!random\/|search|reading-plan|verse-of-the-day|verses|books|word-count)[\w.~%-]+\/\d+(?:\/[\d-]+)?$/; +export const isTextPath = (p) => TEXT_PATH.test(p.split('?')[0]); + +const QUERY_KEY = /^[\w-]{1,40}$/; + +// Maps an incoming sub-path + query onto an upstream URL, or null when it isn't an allowed route. +// The API key can never be supplied by the browser (apikey is dropped). +export function resolveIqPath(path, query = {}) { + const clean = `/${String(path ?? '').replace(/^\/+/, '')}`; + if (clean.includes('..') || clean.includes('//') || clean.length > 300) return null; + const hit = ROUTES.find((r) => r.re.test(clean)); + if (!hit) return null; + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) { + if (!QUERY_KEY.test(k) || k.toLowerCase() === 'apikey' || typeof v !== 'string' || v.length > 200) continue; + qs.append(k, v); + } + const q = qs.toString(); + const key = `${clean}${q ? `?${q}` : ''}`; + return { key, cache: hit.cache !== false, heavy: !!hit.heavy, text: isTextPath(clean) }; +} + +// ---- caching, bounded by the API's own licensing summary ---- +let policy = null; // { textMs, otherMs } +let policyAt = 0; +async function cacheTtl(headers, isText) { + if (!policy || Date.now() - policyAt > 6 * 3600_000) { + try { + const res = await fetch(`${BASE()}/licensing`, { headers, signal: AbortSignal.timeout(8000) }); + const s = (await res.json())?.summary; + const allowed = s?.caching_allowed !== false; + const textHours = allowed ? Math.min(Number(s?.caching_verse_chapter_hours) || 24, 24) : 0; + const optIn = Number(process.env.IQBIBLE_CACHE_HOURS) || 0; // only with the API owner's written permission + const otherHours = allowed ? Math.min(optIn, Number(s?.caching_max_hours) || 1) : 0; + policy = { textMs: textHours * 3600_000, otherMs: otherHours * 3600_000 }; + } catch { + policy = { textMs: 0, otherMs: 0 }; + } + policyAt = Date.now(); + } + return isText ? policy.textMs : policy.otherMs; +} + +const cache = new Map(); // key -> { at, status, type, body } +const CACHE_MAX = 1500; + +const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt } +function overLimit(userId, heavy) { + const bucket = heavy ? 'h' : 'n'; + const max = heavy ? 10 : 120; // per minute, per signed-in user (the API budget is shared by everyone) + const k = `${userId}:${bucket}`; + const now = Date.now(); + const e = hits.get(k); + if (!e || now > e.resetAt) { hits.set(k, { n: 1, resetAt: now + 60_000 }); return false; } + e.n += 1; + return e.n > max; +} + +export function registerIqBibleRoutes(app, requireAuth) { + const apiKey = () => process.env.IQBIBLE_API_KEY; + + app.get('/api/iq/status', requireAuth, (_req, res) => res.json({ available: Boolean(apiKey()) })); + + app.get('/api/iq/*', requireAuth, async (req, res) => { + const target = resolveIqPath(req.params[0], req.query); + if (!target) return res.status(404).json({ error: 'Unknown IQ Bible route.' }); + if (!apiKey()) return res.status(503).json({ error: 'IQBIBLE_API_KEY is not configured on the server.' }); + if (overLimit(req.session.userId, target.heavy)) return res.status(429).json({ error: 'Slow down a little and try again in a minute.' }); + + const headers = { 'X-IQBible-API-Key': apiKey(), Accept: '*/*' }; + const hit = target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS) ? cache.get(target.key) : null; + if (hit && Date.now() - hit.at < hit.ttl) { + res.status(hit.status).type(hit.type).send(hit.body); + return; + } + try { + const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) }); + const body = Buffer.from(await upstream.arrayBuffer()); + const type = upstream.headers.get('content-type') || 'application/json'; + if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' }); + if (upstream.status === 429) { + const retry = upstream.headers.get('retry-after'); + if (retry) res.set('Retry-After', retry); + return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' }); + } + if (upstream.ok && target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS)) { + const ttl = await cacheTtl(headers, target.text); + if (ttl > 0) { + if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value); + cache.set(target.key, { at: Date.now(), ttl, status: upstream.status, type, body }); + } + } + res.status(upstream.status).type(type).send(body); + } catch { + res.status(502).json({ error: 'Could not reach the IQ Bible service.' }); + } + }); +} diff --git a/server/iqbible.test.js b/server/iqbible.test.js new file mode 100644 index 0000000..97930aa --- /dev/null +++ b/server/iqbible.test.js @@ -0,0 +1,34 @@ +// @vitest-environment node +import { test, expect } from 'vitest'; +import { resolveIqPath } from './iqbible.js'; + +test('only listed IQ Bible routes are reachable', () => { + expect(resolveIqPath('commentaries/gill/JHN/3/16')?.key).toBe('/commentaries/gill/JHN/3/16'); + expect(resolveIqPath('/geo/JHN/3')?.cache).toBe(true); + expect(resolveIqPath('/me')).toBeNull(); + expect(resolveIqPath('/licensing')).toBeNull(); + expect(resolveIqPath('/commentaries/gill/../me')).toBeNull(); + expect(resolveIqPath('/lexicon/greek/strongs/G26')).not.toBeNull(); +}); + +test('lexicon sources and audio routes', () => { + expect(resolveIqPath('/lexicon/greek/lsj/G0026')).not.toBeNull(); + expect(resolveIqPath('/lexicon/hebrew/bdb/BDB1005')).not.toBeNull(); + expect(resolveIqPath('/audio/eng_kjv/JHN/3')?.cache).toBe(false); + expect(resolveIqPath('/ai/context', { ref: 'JHN.3.16' })?.heavy).toBe(true); +}); + +test('the browser cannot send its own key and the query is normalised', () => { + const r = resolveIqPath('/bibles/eng_kjv/reading-plan', { days: '30', apikey: 'x', 'bad key': '1', format: 'ical' }); + expect(r.key).toBe('/bibles/eng_kjv/reading-plan?days=30&format=ical'); +}); + +test('only verse and chapter text counts as cacheable text', async () => { + const { isTextPath } = await import('./iqbible.js'); + expect(isTextPath('/bibles/eng_kjv/JHN/3')).toBe(true); + expect(isTextPath('/bibles/eng_kjv/JHN/3/16')).toBe(true); + expect(isTextPath('/bibles/eng_kjv/random/verse')).toBe(false); + expect(isTextPath('/bibles/eng_kjv/verse-of-the-day')).toBe(false); + expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false); + expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull(); +}); diff --git a/server/reminders.js b/server/reminders.js index 8380c15..976808b 100644 --- a/server/reminders.js +++ b/server/reminders.js @@ -2,12 +2,48 @@ // a free pub/sub push service: POST a message to a topic URL, anyone // subscribed to that topic (via the ntfy app or a browser) gets notified. // No API keys or push-subscription plumbing needed. +import { lookup } from 'dns/promises'; +import { isIP } from 'net'; import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js'; // A reminder still goes out if the server was briefly down (or restarting) at the // exact minute — as long as it's within this window after the chosen time. const GRACE_MINUTES = 180; const SEND_TIMEOUT_MS = 10_000; +// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours. +const RETRY_AFTER_MS = 15 * 60 * 1000; +const _lastAttempt = new Map(); // userId -> timestamp of the last failed send + +// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6). +export function isPrivateAddress(addr) { + const v = isIP(addr); + if (v === 4) { + const [a, b] = addr.split('.').map(Number); + return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224; + } + if (v === 6) { + const l = addr.toLowerCase(); + if (l === '::' || l === '::1') return true; + if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7)); + return /^f[cd]/.test(l) || /^fe[89ab]/.test(l); + } + return true; +} + +// Rejects server URLs that would make this server call itself or its private network. +// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1. +export async function assertPublicUrl(raw) { + if (process.env.ALLOW_PRIVATE_NTFY === '1') return; + let u; + try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); } + if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.'); + const host = u.hostname.replace(/^\[|\]$/g, ''); + if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.'); + const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address); + if (addrs.length === 0) throw new Error(`Could not find ${host}.`); + if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.'); +} export function isValidTimeZone(tz) { if (!tz) return false; @@ -40,17 +76,23 @@ export function isDue(user, now = new Date()) { export async function sendNtfy(user, { title, body, tags = 'open_book' }) { const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, ''); const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`; + await assertPublicUrl(url); const headers = { Title: title, Tags: tags }; if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app let res; try { - res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) }); + res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) }); } catch (err) { - throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`); + throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err }); } if (!res.ok) throw new Error(`ntfy responded ${res.status}`); } +// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)? +export function readToday(progress, date) { + return Array.isArray(progress?.activity) && progress.activity.includes(date); +} + export function reminderBody(progress) { const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length; return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!"; @@ -67,7 +109,11 @@ export async function checkReminders(now = new Date()) { const progress = getReadingProgress(user.id); const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt); if (!hasActivePlan) continue; // nothing to nudge them about + if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge + if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue; + _lastAttempt.set(user.id, now.getTime()); await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) }); + _lastAttempt.delete(user.id); setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date); } catch (err) { console.error(`Reading reminder failed for user ${user.id}:`, err.message); diff --git a/server/reminders.test.js b/server/reminders.test.js index 94e28f1..0058acc 100644 --- a/server/reminders.test.js +++ b/server/reminders.test.js @@ -1,6 +1,7 @@ // @vitest-environment node import http from 'node:http'; import { test, expect, afterEach } from 'vitest'; +process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js'; const at = (iso) => new Date(iso); @@ -84,3 +85,17 @@ test('an ntfy error response becomes a readable failure', async () => { test('an unreachable server says so instead of hanging', async () => { await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/); }); + +test('private and loopback addresses are rejected as reminder servers', async () => { + const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js'); + for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true); + expect(isPrivateAddress('8.8.8.8')).toBe(false); + const prev = process.env.ALLOW_PRIVATE_NTFY; + delete process.env.ALLOW_PRIVATE_NTFY; + await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow(); + await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow(); + await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow(); + process.env.ALLOW_PRIVATE_NTFY = prev; + expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true); + expect(readToday({ activity: [] }, '2026-09-29')).toBe(false); +}); diff --git a/src/App.jsx b/src/App.jsx index b60322f..0887dc2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -22,6 +22,9 @@ import { bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, } from './utils/bibleUtils.jsx'; import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js'; +import { knownOnServer, unsyncedStudies } from './utils/syncQueue.js'; +import { fetchStudyChapter } from './utils/studyText.js'; +import { mergeStudies } from './utils/mergeStudies.js'; // Less-common pages are split into their own chunks so the first load only // downloads what's needed to show the home screen. @@ -100,6 +103,8 @@ const AppInner = () => { onSignOut: () => { reader.stopMarksSync(); reader.stopPrefsSync(); + reader.stopInkSync(); + readingPlans.stopReadingPlansSync(); setProjectIndex(switchStorageUser(null)); goStudies(); }, @@ -163,10 +168,12 @@ const AppInner = () => { const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; - // Autosave: 1s after the last edit, save locally, then sync to the server. + // Autosave: 1s after the last edit, save locally, then sync to the server. A save still waiting out + // that second is written straight away when you leave the study or the tab, so nothing typed is lost. // Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer" // and make your other devices think it changed. const savedSignatureRef = useRef({ id: null, signature: '' }); + const pendingSaveRef = useRef(null); // { project, signature } waiting for the debounce const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy const conflictRef = useRef(null); conflictRef.current = syncConflict; @@ -177,7 +184,15 @@ const AppInner = () => { setSyncStatus('syncing'); const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force }); if (result.ok) { - setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited); + // The server's clock decides the version; keep this device's copy on the same number + const serverEdited = result.data?.lastEdited ?? toSave.lastEdited; + setSyncBase(toSave.id, serverEdited); + unsyncedStudies.remove(toSave.id); + knownOnServer.add(toSave.id); + const stored = loadProjectById(toSave.id); + if (stored && stored.lastEdited === toSave.lastEdited && serverEdited !== toSave.lastEdited) { + try { saveProjectToStorage({ ...stored, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* the copy is already saved */ } + } setSyncConflict((c) => (c?.id === toSave.id ? null : c)); setSyncStatus('synced'); window.setTimeout(() => setSyncStatus(''), 2500); @@ -190,8 +205,49 @@ const AppInner = () => { return result; }; + const commitSave = async (toEdit) => { + // Stamp lastEdited once so localStorage and server receive the same value + const toSave = { ...toEdit, lastEdited: Date.now() }; + + // 1. Save locally (can fail when the device is out of storage) + try { + saveProjectToStorage(toSave); + } catch (err) { + console.error('Local save failed:', err); + setSaveStatus(''); + toast("Couldn't save on this device — storage is full. Free some space or export a backup.", { tone: 'error' }); + } + unsyncedStudies.add(toSave.id); + setProjectIndex(loadProjectIndex()); + setSaveStatus('Saved'); + window.setTimeout(() => setSaveStatus(''), 1400); + + // 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice) + if (conflictRef.current?.id === toSave.id) return; + await pushProject(toSave); + }; + + const flushPendingSave = () => { + const pending = pendingSaveRef.current; + if (!pending) return; + window.clearTimeout(saveTimerRef.current); + pendingSaveRef.current = null; + savedSignatureRef.current = { id: pending.project.id, signature: pending.signature }; + commitSave(pending.project); + }; + const flushRef = useRef(flushPendingSave); + flushRef.current = flushPendingSave; + useEffect(() => { - if (!project) return undefined; + const flush = () => { if (document.visibilityState !== 'visible') flushRef.current(); }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', flush); + return () => { window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', flush); }; + }, []); + + useEffect(() => { + if (!project) { flushPendingSave(); return undefined; } + if (pendingSaveRef.current && pendingSaveRef.current.project.id !== project.id) flushPendingSave(); const signature = signatureOf(project); const last = savedSignatureRef.current; if (last.id !== project.id) { @@ -200,25 +256,10 @@ const AppInner = () => { } else if (last.signature === signature) { return undefined; } - if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = window.setTimeout(async () => { - savedSignatureRef.current = { id: project.id, signature }; - // Stamp lastEdited once so localStorage and server receive the same value - const toSave = { ...project, lastEdited: Date.now() }; - - // 1. Save locally - saveProjectToStorage(toSave); - setProjectIndex(loadProjectIndex()); - setSaveStatus('Saved'); - window.setTimeout(() => setSaveStatus(''), 1400); - - // 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice) - if (conflictRef.current?.id === toSave.id) return; - await pushProject(toSave); - }, 1000); - return () => { - if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); - }; + pendingSaveRef.current = { project, signature }; + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = window.setTimeout(() => flushRef.current(), 1000); + return undefined; }, [project]); // eslint-disable-line react-hooks/exhaustive-deps // The two ways out of a conflict: keep this device's version, or take the server's. @@ -232,6 +273,7 @@ const AppInner = () => { if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; } saveProjectToStorage(result.data); setSyncBase(syncConflict.id, result.data.lastEdited); + unsyncedStudies.remove(syncConflict.id); const loaded = loadProjectById(syncConflict.id); savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) }; setProject(loaded); @@ -241,6 +283,25 @@ const AppInner = () => { toast('Switched to the version from your other device'); }; + // Third way out of a conflict: combine both copies chunk by chunk, then save the result as the new version. + const mergeBothVersions = async () => { + if (!project || !syncConflict) return; + const result = await loadRemoteProject(syncConflict.id); + if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; } + const { project: merged, stats } = mergeStudies(project, result.data); + setSyncBase(syncConflict.id, result.data.lastEdited); + savedSignatureRef.current = { id: merged.id, signature: signatureOf(merged) }; + setProject(merged); + const toSave = { ...merged, lastEdited: Date.now() }; + try { saveProjectToStorage(toSave); } catch { /* storage full: the server copy below still holds it */ } + unsyncedStudies.add(toSave.id); + setProjectIndex(loadProjectIndex()); + const saved = await pushProject(toSave); + if (saved.ok) { + toast(`Combined both versions: ${stats.added} chunk${stats.added === 1 ? '' : 's'} added from the other device, ${stats.merged} merged`); + } + }; + const updateProject = (updater) => { setProject((current) => (current ? updater(current) : current)); }; @@ -250,7 +311,7 @@ const AppInner = () => { ...current, chapters: current.chapters.map((ch) => ({ ...ch, - chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)), + chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)), })), })); }; @@ -309,9 +370,7 @@ const AppInner = () => { setStudyBuildBusy(true); try { const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => { - const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`); - if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`); - const data = await res.json(); + const data = await fetchStudyChapter('BSB', book, chapter); const verses = parseBibleChapter(data); const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book; const chunk = { @@ -337,7 +396,7 @@ const AppInner = () => { setCurrentPage('study'); } catch (err) { console.error('studyReadingPlanDay error:', err); - toast("Couldn't load today's passage. Please try again.", { tone: 'error' }); + toast(err?.message?.startsWith("You're offline") ? err.message : "Couldn't load today's passage. Please try again.", { tone: 'error' }); } finally { setStudyBuildBusy(false); } @@ -374,7 +433,8 @@ const AppInner = () => { const retrySync = async () => { if (!project) return; const toSave = { ...project, lastEdited: Date.now() }; - saveProjectToStorage(toSave); + try { saveProjectToStorage(toSave); } catch { /* storage full: still try the server */ } + unsyncedStudies.add(toSave.id); await pushProject(toSave); }; @@ -395,7 +455,7 @@ const AppInner = () => { saveStatus, setSaveStatus, syncStatus, retrySync, - syncConflict, keepMyVersion, useServerVersion, + syncConflict, keepMyVersion, useServerVersion, mergeBothVersions, // Home projectIndex, ...library, diff --git a/src/App.test.jsx b/src/App.test.jsx deleted file mode 100644 index 52e7f5a..0000000 --- a/src/App.test.jsx +++ /dev/null @@ -1,1441 +0,0 @@ -import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest'; -import App from './App'; -import { resetLexiconCaches } from './utils/wordStudy.js'; -import { switchStorageUser } from './utils/bibleUtils.jsx'; - -// --------------------------------------------------------------------------- -// Shared mock data -// --------------------------------------------------------------------------- -const mockChapterData = { - chapter: { - content: [ - { type: 'verse', number: 1, content: ['Paul, a servant of God.'] }, - { type: 'verse', number: 2, content: ['In hope of eternal life.'] }, - { type: 'verse', number: 3, content: ['Grace and peace from God.'] }, - ], - }, -}; - -// Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr. -const mockGreekDict = { - G4102: { - lemma: 'πίστις', - translit: 'pistis', - kjv_def: 'faith, belief', - strongs_def: 'persuasion, i.e. credence; moral conviction', - }, -}; - -function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) { - return vi.fn((url) => { - if (url.includes('available_translations')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB', 'KJV']) }); - } - if (url.includes('bible.helloao.org')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) }); - } - if (url.includes('strongs-greek-dictionary') && greekData !== null) { - return Promise.resolve({ - ok: true, - text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`), - }); - } - return Promise.resolve({ ok: false }); - }); -} - -// --------------------------------------------------------------------------- -// Test lifecycle -// --------------------------------------------------------------------------- -beforeEach(() => { - localStorage.clear(); - resetLexiconCaches(); - switchStorageUser(null); // a signed-in test leaves the storage namespace switched - vi.stubGlobal('fetch', buildFetchMock()); - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); - vi.spyOn(window, 'confirm').mockReturnValue(false); -}); - -afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); -}); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// App checks /api/auth/me on mount before deciding whether to show the app or -// a login gate. In tests the mock fetch reports the server as unreachable, so -// it falls back to local-only mode — but that still takes a tick to resolve. -async function renderApp() { - render(); - await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument()); -} - -// The account menu (avatar button in the app bar) holds the email, Settings, Admin and Log out. -async function openAccountMenu(user = userEvent.setup()) { - await user.click(await screen.findByRole('button', { name: /account menu/i })); - return user; -} - -async function loadChapter() { - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - // SetupPage is lazy-loaded, so wait for it on first open - await user.click(await screen.findByRole('button', { name: /load chapter/i })); - await screen.findByText(/Scripture & Chunks/i); - return user; -} - -function getVerseList() { - return screen.getByTestId('verse-list'); -} - -function clickVerse(textFragment) { - fireEvent.click( - within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }), - ); -} - -function shiftClickVerse(textFragment) { - fireEvent.click( - within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }), - { shiftKey: true }, - ); -} - -function addChunk(startFragment, endFragment) { - clickVerse(startFragment); - shiftClickVerse(endFragment); -} - -function findChunkCounter(n, total) { - return screen.findByText((_, el) => el?.textContent === `Chunk ${n} of ${total}`); -} - -// --------------------------------------------------------------------------- -// Initial render -// --------------------------------------------------------------------------- -describe('Initial render', () => { - test('shows the home page with "My Studies" heading', async () => { - await renderApp(); - expect(screen.getByText('My Studies')).toBeInTheDocument(); - }); - - test('shows "No projects yet" when storage is empty', async () => { - await renderApp(); - expect(screen.getByText(/no projects yet/i)).toBeInTheDocument(); - }); - - test('shows "New Project" button on home page', async () => { - await renderApp(); - expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0); - }); - - test('clicking "New Project" shows the project setup form', async () => { - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - expect(await screen.findByText('New study')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument(); - }); - - test('setup form shows book and chapter inputs, and uses the BSB', async () => { - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - expect(screen.queryByText('Translation')).not.toBeInTheDocument(); - expect(screen.getByText('Book')).toBeInTheDocument(); - expect(screen.getByText('Chapter')).toBeInTheDocument(); - expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Loading a chapter -// --------------------------------------------------------------------------- -describe('Loading a chapter', () => { - test('transitions to the chunk setup view on success', async () => { - await loadChapter(); - expect(screen.getByText(/Scripture & Chunks/i)).toBeInTheDocument(); - }); - - test('renders verse text after loading', async () => { - await loadChapter(); - expect(screen.getByText(/Paul, a servant of God/i)).toBeInTheDocument(); - }); - - test('shows "0 created" initially', async () => { - await loadChapter(); - expect(screen.getByText('0 created')).toBeInTheDocument(); - }); - - test('shows an error message when the API returns a non-ok response', async () => { - vi.stubGlobal('fetch', vi.fn((url) => { - if (url.includes('available_translations')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) }); - } - return Promise.resolve({ ok: false }); - })); - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - await user.click(screen.getByRole('button', { name: /load chapter/i })); - await screen.findByText(/unable to load chapter/i); - }); - - test('shows an error message when chapter data contains no verses', async () => { - vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } })); - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getAllByRole('button', { name: /new project/i })[0]); - await user.click(screen.getByRole('button', { name: /load chapter/i })); - await screen.findByText(/invalid bible data/i); - - }); -}); - -// --------------------------------------------------------------------------- -// Chunk management -// --------------------------------------------------------------------------- -describe('Chunk management', () => { - test('creates a chunk by clicking a verse range', async () => { - await loadChapter(); - addChunk('Paul, a servant', 'Grace and peace'); - expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0); - }); - - test('chunk count increments after each addition', async () => { - await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - addChunk('In hope of eternal', 'Grace and peace'); - await screen.findByText('2 created'); - }); - - test('prevents adding a duplicate chunk range', async () => { - await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText(/That chunk already exists/i); - }); - - test('deletes a chunk', async () => { - await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - fireEvent.click(screen.getByRole('button', { name: /delete/i })); - await screen.findByText('0 created'); - }); - - test('"Begin Studying" is disabled when no chunks exist', async () => { - await loadChapter(); - expect(screen.getByRole('button', { name: /begin studying/i })).toBeDisabled(); - }); - - test('"Begin Studying" is enabled once a chunk exists', async () => { - await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - expect(screen.getByRole('button', { name: /begin studying/i })).not.toBeDisabled(); - }); -}); - -// --------------------------------------------------------------------------- -// Study view navigation -// --------------------------------------------------------------------------- -async function goToStudyWith3Chunks() { - await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - addChunk('In hope of eternal', 'In hope of eternal'); - addChunk('Grace and peace', 'Grace and peace'); - await screen.findByText('3 created'); - fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(3, 3); - // addChunk always selects the newest chunk; navigate back to chunk 1 - // so navigation tests start from a known position. - fireEvent.click(screen.getByRole('button', { name: /previous chunk/i })); - fireEvent.click(screen.getByRole('button', { name: /previous chunk/i })); - expect(await findChunkCounter(1, 3)).toBeInTheDocument(); -} - -describe('Study view navigation', () => { - test('transitions to study view when "Begin Studying" is clicked', async () => { - await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(1, 1); - }); - - test('"Previous Chunk" is disabled on the first chunk', async () => { - await goToStudyWith3Chunks(); - expect(screen.getByRole('button', { name: /previous chunk/i })).toBeDisabled(); - }); - - test('"Next Chunk" advances to the second chunk', async () => { - await goToStudyWith3Chunks(); - expect(await findChunkCounter(1, 3)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /next chunk/i })); - expect(await findChunkCounter(2, 3)).toBeInTheDocument(); - }); - - test('"Next Chunk" is disabled on the last chunk', async () => { - await goToStudyWith3Chunks(); - fireEvent.click(screen.getByRole('button', { name: /next chunk/i })); - fireEvent.click(screen.getByRole('button', { name: /next chunk/i })); - expect(await findChunkCounter(3, 3)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /next chunk/i })).toBeDisabled(); - }); - - test('"Previous Chunk" returns to prior chunk after advancing', async () => { - await goToStudyWith3Chunks(); - fireEvent.click(screen.getByRole('button', { name: /next chunk/i })); - expect(await findChunkCounter(2, 3)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /previous chunk/i })); - expect(await findChunkCounter(1, 3)).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Greek word lookup -// --------------------------------------------------------------------------- -async function goToStudyAndAddGreekWord(fetchMock) { - if (fetchMock) vi.stubGlobal('fetch', fetchMock); - await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - fireEvent.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(1, 1); - fireEvent.click(screen.getByRole('tab', { name: /word study/i })); - fireEvent.click(screen.getByRole('button', { name: /add word/i })); - await screen.findByPlaceholderText(/G4102, H7225, 4102/i); -} - -describe('Greek word lookup', () => { - test('adds a Greek word entry form', async () => { - await goToStudyAndAddGreekWord(); - expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument(); - }); - - test('populates fields after a successful lookup', async () => { - await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict })); - fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } }); - fireEvent.click(screen.getByRole('button', { name: /look up greek/i })); - await screen.findByDisplayValue('πίστις'); - expect(screen.getByDisplayValue('pistis')).toBeInTheDocument(); - // With no gloss-map entry, the English gloss falls back to the KJV - // definition, so it matches the Short Definition field. - expect(screen.getAllByDisplayValue('faith, belief')).toHaveLength(2); - }); - - test('shows "No definition found." when the API returns an empty array', async () => { - await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} })); - fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } }); - fireEvent.click(screen.getByRole('button', { name: /look up greek/i })); - await screen.findByDisplayValue('No definition found.'); - }); - - test('shows "Lookup failed." on network error', async () => { - await goToStudyAndAddGreekWord( - vi.fn((url) => { - if (url.includes('available_translations')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) }); - } - if (url.includes('bible.helloao.org')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve(mockChapterData) }); - } - return Promise.reject(new Error('Network error')); - }), - ); - fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } }); - fireEvent.click(screen.getByRole('button', { name: /look up greek/i })); - await screen.findByDisplayValue('Lookup failed.'); - }); - - test('lookup is skipped when query is empty', async () => { - const fetchSpy = buildFetchMock(); - await goToStudyAndAddGreekWord(fetchSpy); - const callCountBefore = fetchSpy.mock.calls.length; - fireEvent.click(screen.getByRole('button', { name: /look up greek/i })); - await waitFor(() => { - expect(fetchSpy.mock.calls.length).toBe(callCountBefore); - }); - }); - - test('removes a Greek word entry', async () => { - await goToStudyAndAddGreekWord(); - expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /^delete$/i })); - await waitFor(() => { - expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument(); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Authentication (useAuth + AuthPage/SettingsPage local form state) -// --------------------------------------------------------------------------- -describe('Authentication', () => { - const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false }; - const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - - // A tiny fake API: `session` decides what /api/auth/me returns; login can - // optionally require a 2FA code first. - function stubApi({ session = null, mfaRequired = false } = {}) { - const fallback = buildFetchMock(); - const fetchMock = vi.fn((url, opts = {}) => { - if (!url.startsWith('/api/')) return fallback(url, opts); - const route = `${opts.method ?? 'GET'} ${url}`; - if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' }); - if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user); - if (route === 'POST /api/auth/mfa/verify') return json(200, user); - if (route === 'POST /api/auth/logout') return json(200, { ok: true }); - if (route === 'GET /api/projects') return json(200, []); - if (route === 'GET /api/reader/ink') return json(200, {}); - return json(404, { error: 'not mocked' }); - }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; - } - const calledWith = (fetchMock, method, url) => - fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method); - - async function signInWithForm(u) { - await u.type(await screen.findByLabelText(/email/i), user.email); - await u.type(screen.getByLabelText(/password/i), 'correct-horse'); - await u.click(screen.getByRole('button', { name: /^sign in$/i })); - } - - test('signing in with the form shows the app and loads reader ink', async () => { - const fetchMock = stubApi(); - const u = userEvent.setup(); - render(); - await signInWithForm(u); - await openAccountMenu(u); - expect(await screen.findByText(user.email)).toBeInTheDocument(); - expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true); - await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true)); - }); - - test('2FA: a correct code after the password signs the user in', async () => { - const fetchMock = stubApi({ mfaRequired: true }); - const u = userEvent.setup(); - render(); - await signInWithForm(u); - await u.type(await screen.findByPlaceholderText('123456'), '123456'); - await u.click(screen.getByRole('button', { name: /verify/i })); - await openAccountMenu(u); - expect(await screen.findByText(user.email)).toBeInTheDocument(); - expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true); - }); - - test('an existing session skips the login screen, and Log out returns to it', async () => { - const fetchMock = stubApi({ session: user }); - const u = userEvent.setup(); - render(); - await openAccountMenu(u); - expect(await screen.findByText(user.email)).toBeInTheDocument(); - await u.click(screen.getByRole('menuitem', { name: /log out/i })); - expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument(); - expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true); - }); - - test('Settings pre-fills the podcast name from the signed-in account', async () => { - stubApi({ session: user }); - const u = userEvent.setup(); - render(); - await openAccountMenu(u); - await u.click(await screen.findByRole('menuitem', { name: /settings/i })); - expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Admin page (state is local to AdminPage) -// --------------------------------------------------------------------------- -describe('Admin page', () => { - const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true }; - const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - - function stubAdminApi(sessionUser) { - let users = [ - { id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 }, - { id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 }, - ]; - const fallback = buildFetchMock(); - const fetchMock = vi.fn((url, opts = {}) => { - if (!url.startsWith('/api/')) return fallback(url, opts); - const route = `${opts.method ?? 'GET'} ${url}`; - if (route === 'GET /api/auth/me') return json(200, sessionUser); - if (route === 'GET /api/admin/users') return json(200, users); - if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]); - if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); return json(200, { ok: true }); } - if (route === 'GET /api/projects') return json(200, []); - if (route === 'GET /api/reader/ink') return json(200, {}); - return json(404, { error: 'not mocked' }); - }); - vi.stubGlobal('fetch', fetchMock); - return fetchMock; - } - - test('an admin can open the page and see users and projects', async () => { - stubAdminApi(admin); - const u = userEvent.setup(); - render(); - await openAccountMenu(u); - await u.click(await screen.findByRole('menuitem', { name: /admin/i })); - expect(await screen.findByText('student@example.com')).toBeInTheDocument(); - await u.click(screen.getByRole('button', { name: /projects \(1\)/i })); - expect(await screen.findByText('Romans Study')).toBeInTheDocument(); - }); - - test('deleting a user removes them after the list reloads', async () => { - const fetchMock = stubAdminApi(admin); - window.confirm.mockReturnValue(true); - const u = userEvent.setup(); - render(); - await openAccountMenu(u); - await u.click(await screen.findByRole('menuitem', { name: /admin/i })); - const row = (await screen.findByText('student@example.com')).closest('tr'); - await u.click(within(row).getByRole('button', { name: /delete/i })); - await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument()); - expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true); - }); - - test('the Admin page leads back to the dashboard', async () => { - stubAdminApi(admin); - const u = userEvent.setup(); - render(); - await openAccountMenu(u); - await u.click(await screen.findByRole('menuitem', { name: /admin/i })); - await screen.findByText('student@example.com'); - await u.click(screen.getByRole('button', { name: /^back$/i })); - expect(await screen.findByText('My Studies')).toBeInTheDocument(); - }); - - test('non-admins get no Admin button', async () => { - stubAdminApi({ ...admin, isAdmin: false }); - render(); - await openAccountMenu(); - expect(await screen.findByText(admin.email)).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Import page (state is local to ImportPage) -// --------------------------------------------------------------------------- -describe('Import page', () => { - test('opens from Home, uses the BSB (no translation choice), and goes back', async () => { - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /import session list/i })); - expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument(); - expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /^back$/i })); - expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Home page (search/sort/rename local to HomePage; library + reading plan hooks) -// --------------------------------------------------------------------------- -describe('Home page', () => { - // Tests run in local-only mode (the mock API is unreachable), which uses the - // pre-account localStorage keys. - function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) { - const project = { - id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null, - chapters: [{ - book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }], - chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', - crossReferences: [], greekWords: [], tags: [] }], - }], - }; - const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]'); - index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] }); - localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project)); - localStorage.setItem('bible-study-index', JSON.stringify(index)); - } - - test('searching 3+ characters finds matches inside study notes', async () => { - seedProject(); - const user = userEvent.setup(); - await renderApp(); - await user.type(screen.getByPlaceholderText(/search projects/i), 'grace'); - expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument(); - }); - - test('renaming a project updates its title and saved index', async () => { - seedProject(); - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /rename/i })); - const input = screen.getByDisplayValue('Titus Notes'); - await user.clear(input); - await user.type(input, 'Titus Deep Dive'); - await user.click(screen.getByRole('button', { name: /^save$/i })); - expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument(); - expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive'); - }); - - test('deleting a project removes it at once, and Undo brings it back', async () => { - seedProject(); - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /^delete$/i })); - await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument()); - expect(localStorage.getItem('bible-study-project-p1')).toBeNull(); - expect(window.confirm).not.toHaveBeenCalled(); - await user.click(await screen.findByRole('button', { name: /undo/i })); - expect(await screen.findByText('Titus Notes')).toBeInTheDocument(); - expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull(); - }); - - test('sort choice is remembered after leaving Home', async () => { - seedProject(); - const user = userEvent.setup(); - await renderApp(); - await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title'); - await user.click(screen.getByRole('button', { name: /read bible/i })); - await user.click(await screen.findByRole('button', { name: /back to home/i })); - expect(await screen.findByDisplayValue('Title')).toBeInTheDocument(); - }); - - test('Continue reading opens the Reader inside the plan, in the plan\'s own translation', async () => { - seedProject(); - localStorage.setItem('readingPlanProgress', JSON.stringify({ - progress: [{ id: 'rp1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now(), translation: 'ESV' }], - customPlans: [], activity: [], - })); - const esvChapter = { translation: 'ESV', verses: [{ number: 1, text: 'In the beginning was the Word.' }], copyright: '' }; - const fetchMock = vi.fn((url) => { - if (String(url).startsWith('/api/bible/ESV/')) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(esvChapter) }); - return buildFetchMock()(url); - }); - vi.stubGlobal('fetch', fetchMock); - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /reading plans/i })); - await user.click(await screen.findByRole('button', { name: /continue reading/i })); - // The plan strip is up, on the plan's translation, and the chapter came from the ESV route - expect(await screen.findByText(/In the beginning was the Word/)).toBeInTheDocument(); - expect(screen.getAllByText(/The Gospels in 30 Days · Day 1/).length).toBeGreaterThan(0); - expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation - expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true); - // Leaving the reader ends the plan context: the reader's own translation applies again - await user.click(screen.getByRole('button', { name: /back to home/i })); - await user.click(screen.getByRole('button', { name: /read bible/i })); - expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument(); - }); - - test('a reading plan can be started from the Reading Plans page', async () => { - seedProject(); // the nav button shows once there's at least one project - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /reading plans/i })); - // Tap a plan in the catalog, pick a translation for it, and start it. - await user.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ })); - await user.selectOptions(screen.getByRole('combobox'), 'ESV'); - await user.click(screen.getByRole('button', { name: /^start plan$/i })); - await waitFor(() => { - const stored = JSON.parse(localStorage.getItem('readingPlanProgress')); - expect(stored?.progress?.[0]?.planId).toBe('gospels-30'); - expect(stored?.progress?.[0]?.translation).toBe('ESV'); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Export stage (useShareLink) and the shared read-only view -// --------------------------------------------------------------------------- -describe('Project header and sharing', () => { - const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - - test('Export stage → Markdown downloads a file', async () => { - const user = await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /^export$/i })); - await user.click(await screen.findByRole('button', { name: /export markdown/i })); - expect(URL.createObjectURL).toHaveBeenCalled(); - }); - - test('enabling sharing shows a copyable link', async () => { - const fallback = buildFetchMock(); - const fetchMock = vi.fn((url, opts = {}) => { - if (url.startsWith('/api/projects/') && url.endsWith('/share')) { - return (opts.method ?? 'GET') === 'POST' ? json(200, { shareToken: 'tok123' }) : json(200, { shareToken: null }); - } - return fallback(url, opts); - }); - vi.stubGlobal('fetch', fetchMock); - const user = await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /^export$/i })); - await user.click(await screen.findByRole('button', { name: /enable sharing/i })); - expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument(); - }); - - test('a ?share= link shows the read-only view without signing in', async () => { - window.history.pushState({}, '', '/?share=abc'); - const fallback = buildFetchMock(); - vi.stubGlobal('fetch', vi.fn((url, opts) => (url === '/api/share/abc' - ? json(200, { id: 's1', title: 'Shared Titus', translation: 'BSB', chapters: [] }) - : fallback(url, opts)))); - try { - render(); - expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument(); - expect(screen.getByTitle('Shared study')).toBeInTheDocument(); - } finally { - window.history.pushState({}, '', '/'); - } - }); -}); - -// --------------------------------------------------------------------------- -// Study editor (StudyPage local state + useCrossRefs / useWordStudy hooks) -// --------------------------------------------------------------------------- -describe('Study editor', () => { - test('study-page messages now show as a status toast', async () => { - await goToStudyWith3Chunks(); - // Titus is a New Testament book, so the Hebrew picker explains why it can't help. - fireEvent.click(screen.getByRole('tab', { name: /word study/i })); - fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i })); - expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i); - }); - - test('arrow keys move between chunks', async () => { - await goToStudyWith3Chunks(); - fireEvent.keyDown(window, { key: 'ArrowRight' }); - expect(await findChunkCounter(2, 3)).toBeInTheDocument(); - fireEvent.keyDown(window, { key: 'ArrowLeft' }); - expect(await findChunkCounter(1, 3)).toBeInTheDocument(); - }); - - test('a typed cross-reference is added, and the input clears on chunk change', async () => { - await goToStudyWith3Chunks(); - fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i })); - const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i); - fireEvent.change(input, { target: { value: 'Rom 3:23' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - expect(await screen.findByText('Rom 3:23')).toBeInTheDocument(); - fireEvent.change(input, { target: { value: 'half typed' } }); - fireEvent.click(screen.getByRole('button', { name: /next chunk/i })); - await waitFor(() => expect(input).toHaveValue('')); - }); - - test('a tag can be added with Enter', async () => { - await goToStudyWith3Chunks(); - fireEvent.click(screen.getByRole('button', { name: /^more/i })); - const input = screen.getByPlaceholderText(/add a tag/i); - fireEvent.change(input, { target: { value: 'grace' } }); - fireEvent.keyDown(input, { key: 'Enter' }); - expect(await screen.findByText(/#?grace/)).toBeInTheDocument(); - expect(input).toHaveValue(''); - }); -}); - -// --------------------------------------------------------------------------- -// Chunk builder (useChunkBuilder, local to SetupPage) -// --------------------------------------------------------------------------- -describe('Chunk builder', () => { - async function addSecondChapter(user) { - await user.click(screen.getByRole('button', { name: /add chapter/i })); - // The add-chapter form starts on the chapter after the project's last one. - expect(screen.getByDisplayValue('2')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /load chapter/i })); - return screen.findByRole('button', { name: 'Titus 2' }); - } - - test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => { - const user = await loadChapter(); - expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true'); - }); - - test('the Chunks stage reopens the chapter of the chunk being studied', async () => { - const user = await loadChapter(); - await addSecondChapter(user); - addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected) - await user.click(screen.getByRole('button', { name: 'Titus 1' })); - await user.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(1, 1); - await user.click(screen.getByRole('button', { name: /^chunks$/i })); - expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true'); - }); -}); - -// --------------------------------------------------------------------------- -// Stage bar (Chunks / Study / Outline / Script / Export) -// --------------------------------------------------------------------------- -describe('Stage bar', () => { - test('later stages are disabled until a chunk exists', async () => { - await loadChapter(); - for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) { - expect(screen.getByRole('button', { name })).toBeDisabled(); - } - expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled(); - }); - - test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => { - const user = await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /^script$/i })); - await user.click(await screen.findByRole('button', { name: /pronunciation guide/i })); - expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /back to script/i })); - expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument(); - }); - - test('a script written in the Script stage is kept on the chunk', async () => { - const user = await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /^script$/i })); - const script = await screen.findByLabelText(/final script/i); - fireEvent.change(script, { target: { value: 'Welcome to the episode.' } }); - await user.click(screen.getByRole('button', { name: /^study$/i })); - await user.click(screen.getByRole('button', { name: /^script$/i })); - expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument(); - }); -}); - - -// --------------------------------------------------------------------------- -// Study workspace: outline editing, notes accordion, and the resource tabs -// --------------------------------------------------------------------------- -describe('Study workspace', () => { - const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } }); - // Wraps the default mock so individual tests can answer specific URLs. - const withRoutes = (routes) => { - const base = buildFetchMock(); - return vi.fn((url, opts) => { - for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data); - return base(url, opts); - }); - }; - async function studyOneChunk(routes = []) { - if (routes.length) vi.stubGlobal('fetch', withRoutes(routes)); - const user = await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(1, 1); - return user; - } - - test('outline: clicking between verses ends a chunk; clicking again joins them', async () => { - const user = await loadChapter(); - await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i })); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i })); - await screen.findByText('2 created'); - await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i })); - await screen.findByText('1 created'); - expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0); - }); - - test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => { - const user = await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - await user.click(screen.getByRole('button', { name: /suggest from headings/i })); - await screen.findByText('2 created'); - expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i); - }); - - test('notes: one section is open at a time, and text is kept when switching', async () => { - const user = await studyOneChunk(); - fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } }); - await user.click(screen.getByRole('button', { name: /interpret/i })); - expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument(); - expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /observe/i })); - expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.'); - }); - - test('notes: the outline dots and word count reflect what you wrote', async () => { - await studyOneChunk(); - fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } }); - expect(screen.getByText('3 words')).toBeInTheDocument(); - }); - - test('interlinear: clicking an original word adds it to the word study', async () => { - const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]); - await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words - await user.click(await screen.findByRole('button', { name: /Παῦλος/ })); - expect(await screen.findByRole('status')).toHaveTextContent(/added/i); - expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument(); - expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card - }); - - test('commentary: "Add to notes" quotes a paragraph with its citation', async () => { - const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]); - await user.click(await screen.findByRole('button', { name: /add to notes/i })); - expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i); - await user.click(screen.getByRole('button', { name: /^more/i })); - expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)'); - }); - - test('people & places: lists what is tagged in the chunk', async () => { - const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]); - await user.click(screen.getByRole('tab', { name: /people & places/i })); - const panel = screen.getByRole('tabpanel'); - expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument(); - expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument(); - }); - - test('script: "Build outline from notes" starts the script from your notes', async () => { - const user = await studyOneChunk(); - fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } }); - await user.click(screen.getByRole('button', { name: /^script$/i })); - await user.click(await screen.findByRole('button', { name: /build outline from notes/i })); - const script = screen.getByLabelText(/final script/i); - expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.'); - expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument(); - }); -}); - - -// --------------------------------------------------------------------------- -// App bar and study layout -// --------------------------------------------------------------------------- -describe('App bar', () => { - test('shows the three destinations, and the account menu switches the theme', async () => { - const user = userEvent.setup(); - await renderApp(); - expect(screen.getByRole('button', { name: /^studies$/i })).toHaveAttribute('aria-current', 'page'); - expect(screen.getByRole('button', { name: /read bible/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /reading plans/i })).toBeInTheDocument(); - await openAccountMenu(user); - await user.click(screen.getByRole('button', { name: /^dark$/i })); - expect(localStorage.getItem('reader-theme')).toBe('dark'); - // Local-only mode has no signed-in user, so there is no Settings/Log out to show - expect(screen.queryByRole('menuitem', { name: /log out/i })).not.toBeInTheDocument(); - }); - - test('the account menu closes on Escape', async () => { - const user = userEvent.setup(); - await renderApp(); - await openAccountMenu(user); - expect(screen.getByRole('button', { name: /^paper$/i })).toBeInTheDocument(); - await user.keyboard('{Escape}'); - expect(screen.queryByRole('button', { name: /^paper$/i })).not.toBeInTheDocument(); - }); - - test('the brand button and Studies return Home from another page', async () => { - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /import session list/i })); - await screen.findByRole('heading', { name: /import session list/i }); - await user.click(screen.getByRole('button', { name: /bible study home/i })); - expect(await screen.findByText('My Studies')).toBeInTheDocument(); - }); -}); - -describe('Study layout', () => { - test('notes sit in their own box above the resources, not under the passage', async () => { - const u = await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - await u.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(1, 1); - const notes = screen.getByRole('region', { name: 'Notes' }); - const resources = screen.getByRole('region', { name: 'Resources' }); - expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument(); - expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument(); - // notes come first in the right column - expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - // and the passage column no longer holds them - expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument(); - }); -}); - -describe('Reading plan with more days than chapters', () => { - test('Continue reading opens the plan on a real chapter instead of stranding the Reader', async () => { - localStorage.setItem('readingPlanProgress', JSON.stringify({ - customPlans: [{ id: 'custom-luke', title: 'Luke', days: 90, books: ['LUK'], custom: true }], - progress: [{ id: 'rp1', planId: 'custom-luke', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now() }], - activity: [], - })); - const user = userEvent.setup(); - await renderApp(); - await user.click(await screen.findByRole('button', { name: /continue reading/i })); - expect(await screen.findByText(/Luke 3 · BSB/)).toBeInTheDocument(); - expect(screen.getAllByText(/Luke · Day 3/).length).toBeGreaterThan(0); - expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// Bookmarks and highlights follow the account -// --------------------------------------------------------------------------- -describe('Bookmark and highlight sync', () => { - const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false }; - const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...over }); - - // A fake API that keeps the account's marks, like the real server does. - function stubServer(initial = { bookmarks: {}, highlights: [], updatedAt: 0 }) { - const server = { ...initial, puts: [], prefs: initial.prefs ?? {}, prefPuts: [] }; - const fallback = buildFetchMock(); - const fetchMock = vi.fn((url, opts = {}) => { - if (!url.startsWith('/api/')) return fallback(url, opts); - const route = `${opts.method ?? 'GET'} ${url}`; - if (route === 'GET /api/auth/me') return json(200, user); - if (route === 'POST /api/auth/logout') return json(200, { ok: true }); - if (route === 'GET /api/projects') return json(200, []); - if (route === 'GET /api/reader/ink') return json(200, {}); - if (route === 'GET /api/reader/marks') return json(200, { bookmarks: server.bookmarks, highlights: server.highlights, updatedAt: server.updatedAt }); - if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs }); - if (route === 'PUT /api/prefs') { - const patch = JSON.parse(opts.body).prefs; - server.prefPuts.push(patch); - server.prefs = { ...server.prefs, ...patch }; - return json(200, { prefs: server.prefs }); - } - if (route === 'PUT /api/reader/marks') { - const body = JSON.parse(opts.body); - server.puts.push(body); - server.bookmarks = body.bookmarks; - server.highlights = body.highlights; - server.updatedAt = Math.max(Date.now(), server.updatedAt + 1); - return json(200, { ok: true, updatedAt: server.updatedAt }); - } - return json(404, { error: 'not mocked' }); - }); - vi.stubGlobal('fetch', fetchMock); - return server; - } - const local = (key) => JSON.parse(localStorage.getItem(key) ?? 'null'); - - test('a new device gets the account\'s bookmarks and highlights when you sign in', async () => { - stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 }); - await renderApp(); - await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' })); - expect(local('readerTextHighlights')).toEqual([hl()]); - }); - - test('marks already on this device are saved to an account that has none yet', async () => { - localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' })); - const server = stubServer(); - await renderApp(); - await waitFor(() => expect(server.puts).toHaveLength(1)); - expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0' }); - }); - - test('marks on both sides are merged, and the merge is saved back', async () => { - localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' })); - const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [], updatedAt: 500 }); - await renderApp(); - await waitFor(() => expect(server.puts).toHaveLength(1)); - expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' }); - expect(local('reader-bookmarks')).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' }); - }); - - test('a device that is up to date takes the newer server copy, dropping a removed bookmark', async () => { - localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' })); - localStorage.setItem('reader-marks-synced-at', '100'); - const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 900 }); // removed on another device - await renderApp(); - await waitFor(() => expect(local('reader-bookmarks')).toEqual({})); - expect(server.puts).toHaveLength(0); // nothing to send back - }); - - test('bookmarking a verse in the Reader saves it to the account', async () => { - const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 500 }); - localStorage.setItem('reader-marks-synced-at', '500'); - const u = userEvent.setup(); - await renderApp(); - await u.click(screen.getByRole('button', { name: /read bible/i })); - await u.click(await screen.findByText(/Paul, a servant of God/)); - await u.click(await screen.findByRole('button', { name: /bookmark verse/i })); - await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 }); - expect(Object.keys(server.puts[0].bookmarks)).toEqual(['GEN-1-1']); - }); - - test('signing out clears this device\'s copy so the next person starts clean', async () => { - const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 }); - const u = userEvent.setup(); - await renderApp(); - await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' })); - await u.click(await screen.findByRole('button', { name: /account menu/i })); - await u.click(await screen.findByRole('menuitem', { name: /log out/i })); - await waitFor(() => expect(localStorage.getItem('reader-marks-synced-at')).toBeNull()); - expect(local('reader-bookmarks')).toEqual({}); - expect(local('readerTextHighlights')).toEqual([]); - expect(server.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); // still safe on the account - }); -}); - -describe('Preference sync', () => { - const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false }; - const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - - function stubPrefsServer(prefs = {}) { - const server = { prefs, puts: [] }; - const fallback = buildFetchMock(); - vi.stubGlobal('fetch', vi.fn((url, opts = {}) => { - if (!url.startsWith('/api/')) return fallback(url, opts); - const route = `${opts.method ?? 'GET'} ${url}`; - if (route === 'GET /api/auth/me') return json(200, user); - if (route === 'POST /api/auth/logout') return json(200, { ok: true }); - if (route === 'GET /api/projects') return json(200, []); - if (route === 'GET /api/reader/ink') return json(200, {}); - if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 }); - if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs }); - if (route === 'PUT /api/prefs') { - const patch = JSON.parse(opts.body).prefs; - server.puts.push(patch); - server.prefs = { ...server.prefs, ...patch }; - return json(200, { prefs: server.prefs }); - } - return json(404, { error: 'not mocked' }); - })); - return server; - } - - test('signing in on a new device brings your theme, translation and place in the Reader', async () => { - stubPrefsServer({ - theme: { v: 'dark', at: 900 }, translation: { v: 'NASB', at: 900 }, layout: { v: 'verses', at: 900 }, - position: { v: { book: 'JHN', chapter: 3 }, at: 900 }, - }); - const u = userEvent.setup(); - await renderApp(); - await waitFor(() => expect(localStorage.getItem('reader-theme')).toBe('dark')); - expect(localStorage.getItem('reader-translation')).toBe('NASB'); - expect(localStorage.getItem('reader-layout')).toBe('verses'); - await u.click(screen.getByRole('button', { name: /read bible/i })); - expect(await screen.findByText(/John 3 · NASB/)).toBeInTheDocument(); - }); - - test('changing the theme is saved to the account', async () => { - const server = stubPrefsServer({ theme: { v: 'paper', at: 10 } }); - const u = userEvent.setup(); - await renderApp(); - await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); // initial fill-in of the other keys - server.puts.length = 0; - await u.click(await screen.findByRole('button', { name: /account menu/i })); - await u.click(screen.getByRole('button', { name: /^sepia$/i })); - await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 }); - expect(server.puts[0].theme.v).toBe('sepia'); - expect(Object.keys(server.puts[0])).toEqual(['theme']); // only what changed - }); - - test('the commentary you pick is shared between the Reader and the Study stage', async () => { - stubPrefsServer({ commentarySource: { v: 'tyndale', at: 900 } }); - await renderApp(); - await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale')); - }); - - async function openSettings(u) { - await u.click(await screen.findByRole('button', { name: /account menu/i })); - await u.click(await screen.findByRole('menuitem', { name: /settings/i })); - return screen.findByRole('region', { name: 'Preferences' }); - } - - test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => { - const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } }); - const u = userEvent.setup(); - await renderApp(); - await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); - server.puts.length = 0; - const prefs = within(await openSettings(u)); - expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB'); - await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV'); - expect(localStorage.getItem('reader-translation')).toBe('ESV'); - await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 }); - expect(server.puts[0].translation.v).toBe('ESV'); - }); - - test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => { - stubPrefsServer(); - const u = userEvent.setup(); - await renderApp(); - const prefs = within(await openSettings(u)); - await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' })); - await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' })); - await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale'); - await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT'); - expect(localStorage.getItem('reader-theme')).toBe('dark'); - expect(localStorage.getItem('reader-layout')).toBe('verses'); - expect(localStorage.getItem('reader-commentary')).toBe('tyndale'); - expect(localStorage.getItem('plan-translation')).toBe('NLT'); - }); - - test('text size, reading width and narrator are remembered on this device (not synced)', async () => { - const server = stubPrefsServer(); - const u = userEvent.setup(); - await renderApp(); - await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); - server.puts.length = 0; - const prefs = within(await openSettings(u)); - await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' })); - await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' })); - await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays'); - expect(localStorage.getItem('reader-font-size')).toBe('1.25'); - expect(localStorage.getItem('reader-wide')).toBe('1'); - expect(localStorage.getItem('audio-narrator')).toBe('hays'); - await new Promise((r) => setTimeout(r, 1500)); - expect(server.puts).toHaveLength(0); // per-device settings stay off the account - }); - - test('Settings leads back to the dashboard', async () => { - stubPrefsServer(); - const u = userEvent.setup(); - await renderApp(); - await openSettings(u); - await u.click(screen.getByRole('button', { name: /bible study home/i })); - expect(await screen.findByText('My Studies')).toBeInTheDocument(); - }); - - test('a new reading plan starts in the Bible chosen for new plans', async () => { - localStorage.setItem('plan-translation', 'CSB'); - stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } }); - const u = userEvent.setup(); - await renderApp(); - await u.click(screen.getByRole('button', { name: /reading plans/i })); - await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ })); - expect(screen.getByRole('combobox')).toHaveValue('CSB'); - }); - - test('signing out forgets the sync timestamps', async () => { - stubPrefsServer({ theme: { v: 'dark', at: 900 } }); - const u = userEvent.setup(); - await renderApp(); - await waitFor(() => expect(localStorage.getItem('prefs-meta')).not.toBeNull()); - await u.click(await screen.findByRole('button', { name: /account menu/i })); - await u.click(await screen.findByRole('menuitem', { name: /log out/i })); - await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull()); - }); -}); - -// --------------------------------------------------------------------------- -// Every page has a way back to the dashboard -// --------------------------------------------------------------------------- -describe('Back to the dashboard', () => { - const home = /back to home|bible study home/i; - const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument(); - - test.each([ - ['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }], - ['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }], - ['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }], - ])('%s', async (_name, open) => { - const u = userEvent.setup(); - await renderApp(); - await open(u); - await u.click(screen.getByRole('button', { name: home })); - await dashboard(); - }); - - test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => { - const u = await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') })); - await screen.findByRole('navigation', { name: /study stages/i }); - await u.click(screen.getByRole('button', { name: home })); - await dashboard(); - }); - - test('the Pronunciation guide', async () => { - const u = await loadChapter(); - addChunk('Paul, a servant', 'Paul, a servant'); - await screen.findByText('1 created'); - await u.click(screen.getByRole('button', { name: /^script$/i })); - await u.click(await screen.findByRole('button', { name: /pronunciation guide/i })); - await screen.findByRole('heading', { name: /pronunciation guide/i }); - await u.click(screen.getByRole('button', { name: home })); - await dashboard(); - }); -}); - -// --------------------------------------------------------------------------- -// The home page the user chooses -// --------------------------------------------------------------------------- -describe('Chosen home page', () => { - const homeBtn = /back to home|bible study home/i; - - test('the app opens on the chosen page', async () => { - localStorage.setItem('start-page', 'reader'); - render(); - expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument(); - expect(screen.queryByText('My Studies')).not.toBeInTheDocument(); - }); - - test('by default the app opens on My Studies', async () => { - await renderApp(); - expect(screen.getByText('My Studies')).toBeInTheDocument(); - }); - - test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => { - localStorage.setItem('start-page', 'reader'); - const u = userEvent.setup(); - render(); - await screen.findByText(/Genesis 1 · BSB/); - // already on the chosen page: Home means My Studies - await u.click(screen.getByRole('button', { name: homeBtn })); - expect(await screen.findByText('My Studies')).toBeInTheDocument(); - // from another page, Home is the chosen page - await u.click(screen.getByRole('button', { name: /reading plans/i })); - await screen.findByRole('heading', { name: /^reading plans$/i }); - await u.click(screen.getByRole('button', { name: homeBtn })); - expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument(); - }); - - test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => { - localStorage.setItem('start-page', 'plans'); - const u = userEvent.setup(); - render(); - await screen.findByRole('heading', { name: /^reading plans$/i }); // opened on Reading Plans - await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies - await screen.findByText('My Studies'); - await u.click(screen.getByRole('button', { name: /import session list/i })); - await screen.findByRole('heading', { name: /import session list/i }); - await u.click(screen.getByRole('button', { name: /bible study home/i })); - expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument(); - await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again - await u.click(screen.getByRole('button', { name: /import session list/i })); - await u.click(screen.getByRole('button', { name: /^studies$/i })); - expect(await screen.findByText('My Studies')).toBeInTheDocument(); - }); - - test('leaving a study with a different home page closes the study', async () => { - localStorage.setItem('start-page', 'plans'); - const u = userEvent.setup(); - render(); - await screen.findByRole('heading', { name: /^reading plans$/i }); - await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies - await u.click(screen.getAllByRole('button', { name: /new project/i })[0]); - await u.click(await screen.findByRole('button', { name: /load chapter/i })); - await screen.findByText(/Scripture & Chunks/i); - await u.click(screen.getByRole('button', { name: homeBtn })); - expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument(); - }); - - test('Settings changes it, and it is saved for next time', async () => { - const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false }; - const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) }); - const fallback = buildFetchMock(); - vi.stubGlobal('fetch', vi.fn((url, opts = {}) => { - if (!url.startsWith('/api/')) return fallback(url, opts); - const route = `${opts.method ?? 'GET'} ${url}`; - if (route === 'GET /api/auth/me') return json(200, user); - if (route === 'GET /api/projects') return json(200, []); - if (route === 'GET /api/reader/ink') return json(200, {}); - if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 }); - if (route === 'GET /api/prefs') return json(200, { prefs: {} }); - if (route === 'PUT /api/prefs') return json(200, { prefs: {} }); - return json(404, { error: 'not mocked' }); - })); - const u = userEvent.setup(); - await renderApp(); - await u.click(await screen.findByRole('button', { name: /account menu/i })); - await u.click(await screen.findByRole('menuitem', { name: /settings/i })); - const prefs = within(await screen.findByRole('region', { name: 'Preferences' })); - await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' })); - expect(localStorage.getItem('start-page')).toBe('plans'); - await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page - expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument(); - }); -}); - -describe('Resizable study column', () => { - async function openStudy() { - const u = await loadChapter(); - addChunk('Paul, a servant', 'In hope of eternal'); - await screen.findByText('1 created'); - await u.click(screen.getByRole('button', { name: /begin studying/i })); - await findChunkCounter(1, 1); - return u; - } - const columns = () => screen.getByRole('separator', { name: /resize side panel/i }).closest('aside').parentElement; - - test('the handle widens and narrows the right column and the choice is remembered', async () => { - const u = await openStudy(); - const handle = screen.getByRole('separator', { name: /resize side panel/i }); - expect(columns().style.getPropertyValue('--study-right')).toBe(''); // default width - handle.focus(); - await u.keyboard('{ArrowLeft}'); - expect(columns().style.getPropertyValue('--study-right')).toBe('472px'); - expect(localStorage.getItem('study-right-width')).toBe('472'); - await u.keyboard('{Shift>}{ArrowRight}{/Shift}'); - expect(columns().style.getPropertyValue('--study-right')).toBe('376px'); - }); - - test('double-clicking the handle restores the default width', async () => { - const u = await openStudy(); - const handle = screen.getByRole('separator', { name: /resize side panel/i }); - handle.focus(); - await u.keyboard('{ArrowLeft}'); - await u.dblClick(handle); - expect(localStorage.getItem('study-right-width')).toBeNull(); - expect(columns().style.getPropertyValue('--study-right')).toBe(''); - }); - - test('a saved width is applied when the study opens', async () => { - localStorage.setItem('study-right-width', '560'); - await openStudy(); - expect(columns().style.getPropertyValue('--study-right')).toBe('560px'); - }); -}); - -describe('Import template', () => { - test('the Import page offers a Word template to download', async () => { - const user = userEvent.setup(); - await renderApp(); - await user.click(screen.getByRole('button', { name: /import session list/i })); - await user.click(await screen.findByRole('button', { name: /download template/i })); - await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalled()); - const blob = URL.createObjectURL.mock.calls.at(-1)[0]; - expect(blob.type).toMatch(/wordprocessingml/); - // and the button comes back to normal afterwards - expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled(); - }); -}); - -describe('System chrome follows the theme', () => { - test('the app tells the browser/OS the theme colour and scheme, and updates it when the theme changes', async () => { - localStorage.setItem('reader-theme', 'dark'); - const user = userEvent.setup(); - await renderApp(); - const themeColor = () => document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content'); - expect(themeColor()).toBe('#262421'); - expect(document.documentElement.style.colorScheme).toBe('dark'); - await user.click(screen.getByRole('button', { name: /account menu/i })); - await user.click(screen.getByRole('button', { name: /^paper$/i })); - expect(themeColor()).toBe('#f1ece0'); - expect(document.documentElement.style.colorScheme).toBe('light'); - }); -}); - -describe('New pages start at the top', () => { - test('moving to another page resets the scroll position', async () => { - const user = userEvent.setup(); - await renderApp(); - const scroller = document.scrollingElement ?? document.documentElement; - scroller.scrollTop = 480; // scrolled down on the dashboard - await user.click(screen.getByRole('button', { name: /reading plans/i })); - await screen.findByRole('heading', { name: /^reading plans$/i }); - expect(scroller.scrollTop).toBe(0); - scroller.scrollTop = 300; - await user.click(screen.getByRole('button', { name: /back to home|bible study home/i })); - await screen.findByText('My Studies'); - expect(scroller.scrollTop).toBe(0); - }); -}); diff --git a/src/components/ResetPasswordForm.jsx b/src/components/ResetPasswordForm.jsx new file mode 100644 index 0000000..0a52fb2 --- /dev/null +++ b/src/components/ResetPasswordForm.jsx @@ -0,0 +1,82 @@ +// "Forgot password": ask for a code (it is sent to the account's notification topic), then choose a new +// password with it. Shown on the sign-in page in place of the sign-in form. +import { useState } from 'react'; +import { requestPasswordReset, resetPassword } from '../syncService.js'; + +const input = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]'; +const primary = 'w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50'; + +export default function ResetPasswordForm({ initialEmail = '', onDone, onBack }) { + const [step, setStep] = useState('ask'); // 'ask' | 'code' | 'done' + const [email, setEmail] = useState(initialEmail); + const [form, setForm] = useState({ code: '', password: '', token: '' }); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const ask = async (e) => { + e.preventDefault(); + setError(''); setBusy(true); + const result = await requestPasswordReset(email.trim()); + setBusy(false); + if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; } + setMessage(result.data?.message ?? 'If that account has a notification topic, a code was sent.'); + setStep('code'); + }; + + const reset = async (e) => { + e.preventDefault(); + setError(''); setBusy(true); + const result = await resetPassword({ email: email.trim(), code: form.code, newPassword: form.password, token: form.token.trim() || undefined }); + setBusy(false); + if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; } + setStep('done'); + }; + + if (step === 'done') { + return ( +
+

Password changed

+

You’ve been signed out everywhere. Sign in with your new password.

+ +
+ ); + } + + return ( +
+

Reset your password

+ {step === 'ask' ? ( +
+

We’ll send a one-time code to the notification topic you set up for reading reminders (ntfy).

+ + {error &&

{error}

} + + +
+ ) : ( +
+ {message &&

{message}

} + + + + + {error &&

{error}

} + +
+ )} + +
+ ); +} diff --git a/src/components/ResetPasswordForm.test.jsx b/src/components/ResetPasswordForm.test.jsx new file mode 100644 index 0000000..37704e5 --- /dev/null +++ b/src/components/ResetPasswordForm.test.jsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; + +vi.mock('../syncService.js', () => ({ requestPasswordReset: vi.fn(), resetPassword: vi.fn() })); +import ResetPasswordForm from './ResetPasswordForm.jsx'; +import { requestPasswordReset, resetPassword } from '../syncService.js'; + +beforeEach(() => { + vi.clearAllMocks(); + requestPasswordReset.mockResolvedValue({ ok: true, data: { message: 'A code was sent if you have a topic.' } }); + resetPassword.mockResolvedValue({ ok: true, data: { ok: true } }); +}); + +test('asks for a code, then sets the new password with it', async () => { + const user = userEvent.setup(); + const onDone = vi.fn(); + render( {}} />); + await user.click(screen.getByRole('button', { name: /send me a code/i })); + expect(requestPasswordReset).toHaveBeenCalledWith('me@x.co'); + expect(await screen.findByText(/A code was sent/)).toBeInTheDocument(); + + await user.type(screen.getByLabelText(/^Code/), 'ABCD2345'); + await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass'); + await user.click(screen.getByRole('button', { name: /change password/i })); + expect(resetPassword).toHaveBeenCalledWith({ email: 'me@x.co', code: 'ABCD2345', newPassword: 'brand-new-pass', token: undefined }); + await user.click(await screen.findByRole('button', { name: /back to sign in/i })); + expect(onDone).toHaveBeenCalled(); +}); + +test('a wrong code shows the server\'s message and stays on the form', async () => { + resetPassword.mockResolvedValue({ ok: false, error: 'That code is wrong or has expired. Ask for a new one.' }); + const user = userEvent.setup(); + render( {}} onBack={() => {}} />); + await user.click(screen.getByRole('button', { name: /i already have a code/i })); + await user.type(screen.getByLabelText(/^Code/), 'WRONG123'); + await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass'); + await user.click(screen.getByRole('button', { name: /change password/i })); + expect(await screen.findByRole('alert')).toHaveTextContent(/wrong or has expired/); +}); + +test('an authenticator code is passed along when one is typed', async () => { + const user = userEvent.setup(); + render( {}} onBack={() => {}} />); + await user.click(screen.getByRole('button', { name: /i already have a code/i })); + await user.type(screen.getByLabelText(/^Code/), 'ABCD2345'); + await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass'); + await user.type(screen.getByLabelText(/Authenticator code/), '123456'); + await user.click(screen.getByRole('button', { name: /change password/i })); + expect(resetPassword).toHaveBeenCalledWith(expect.objectContaining({ token: '123456' })); +}); diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx index 52fcd5d..52f83ba 100644 --- a/src/components/StudyPanel.jsx +++ b/src/components/StudyPanel.jsx @@ -2,11 +2,31 @@ // public-domain / open sources, plus the people, places and events tied to the verse. import { useState } from 'react'; import { useApp } from '../context/AppContext.js'; +import { useIqAvailable } from '../hooks/useIq.js'; +import IqCommentary from './iq/IqCommentary.jsx'; import { COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync, } from '../hooks/useVerseStudy.js'; export function CommentaryTab({ book, chapter, verse }) { + const iq = useIqAvailable(); + const [more, setMore] = useState(() => { try { return localStorage.getItem('iq-commentary-mode') === '1'; } catch { return false; } }); + const setMode = (on) => { setMore(on); try { localStorage.setItem('iq-commentary-mode', on ? '1' : '0'); } catch { /* storage unavailable */ } }; + const chip = (on) => `rounded-full border px-2.5 py-0.5 text-[11px] ${on ? 'border-[var(--r-text,#334155)] bg-[var(--r-text,#334155)] text-[var(--r-bg,#ffffff)]' : 'border-[var(--r-line,#cbd5e1)] text-[var(--r-sub,#64748b)]'}`; + return ( +
+ {iq && ( +
+ + +
+ )} + {iq && more ? : } +
+ ); +} + +function ClassicCommentary({ book, chapter, verse }) { // The chosen commentary is a synced preference, shared with the Study stage const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp(); const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0]; diff --git a/src/components/iq/IqCommentary.jsx b/src/components/iq/IqCommentary.jsx new file mode 100644 index 0000000..bee3634 --- /dev/null +++ b/src/components/iq/IqCommentary.jsx @@ -0,0 +1,56 @@ +// More commentaries from IQ Bible: Gill and Matthew Henry plus hundreds of church-history sources. +// Only sources that actually have notes on the chapter being read are offered. +import { useState } from 'react'; +import { useIq } from '../../hooks/useIq.js'; +import { listOf, pickIqEntries } from '../../utils/iqbible.js'; + +const KEY = 'iq-commentary'; +const muted = 'text-xs text-[var(--r-sub,#64748b)]'; + +export default function IqCommentary({ book, chapter, verse }) { + const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } }); + const sources = useIq(`books/${book}/commentaries`, { chapter }); + const list = listOf(sources.data); + const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0]; + const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null); + const entries = pickIqEntries(notes.data?.entries, chapter, verse); + + const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } }; + const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {}); + + if (sources.status === 'loading') return

Loading…

; + if (sources.status === 'error') return

{sources.error}

; + if (!list.length) return

No IQ Bible commentaries cover this chapter.

; + + return ( +
+ +
+ {notes.status === 'loading' &&

Loading…

} + {notes.status === 'error' &&

{notes.error}

} + {(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && ( +

{source?.author_name} has no notes at or before verse {verse} in this chapter.

+ )} + {entries.map((e, i) => ( +
+

+ {e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''} + {e.source_title ? ` · ${e.source_title}` : ''} +

+
{e.text}
+
+ ))} +
+ {source &&

{source.author_name}{source.year ? `, ${source.year}` : ''} · via IQ Bible{source.license ? ` · ${source.license}` : ''}

} +
+ ); +} diff --git a/src/components/iq/IqDailyCard.jsx b/src/components/iq/IqDailyCard.jsx new file mode 100644 index 0000000..ff1866f --- /dev/null +++ b/src/components/iq/IqDailyCard.jsx @@ -0,0 +1,48 @@ +// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible. +import { useState } from 'react'; +import { useApp } from '../../context/AppContext.js'; +import { useIq, useIqAvailable } from '../../hooks/useIq.js'; +import { bookOptions } from '../../utils/bibleUtils.jsx'; + +export default function IqDailyCard() { + const on = useIqAvailable(); + if (!on) return null; + return ; +} + +function Card() { + const { jumpToReaderVerse, openBibleReader } = useApp(); + const votd = useIq('bibles/eng_kjv/verse-of-the-day'); + const dev = useIq('devotionals/today'); + const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening')); + const verse = votd.data?.data; + const reading = dev.data?.data?.[part]; + const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev; + if (!verse && !reading) return null; + return ( +
+ {verse && ( +
+

Verse of the day · KJV

+

{verse.text}

+ +
+ )} + {reading && ( +
+ Spurgeon, {part === 'morning' ? 'Morning' : 'Evening'}: “{reading.heading_text}” +
+ {['morning', 'evening'].map((p) => ( + + ))} +
+

{reading.heading_citation}

+
{reading.body}
+

Charles Spurgeon, Morning and Evening (1866), via IQ Bible.

+
+ )} +
+ ); +} diff --git a/src/components/iq/IqLexicon.jsx b/src/components/iq/IqLexicon.jsx new file mode 100644 index 0000000..96c7aef --- /dev/null +++ b/src/components/iq/IqLexicon.jsx @@ -0,0 +1,87 @@ +// A word's entry in the original-language lexicons: Strong's, then Brown-Driver-Briggs (Hebrew) or +// Liddell-Scott-Jones and Abbott-Smith (Greek), found through IQ Bible's Strong's crosswalk. +import { useState } from 'react'; +import DOMPurify from 'dompurify'; +import { useIq } from '../../hooks/useIq.js'; +import { listOf, normalizeStrongs } from '../../utils/iqbible.js'; + +const muted = 'text-xs text-[var(--r-sub,#64748b)]'; +const SOURCES = { bdb: 'Brown-Driver-Briggs', lsj: 'Liddell-Scott-Jones', 'abbott-smith': 'Abbott-Smith' }; +const html = (s) => DOMPurify.sanitize(String(s ?? ''), { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'span', 'ul', 'ol', 'li', 'sup', 'sub'], ALLOWED_ATTR: [] }); + +function SourceEntry({ lang, source, nativeKey }) { + const { status, data, error } = useIq(`lexicon/${lang}/${source}/${encodeURIComponent(nativeKey)}`); + const entry = listOf(data)[0]; + return ( +
+ {SOURCES[source] ?? source} +
+ {status === 'loading' &&

Loading…

} + {status === 'error' &&

{error}

} + {status === 'empty' &&

No entry.

} + {entry && source === 'bdb' && ( + <> +

{entry.head_word} {entry.pos}

+ {entry.primary_gloss &&

{entry.primary_gloss}

} +
+ + )} + {entry && source !== 'bdb' && ( + <> +

{entry.greek} {entry.transliteration}{entry.morph ? ` · ${entry.morph}` : ''}

+ {entry.gloss &&

{entry.gloss}

} +
+ + )} +
+
+ ); +} + +function Occurrences({ strongs }) { + const { status, data, error } = useIq('original-language/search', { strongs, limit: 20 }); + if (status === 'loading') return

Loading…

; + if (status === 'error') return

{error}

; + const list = data?.occurrences ?? []; + if (!list.length) return

No occurrences found.

; + return ( +

+ {list.map((o) => `${o.name_en} ${o.chapter}:${o.verse}`).join(' · ')} + {data.count >= 20 && … first 20 shown} +

+ ); +} + +export default function IqLexicon({ strongs: raw, onClose }) { + const strongs = normalizeStrongs(raw); + const lang = strongs?.startsWith('H') ? 'hebrew' : 'greek'; + const basic = useIq(strongs ? `lexicon/${lang}/strongs/${strongs}` : null); + const cross = useIq(strongs ? `lexicon/crosswalk/${strongs}` : null); + const [showOcc, setShowOcc] = useState(false); + if (!strongs) return null; + const strong = listOf(basic.data)[0]; + const sources = listOf(cross.data).filter((c) => SOURCES[c.source] && c.native_key && (lang === 'hebrew' ? c.source === 'bdb' : c.source !== 'bdb')); + const seen = new Set(); + const unique = sources.filter((c) => { const k = `${c.source}/${c.native_key}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 4); + + return ( +
+
+

{strongs}{strong?.occurences ? {strong.occurences}× in the Bible : null}

+ +
+ {basic.status === 'loading' &&

Loading…

} + {basic.status === 'error' &&

{basic.error}

} + {strong &&

{strong.glossary}

} +
+ {cross.status === 'loading' &&

Finding other lexicons…

} + {unique.map((c) => )} +
+ + {showOcc &&
} +

Lexicons via IQ Bible (Strong's, BDB, LSJ, Abbott-Smith; see credits there).

+
+ ); +} diff --git a/src/components/iq/IqNarratorOptions.jsx b/src/components/iq/IqNarratorOptions.jsx new file mode 100644 index 0000000..aff90fb --- /dev/null +++ b/src/components/iq/IqNarratorOptions.jsx @@ -0,0 +1,16 @@ +//