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' ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
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 @@
+//