Speed up the app, run production build, remove chunk ink, start App.jsx split
Performance: - Lazy-load pages, docx, and mammoth (initial JS 1,298 KB -> ~290 KB) - Gzip responses and cache hashed assets for a year - Memoize DrawCanvas strokes so ink isn't redrawn on every render - Precompute whole-Bible search index and defer the query while typing - Reload once if a lazy chunk is missing after a deploy Deployment: - setup.sh builds and runs one production server on APP_PORT (default 5173); --dev keeps the old Vite dev setup - Session cookie uses secure: 'auto' so login works behind any proxy Chunk editor: - Remove the Draw layout, Ink Notes panel, and ink in HTML export (existing inkStrokes data is left in saved projects) Refactor: - Move module-level helpers out of App.jsx into src/utils/* - Extract Bible reader state and logic into hooks/useBibleReader Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00932dee4a
commit
ade7f1af6f
20 files changed
+1312
-1250
No files matched your search
@@ -12,7 +12,7 @@ A self-hosted Bible study tool with notes, projects, and user accounts.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start (Development)
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/nmemmert/study-bible-project.git
|
git clone https://github.com/nmemmert/study-bible-project.git
|
||||||
@@ -20,24 +20,26 @@ cd study-bible-project
|
|||||||
bash setup.sh
|
bash setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`setup.sh` installs dependencies, finds a free port, and starts both servers:
|
`setup.sh` installs dependencies, builds the optimized frontend, and runs one
|
||||||
|
production server (frontend + API) on **http://localhost:5173**. Set `APP_PORT`
|
||||||
|
to use a different port. If the build fails, the previously running server is
|
||||||
|
left untouched. `bash restart.sh` pulls the latest code and does the same.
|
||||||
|
|
||||||
| Server | Default URL |
|
**Stop / logs:**
|
||||||
|--------|-------------|
|
|
||||||
| API (Express) | http://localhost:3001 |
|
|
||||||
| Frontend (Vite) | http://localhost:5173 |
|
|
||||||
|
|
||||||
**Stop the servers:**
|
|
||||||
```bash
|
```bash
|
||||||
kill $(cat .api.pid) $(cat .vite.pid)
|
kill $(cat .api.pid)
|
||||||
|
tail -f .api.log
|
||||||
```
|
```
|
||||||
|
|
||||||
**View logs:**
|
### Development mode (hot reload)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tail -f .api.log # API server
|
bash setup.sh --dev
|
||||||
tail -f .vite.log # Vite dev server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Starts the API on http://localhost:3001 and the Vite dev server on
|
||||||
|
http://localhost:5173. Dev mode is much slower, so don't use it for real users.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production Install (Ubuntu / systemd)
|
## Production Install (Ubuntu / systemd)
|
||||||
|
|||||||
Generated
+80
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"compression": "^1.8.2",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"docx": "^9.7.0",
|
"docx": "^9.7.0",
|
||||||
"dompurify": "^3.4.9",
|
"dompurify": "^3.4.9",
|
||||||
@@ -2781,6 +2782,85 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compressible": {
|
||||||
|
"version": "2.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||||
|
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": ">= 1.43.0 < 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression": {
|
||||||
|
"version": "1.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
|
||||||
|
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "3.1.2",
|
||||||
|
"compressible": "~2.0.18",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"negotiator": "~0.6.4",
|
||||||
|
"on-headers": "~1.1.0",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/negotiator": {
|
||||||
|
"version": "0.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||||
|
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/compression/node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"compression": "^1.8.2",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"docx": "^9.7.0",
|
"docx": "^9.7.0",
|
||||||
"dompurify": "^3.4.9",
|
"dompurify": "^3.4.9",
|
||||||
|
|||||||
+4
-20
@@ -3,28 +3,12 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
stop_pid_file() {
|
|
||||||
local pid_file="$1"
|
|
||||||
local label="$2"
|
|
||||||
if [ -f "$pid_file" ]; then
|
|
||||||
local old_pid
|
|
||||||
old_pid=$(cat "$pid_file")
|
|
||||||
if kill -0 "$old_pid" 2>/dev/null; then
|
|
||||||
echo "Stopping $label (PID $old_pid)..."
|
|
||||||
kill "$old_pid"
|
|
||||||
sleep 1
|
|
||||||
fi
|
|
||||||
rm -f "$pid_file"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_pid_file "$ROOT_DIR/.api.pid" "API server"
|
|
||||||
stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server"
|
|
||||||
|
|
||||||
echo "Pulling latest changes from Gitea..."
|
echo "Pulling latest changes from Gitea..."
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
git checkout -- package-lock.json 2>/dev/null || true
|
git checkout -- package-lock.json 2>/dev/null || true
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
echo "Restarting via setup.sh..."
|
# setup.sh builds first and only then stops the old server, so the site
|
||||||
exec "$ROOT_DIR/setup.sh"
|
# stays up during the build (and if the build fails). Pass --dev through.
|
||||||
|
echo "Rebuilding and restarting via setup.sh..."
|
||||||
|
exec "$ROOT_DIR/setup.sh" "$@"
|
||||||
+19
-2
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
@@ -48,6 +49,8 @@ function checkAuthRateLimit(ip) {
|
|||||||
return e.count > 10;
|
return e.count > 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
|
||||||
|
app.use(compression());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: new SqliteSessionStore(),
|
store: new SqliteSessionStore(),
|
||||||
@@ -57,7 +60,10 @@ app.use(session({
|
|||||||
rolling: true,
|
rolling: true,
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isProd,
|
// 'auto' = Secure when the request arrived over HTTPS (via the proxy's
|
||||||
|
// X-Forwarded-Proto). A hard `true` silently breaks login if the proxy
|
||||||
|
// doesn't send that header, or when the app is opened over plain http.
|
||||||
|
secure: isProd ? 'auto' : false,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
},
|
},
|
||||||
@@ -518,8 +524,19 @@ app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
if (isProd) {
|
if (isProd) {
|
||||||
const distPath = join(__dirname, '..', 'dist');
|
const distPath = join(__dirname, '..', 'dist');
|
||||||
app.use(express.static(distPath));
|
// Vite's /assets files have content hashes in their names, so they can be
|
||||||
|
// cached forever. Other static files (interlinear JSON, icons) cache for a
|
||||||
|
// day; index.html and the service worker always revalidate so deploys show up.
|
||||||
|
const noCache = (res) => res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
app.use('/assets', express.static(join(distPath, 'assets'), { immutable: true, maxAge: '1y' }));
|
||||||
|
app.use(express.static(distPath, {
|
||||||
|
maxAge: '1d',
|
||||||
|
setHeaders: (res, path) => {
|
||||||
|
if (path.endsWith('index.html') || path.endsWith('sw.js')) noCache(res);
|
||||||
|
},
|
||||||
|
}));
|
||||||
app.get('*', (_req, res) => {
|
app.get('*', (_req, res) => {
|
||||||
|
noCache(res);
|
||||||
res.sendFile(join(distPath, 'index.html'));
|
res.sendFile(join(distPath, 'index.html'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Installs dependencies, builds the frontend, and runs the app.
|
||||||
|
#
|
||||||
|
# bash setup.sh Production: one Express server serves the optimized
|
||||||
|
# build + API on $APP_PORT (default 5173).
|
||||||
|
# bash setup.sh --dev Development: Express API on 3001 + Vite dev server
|
||||||
|
# with hot reload (slower; not for real users).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODE="prod"
|
||||||
|
if [ "${1:-}" = "--dev" ]; then
|
||||||
|
MODE="dev"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Production serves on the port the Vite dev server used to use, so an
|
||||||
|
# existing reverse proxy pointed at it keeps working. Override with APP_PORT=.
|
||||||
|
APP_PORT="${APP_PORT:-5173}"
|
||||||
PREFERRED_VITE_PORT=5173
|
PREFERRED_VITE_PORT=5173
|
||||||
MAX_VITE_PORT=5200
|
MAX_VITE_PORT=5200
|
||||||
API_PORT=3001
|
API_PORT=3001
|
||||||
@@ -41,7 +55,7 @@ stop_pid_file() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "=== Bible Study App Setup ==="
|
echo "=== Bible Study App Setup ($MODE) ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ── Node check ───────────────────────────────────────────────────────────────
|
# ── Node check ───────────────────────────────────────────────────────────────
|
||||||
@@ -68,7 +82,35 @@ echo ""
|
|||||||
echo "Installing dependencies..."
|
echo "Installing dependencies..."
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# ── Find a free Vite port ─────────────────────────────────────────────────────
|
# ── Production ────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$MODE" = "prod" ]; then
|
||||||
|
# Build before stopping anything, so a failed build leaves the running site up.
|
||||||
|
echo ""
|
||||||
|
echo "Building frontend..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
stop_pid_file "$API_PID_FILE" "server"
|
||||||
|
stop_pid_file "$VITE_PID_FILE" "Vite dev server"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Starting production server on http://localhost:$APP_PORT"
|
||||||
|
echo "Logs: $API_LOG_FILE"
|
||||||
|
|
||||||
|
NODE_ENV=production PORT="$APP_PORT" \
|
||||||
|
nohup node "$ROOT_DIR/server/index.js" > "$API_LOG_FILE" 2>&1 &
|
||||||
|
echo $! > "$API_PID_FILE"
|
||||||
|
echo "Server PID: $(cat "$API_PID_FILE")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To stop: kill \$(cat .api.pid)"
|
||||||
|
echo "To view logs: tail -f .api.log"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Development ───────────────────────────────────────────────────────────────
|
||||||
|
stop_pid_file "$API_PID_FILE" "API server"
|
||||||
|
stop_pid_file "$VITE_PID_FILE" "Vite server"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
VITE_PORT=$(find_port)
|
VITE_PORT=$(find_port)
|
||||||
if [ -z "$VITE_PORT" ]; then
|
if [ -z "$VITE_PORT" ]; then
|
||||||
@@ -82,11 +124,6 @@ else
|
|||||||
echo "Port $VITE_PORT is available for Vite."
|
echo "Port $VITE_PORT is available for Vite."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Stop any previously running instances ─────────────────────────────────────
|
|
||||||
stop_pid_file "$API_PID_FILE" "API server"
|
|
||||||
stop_pid_file "$VITE_PID_FILE" "Vite server"
|
|
||||||
|
|
||||||
# ── Start Express API server ──────────────────────────────────────────────────
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Starting API server on http://localhost:$API_PORT"
|
echo "Starting API server on http://localhost:$API_PORT"
|
||||||
echo "API logs: $API_LOG_FILE"
|
echo "API logs: $API_LOG_FILE"
|
||||||
@@ -98,7 +135,6 @@ echo "API server PID: $(cat "$API_PID_FILE")"
|
|||||||
# Give the API a moment to initialise before Vite starts
|
# Give the API a moment to initialise before Vite starts
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# ── Start Vite dev server ─────────────────────────────────────────────────────
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Starting Vite dev server on http://localhost:$VITE_PORT"
|
echo "Starting Vite dev server on http://localhost:$VITE_PORT"
|
||||||
echo "Vite logs: $VITE_LOG_FILE"
|
echo "Vite logs: $VITE_LOG_FILE"
|
||||||
|
|||||||
+51
-1097
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -130,7 +130,7 @@ describe('Initial render', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await renderApp();
|
await renderApp();
|
||||||
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||||
expect(screen.getByText('Project Setup')).toBeInTheDocument();
|
expect(await screen.findByText('Project Setup')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// State, data loading, and helpers for the read-only Bible reader page.
|
||||||
|
// Audio playback stays in App because the study page's book player shares
|
||||||
|
// the same <audio> element.
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { bookOptions, parseBibleChapter } from '../utils/bibleUtils.jsx';
|
||||||
|
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
|
||||||
|
|
||||||
|
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||||
|
|
||||||
|
export function useBibleReader(currentPage) {
|
||||||
|
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||||
|
const [readerChapter, setReaderChapter] = useState(1);
|
||||||
|
const [readerVerses, setReaderVerses] = useState([]);
|
||||||
|
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
|
||||||
|
const [readerLoading, setReaderLoading] = useState(false);
|
||||||
|
const [readerError, setReaderError] = useState('');
|
||||||
|
const [readerInterlinear, setReaderInterlinear] = useState(null); // full book interlinear
|
||||||
|
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
|
||||||
|
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
|
||||||
|
const _readerInterlinearCacheRef = useRef({});
|
||||||
|
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
|
||||||
|
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
||||||
|
});
|
||||||
|
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
|
||||||
|
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
|
||||||
|
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
|
||||||
|
const [readerCrossRefsLoading, setReaderCrossRefsLoading] = useState(false);
|
||||||
|
const [readerShowCrossRefs, setReaderShowCrossRefs] = useState(false);
|
||||||
|
const _readerCrossRefCacheRef = useRef({});
|
||||||
|
const [readerSearch, setReaderSearch] = useState('');
|
||||||
|
const [readerSearchActive, setReaderSearchActive] = useState(false);
|
||||||
|
const [readerSearchScope, setReaderSearchScope] = useState('chapter'); // 'chapter' | 'bible'
|
||||||
|
const [bibleIndexStatus, setBibleIndexStatus] = useState('idle'); // 'idle' | 'loading' | 'ready' | 'error'
|
||||||
|
const _bibleIndexCacheRef = useRef({}); // translation -> flat verse array
|
||||||
|
const [readerInkByPage, setReaderInkByPage] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('readerInkByPage') ?? '{}'); }
|
||||||
|
catch { return {}; }
|
||||||
|
});
|
||||||
|
const [readerTextHighlights, setReaderTextHighlights] = useState(() => {
|
||||||
|
try {
|
||||||
|
const val = JSON.parse(localStorage.getItem('readerTextHighlights') || '[]');
|
||||||
|
return Array.isArray(val) ? val : [];
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
// Debounce timer ref so rapid stroke updates don't flood the server
|
||||||
|
const inkSaveTimerRef = useRef({});
|
||||||
|
|
||||||
|
// The HelloAO API has no search endpoint, so whole-Bible search fetches the
|
||||||
|
// entire translation once (~7MB) and searches an in-memory flat verse index
|
||||||
|
// client-side. Cached per translation for the rest of the session (not persisted
|
||||||
|
// across reloads, to avoid eating into localStorage/IndexedDB quota for a cache
|
||||||
|
// that's cheap enough to rebuild once per visit).
|
||||||
|
const loadBibleIndex = async () => {
|
||||||
|
if (_bibleIndexCacheRef.current.BSB) {
|
||||||
|
setBibleIndexStatus('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBibleIndexStatus('loading');
|
||||||
|
try {
|
||||||
|
// Check IndexedDB cache before hitting the network (~7 MB fetch)
|
||||||
|
const cached = await getBibleIndexFromIDB('BSB');
|
||||||
|
if (cached?.flat && Date.now() - cached.cachedAt < BIBLE_IDB_TTL) {
|
||||||
|
_bibleIndexCacheRef.current.BSB = cached.flat;
|
||||||
|
setBibleIndexStatus('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('https://bible.helloao.org/api/BSB/complete.json');
|
||||||
|
if (!res.ok) throw new Error('Unable to load the full Bible for search.');
|
||||||
|
const data = await res.json();
|
||||||
|
const flat = [];
|
||||||
|
for (const book of data.books ?? []) {
|
||||||
|
for (const ch of book.chapters ?? []) {
|
||||||
|
const verses = parseBibleChapter({ chapter: ch.chapter });
|
||||||
|
for (const v of verses) {
|
||||||
|
if (v.text) {
|
||||||
|
flat.push({ bookAbbrev: book.id, bookName: book.commonName || book.name, chapter: ch.chapter.number, verse: v.number, text: v.text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_bibleIndexCacheRef.current.BSB = flat;
|
||||||
|
setBibleIndexStatus('ready');
|
||||||
|
// Persist for future visits — non-blocking
|
||||||
|
saveBibleIndexToIDB('BSB', flat);
|
||||||
|
} catch {
|
||||||
|
setBibleIndexStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadReaderChapter = async (bookAbbrev, chapterNumber) => {
|
||||||
|
setReaderLoading(true);
|
||||||
|
setReaderError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/BSB/${bookAbbrev}/${chapterNumber}.json`);
|
||||||
|
if (!res.ok) throw new Error('Unable to load chapter.');
|
||||||
|
const data = await res.json();
|
||||||
|
const verses = parseBibleChapter(data);
|
||||||
|
if (!Array.isArray(verses) || verses.length === 0) {
|
||||||
|
throw new Error('Invalid Bible data returned.');
|
||||||
|
}
|
||||||
|
setReaderVerses(verses);
|
||||||
|
setReaderTotalChapters(data.book?.numberOfChapters ?? chapterNumber);
|
||||||
|
} catch (err) {
|
||||||
|
setReaderError(err.message || 'Failed to load chapter.');
|
||||||
|
setReaderVerses([]);
|
||||||
|
} finally {
|
||||||
|
setReaderLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage !== 'reader') return;
|
||||||
|
loadReaderChapter(readerBookAbbrev, readerChapter);
|
||||||
|
}, [currentPage, readerBookAbbrev, readerChapter]);
|
||||||
|
|
||||||
|
// After navigating to a verse (from the bookmarks panel or a whole-Bible search
|
||||||
|
// result), scroll to it and briefly flash its background so it's easy to spot.
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerJumpVerse == null || readerLoading) return;
|
||||||
|
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
const prevTransition = el.style.transition;
|
||||||
|
const prevBackground = el.style.backgroundColor;
|
||||||
|
el.style.transition = 'background-color 0.4s';
|
||||||
|
el.style.backgroundColor = '#fef08a';
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
el.style.backgroundColor = prevBackground;
|
||||||
|
window.setTimeout(() => { el.style.transition = prevTransition; }, 400);
|
||||||
|
}, 1200);
|
||||||
|
setReaderJumpVerse(null);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [readerVerses, readerLoading, readerJumpVerse]);
|
||||||
|
|
||||||
|
// Navigates the reader to a specific verse (bookmarks panel, search results) and
|
||||||
|
// scrolls/flashes it once that chapter's verses have loaded.
|
||||||
|
const jumpToReaderVerse = (bookAbbrev, chapter, verseNumber) => {
|
||||||
|
setReaderBookAbbrev(bookAbbrev);
|
||||||
|
setReaderChapter(chapter);
|
||||||
|
setReaderSelectedVerse(null);
|
||||||
|
setReaderInterlinear(null);
|
||||||
|
setReaderCrossRefs(null);
|
||||||
|
setReaderSearch('');
|
||||||
|
setReaderSearchActive(false);
|
||||||
|
setReaderBookmarksPanelOpen(false);
|
||||||
|
setReaderJumpVerse(verseNumber);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readerGoToPreviousChapter = () => {
|
||||||
|
if (readerChapter > 1) { setReaderChapter((c) => c - 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const readerGoToNextChapter = () => {
|
||||||
|
if (readerChapter < readerTotalChapters) { setReaderChapter((c) => c + 1); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReaderBookChange = (abbrev) => {
|
||||||
|
setReaderBookAbbrev(abbrev);
|
||||||
|
setReaderChapter(1);
|
||||||
|
setReaderSelectedVerse(null);
|
||||||
|
setReaderInterlinear(null);
|
||||||
|
setReaderCrossRefs(null);
|
||||||
|
setReaderSearch('');
|
||||||
|
setReaderSearchActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadReaderInterlinear = async (bookAbbrev) => {
|
||||||
|
if (_readerInterlinearCacheRef.current[bookAbbrev]) {
|
||||||
|
setReaderInterlinear(_readerInterlinearCacheRef.current[bookAbbrev]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReaderInterlinearLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/interlinear/${bookAbbrev}.json`);
|
||||||
|
if (!res.ok) throw new Error('no interlinear');
|
||||||
|
const data = await res.json();
|
||||||
|
_readerInterlinearCacheRef.current[bookAbbrev] = data;
|
||||||
|
setReaderInterlinear(data);
|
||||||
|
} catch {
|
||||||
|
setReaderInterlinear(null);
|
||||||
|
} finally {
|
||||||
|
setReaderInterlinearLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage !== 'reader') return;
|
||||||
|
loadReaderInterlinear(readerBookAbbrev);
|
||||||
|
setReaderSelectedVerse(null);
|
||||||
|
}, [currentPage, readerBookAbbrev]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage !== 'reader' || !readerShowCrossRefs) return;
|
||||||
|
loadReaderCrossRefs(readerBookAbbrev, readerChapter);
|
||||||
|
}, [currentPage, readerBookAbbrev, readerChapter, readerShowCrossRefs]);
|
||||||
|
|
||||||
|
// Bookmarks, ink, highlights, cross-refs
|
||||||
|
const toggleReaderBookmark = (verseKey) => {
|
||||||
|
setReaderBookmarks((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (next[verseKey]) {
|
||||||
|
delete next[verseKey];
|
||||||
|
} else {
|
||||||
|
const usedColors = Object.values(next);
|
||||||
|
const color = BOOKMARK_COLORS.find((c) => !usedColors.includes(c)) ?? BOOKMARK_COLORS[0];
|
||||||
|
next[verseKey] = color;
|
||||||
|
}
|
||||||
|
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
|
||||||
|
const key = `${bookAbbrev}_${chapter}`;
|
||||||
|
setReaderInkByPage((prev) => {
|
||||||
|
const next = { ...prev, [key]: strokes };
|
||||||
|
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Debounce server sync — wait 1 s after last stroke before sending
|
||||||
|
clearTimeout(inkSaveTimerRef.current[key]);
|
||||||
|
inkSaveTimerRef.current[key] = setTimeout(() => {
|
||||||
|
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ strokes }),
|
||||||
|
}).catch(() => {}); // silent — localStorage already saved locally
|
||||||
|
}, 1000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cycleBookmarkColor = (verseKey) => {
|
||||||
|
setReaderBookmarks((prev) => {
|
||||||
|
const cur = prev[verseKey];
|
||||||
|
if (!cur) return prev;
|
||||||
|
const idx = BOOKMARK_COLORS.indexOf(cur);
|
||||||
|
const next = { ...prev, [verseKey]: BOOKMARK_COLORS[(idx + 1) % BOOKMARK_COLORS.length] };
|
||||||
|
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTextHighlight = (highlight) => {
|
||||||
|
const id = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||||
|
setReaderTextHighlights(prev => {
|
||||||
|
const next = [...prev, { id, ...highlight }];
|
||||||
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTextHighlight = (id) => {
|
||||||
|
setReaderTextHighlights(prev => {
|
||||||
|
const next = prev.filter(h => h.id !== id);
|
||||||
|
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadReaderCrossRefs = async (bookAbbrev, chapterNumber) => {
|
||||||
|
const cacheKey = `${bookAbbrev}/${chapterNumber}`;
|
||||||
|
if (_readerCrossRefCacheRef.current[cacheKey]) {
|
||||||
|
setReaderCrossRefs(_readerCrossRefCacheRef.current[cacheKey]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReaderCrossRefsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${bookAbbrev}/${chapterNumber}.json`);
|
||||||
|
if (!res.ok) throw new Error('no cross-refs');
|
||||||
|
const data = await res.json();
|
||||||
|
const byVerse = {};
|
||||||
|
for (const item of data.chapter?.content ?? []) {
|
||||||
|
if (item.references?.length) byVerse[item.verse] = item.references;
|
||||||
|
}
|
||||||
|
_readerCrossRefCacheRef.current[cacheKey] = byVerse;
|
||||||
|
setReaderCrossRefs(byVerse);
|
||||||
|
} catch {
|
||||||
|
setReaderCrossRefs(null);
|
||||||
|
} finally {
|
||||||
|
setReaderCrossRefsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merges reader ink loaded from the server at sign-in; server wins over local.
|
||||||
|
const mergeServerInk = useCallback((serverInk) => {
|
||||||
|
setReaderInkByPage((local) => {
|
||||||
|
const merged = { ...local, ...serverInk };
|
||||||
|
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
readerBookAbbrev, setReaderBookAbbrev,
|
||||||
|
readerChapter, setReaderChapter,
|
||||||
|
readerVerses,
|
||||||
|
readerTotalChapters,
|
||||||
|
readerLoading,
|
||||||
|
readerError,
|
||||||
|
readerInterlinear,
|
||||||
|
readerSelectedVerse, setReaderSelectedVerse,
|
||||||
|
readerFontSize, setReaderFontSize,
|
||||||
|
readerBookmarks, setReaderBookmarks,
|
||||||
|
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||||
|
readerCrossRefs, setReaderCrossRefs,
|
||||||
|
readerCrossRefsLoading,
|
||||||
|
readerShowCrossRefs, setReaderShowCrossRefs,
|
||||||
|
readerSearch, setReaderSearch,
|
||||||
|
readerSearchActive, setReaderSearchActive,
|
||||||
|
readerSearchScope, setReaderSearchScope,
|
||||||
|
readerInkByPage,
|
||||||
|
updateReaderPageInk,
|
||||||
|
mergeServerInk,
|
||||||
|
readerTextHighlights,
|
||||||
|
addTextHighlight,
|
||||||
|
removeTextHighlight,
|
||||||
|
bibleIndexStatus,
|
||||||
|
loadBibleIndex,
|
||||||
|
_bibleIndexCacheRef,
|
||||||
|
loadReaderChapter,
|
||||||
|
handleReaderBookChange,
|
||||||
|
readerGoToPreviousChapter,
|
||||||
|
readerGoToNextChapter,
|
||||||
|
jumpToReaderVerse,
|
||||||
|
toggleReaderBookmark,
|
||||||
|
cycleBookmarkColor,
|
||||||
|
loadReaderCrossRefs,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,15 @@ if ('serviceWorker' in navigator) {
|
|||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pages are lazy-loaded chunks. If a deploy happened while this tab was open,
|
||||||
|
// the old chunk files are gone — reload once to pick up the new build.
|
||||||
|
window.addEventListener('vite:preloadError', () => {
|
||||||
|
if (sessionStorage.getItem('chunk-reload')) return;
|
||||||
|
sessionStorage.setItem('chunk-reload', '1');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
window.addEventListener('load', () => sessionStorage.removeItem('chunk-reload'));
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { renderInkToCanvas } from '../utils/inkRender.js';
|
import { renderInkToCanvas } from '../utils/inkRender.js';
|
||||||
|
|
||||||
@@ -35,8 +35,9 @@ export default function DrawCanvas({ strokes, onStrokesChange, onDone, headerCon
|
|||||||
const { pageCount: initPages } = extractMeta(strokes);
|
const { pageCount: initPages } = extractMeta(strokes);
|
||||||
const [pageCount, setPageCount] = useState(initPages);
|
const [pageCount, setPageCount] = useState(initPages);
|
||||||
|
|
||||||
// realStrokes = strokes without the metadata header
|
// realStrokes = strokes without the metadata header. Memoized so the canvas
|
||||||
const { realStrokes } = extractMeta(strokes);
|
// only redraws when the strokes actually change, not on every parent re-render.
|
||||||
|
const realStrokes = useMemo(() => extractMeta(strokes).realStrokes, [strokes]);
|
||||||
|
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const hoverRef = useRef(null);
|
const hoverRef = useRef(null);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
|
||||||
import DrawCanvas from './DrawCanvas.jsx';
|
import DrawCanvas from './DrawCanvas.jsx';
|
||||||
|
|
||||||
|
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
|
||||||
|
|
||||||
export default function ReaderPage() {
|
export default function ReaderPage() {
|
||||||
const {
|
const {
|
||||||
authStatus,
|
authStatus,
|
||||||
@@ -55,8 +57,27 @@ export default function ReaderPage() {
|
|||||||
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
|
||||||
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
const [readerDrawMode, setReaderDrawMode] = useState(false);
|
||||||
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
|
||||||
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? [];
|
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
|
||||||
const [selectionPicker, setSelectionPicker] = useState(null);
|
const [selectionPicker, setSelectionPicker] = useState(null);
|
||||||
|
|
||||||
|
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
|
||||||
|
// and let typing stay responsive while results catch up via useDeferredValue.
|
||||||
|
const bibleIndex = bibleIndexStatus === 'ready' ? (_bibleIndexCacheRef.current.BSB ?? null) : null;
|
||||||
|
const bibleIndexLower = useMemo(
|
||||||
|
() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null),
|
||||||
|
[bibleIndex],
|
||||||
|
);
|
||||||
|
const deferredBibleQuery = useDeferredValue(
|
||||||
|
readerSearchActive && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '',
|
||||||
|
);
|
||||||
|
const bibleMatches = useMemo(() => {
|
||||||
|
if (!bibleIndex || !deferredBibleQuery) return [];
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < bibleIndex.length; i++) {
|
||||||
|
if (bibleIndexLower[i].includes(deferredBibleQuery)) out.push(bibleIndex[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
|
||||||
const swipeTouchRef = useRef(null);
|
const swipeTouchRef = useRef(null);
|
||||||
|
|
||||||
const [toolbarVisible, setToolbarVisible] = useState(true);
|
const [toolbarVisible, setToolbarVisible] = useState(true);
|
||||||
@@ -444,10 +465,9 @@ export default function ReaderPage() {
|
|||||||
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
|
<p className="text-sm text-rose-600">Couldn't load the full Bible for search. <button type="button" onClick={loadBibleIndex} className="underline">Try again</button></p>
|
||||||
)}
|
)}
|
||||||
{bibleIndexStatus === 'ready' && (() => {
|
{bibleIndexStatus === 'ready' && (() => {
|
||||||
const q = readerSearch.trim().toLowerCase();
|
const q = deferredBibleQuery;
|
||||||
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
|
if (!q) return <p className="text-sm text-slate-500">Type at least a word to search all 66 books.</p>;
|
||||||
const index = _bibleIndexCacheRef.current.BSB ?? [];
|
const matches = bibleMatches;
|
||||||
const matches = index.filter((v) => v.text.toLowerCase().includes(q));
|
|
||||||
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
if (matches.length === 0) return <p className="text-sm text-slate-500">No verses match "{readerSearch}".</p>;
|
||||||
const shown = matches.slice(0, 100);
|
const shown = matches.slice(0, 100);
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-68
@@ -2,7 +2,6 @@ import { createPortal } from 'react-dom';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
|
import { STUDY_TABS, COMMENTARY_OPTIONS, NT_BOOK_NUMBER, formatChunkReference, CrossRefChip } from '../utils/bibleUtils.jsx';
|
||||||
import DrawCanvas from './DrawCanvas.jsx';
|
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
const {
|
const {
|
||||||
@@ -197,7 +196,6 @@ export default function StudyPage() {
|
|||||||
{[
|
{[
|
||||||
{ id: 'stacked', label: '☰ Stacked' },
|
{ id: 'stacked', label: '☰ Stacked' },
|
||||||
{ id: 'split', label: '◫ Split' },
|
{ id: 'split', label: '◫ Split' },
|
||||||
{ id: 'annotate', label: '✏ Draw' },
|
|
||||||
].map((m) => (
|
].map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -243,29 +241,6 @@ export default function StudyPage() {
|
|||||||
|
|
||||||
{selectedChunk ? (
|
{selectedChunk ? (
|
||||||
<div className="mt-6 space-y-6">
|
<div className="mt-6 space-y-6">
|
||||||
{studyLayout === 'annotate' ? (
|
|
||||||
<DrawCanvas
|
|
||||||
strokes={selectedChunk.inkStrokes ?? []}
|
|
||||||
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
|
|
||||||
onDone={() => setStudyLayout('stacked')}
|
|
||||||
headerContent={
|
|
||||||
<>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
|
|
||||||
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
|
|
||||||
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{selectedChunkVerses.map((verse) => (
|
|
||||||
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
|
|
||||||
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
|
|
||||||
{verse.text}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex lg:items-start lg:gap-6 lg:space-y-0' : ''}`}>
|
<div className={`space-y-6 ${studyLayout === 'split' ? 'lg:flex lg:items-start lg:gap-6 lg:space-y-0' : ''}`}>
|
||||||
{/* Scripture */}
|
{/* Scripture */}
|
||||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'scripture' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' ? 'lg:sticky lg:top-6 lg:flex-1 lg:basis-0 lg:min-w-0 lg:self-start' : ''}`}>
|
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'scripture' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' ? 'lg:sticky lg:top-6 lg:flex-1 lg:basis-0 lg:min-w-0 lg:self-start' : ''}`}>
|
||||||
@@ -515,47 +490,6 @@ export default function StudyPage() {
|
|||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline ink notes — collapsible, always available without switching to annotate mode */}
|
|
||||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'notes' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'notes' ? 'hidden' : ''}`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCollapsedSections((c) => ({ ...c, inlineInk: !c.inlineInk }))}
|
|
||||||
className="flex w-full items-center justify-between gap-2 text-left"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-slate-900">Ink Notes</h3>
|
|
||||||
<p className="text-xs text-slate-500">Draw diagrams or handwritten notes for this chunk.</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-slate-400">{collapsedSections.inlineInk ? '▸' : '▾'}</span>
|
|
||||||
</button>
|
|
||||||
{!collapsedSections.inlineInk && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<DrawCanvas
|
|
||||||
strokes={selectedChunk.inkStrokes ?? []}
|
|
||||||
onStrokesChange={(s) => updateChunk(selectedChunk.id, { inkStrokes: s })}
|
|
||||||
headerContent={
|
|
||||||
selectedChunkVerses.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Scripture · draw on me</span>
|
|
||||||
<span className="rounded-full bg-slate-200 px-2.5 py-0.5 text-xs font-semibold text-slate-600">
|
|
||||||
{formatChunkReference(project, selectedChunkChapterIndex, selectedChunk, '–')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{selectedChunkVerses.map((verse) => (
|
|
||||||
<p key={`${verse.chapter}-${verse.number}`} className="mb-1">
|
|
||||||
<span className="font-semibold text-slate-900">{verse.chapter}:{verse.number}.</span>{' '}
|
|
||||||
{verse.text}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cross-references */}
|
{/* Cross-references */}
|
||||||
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
<div className={`rounded-3xl border border-slate-200 bg-slate-50 p-5 ${mobileStudyTab !== 'crossRefs' ? 'max-sm:hidden' : ''} ${studyLayout === 'split' && activeStudyTab !== 'crossRefs' ? 'hidden' : ''}`}>
|
||||||
<button
|
<button
|
||||||
@@ -941,7 +875,6 @@ export default function StudyPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -955,7 +888,7 @@ export default function StudyPage() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
|
{/* Prev / Next — fixed bottom bar, visible while scrolling long chunks */}
|
||||||
{selectedChunk && studyLayout !== 'annotate' && (
|
{selectedChunk && (
|
||||||
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
|
<div className="fixed bottom-0 inset-x-0 z-20 border-t border-slate-200 bg-white/95 backdrop-blur-sm">
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+4
-6
@@ -7,12 +7,10 @@ import {
|
|||||||
parseBibleChapter,
|
parseBibleChapter,
|
||||||
wordTableHtml,
|
wordTableHtml,
|
||||||
buildExportHtml,
|
buildExportHtml,
|
||||||
buildMarkdownExport,
|
} from './utils/bibleUtils.jsx';
|
||||||
buildClaudePrompt,
|
import { buildMarkdownExport, buildClaudePrompt } from './utils/exportBuilders.js';
|
||||||
createParagraphsFromText,
|
import { migrateChunk, migrateProject } from './utils/projectStorage.js';
|
||||||
migrateChunk,
|
import { createParagraphsFromText } from './utils/docxExport.js';
|
||||||
migrateProject,
|
|
||||||
} from './App.jsx';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// storageKey
|
// storageKey
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// IndexedDB cache for the whole-Bible search index.
|
||||||
|
const BIBLE_IDB_NAME = 'study-app-bible-index';
|
||||||
|
const BIBLE_IDB_STORE = 'bible-index';
|
||||||
|
export const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function openBibleIndexDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
|
||||||
|
req.onupgradeneeded = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
|
||||||
|
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = (e) => resolve(e.target.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBibleIndexFromIDB(translation) {
|
||||||
|
try {
|
||||||
|
const db = await openBibleIndexDB();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
|
||||||
|
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
|
||||||
|
req.onsuccess = () => resolve(req.result ?? null);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBibleIndexToIDB(translation, flat) {
|
||||||
|
try {
|
||||||
|
const db = await openBibleIndexDB();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
|
||||||
|
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Non-fatal; in-memory cache still works for this session
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getStroke } from 'perfect-freehand';
|
|
||||||
import { pathFromStroke } from './inkRender.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Study constants
|
// Study constants
|
||||||
@@ -192,28 +190,6 @@ export function getChunkVerseEntries(project, startChapterIndex, chunk) {
|
|||||||
|
|
||||||
const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
function inkStrokesToSvgHtml(strokes) {
|
|
||||||
if (!strokes?.length) return '';
|
|
||||||
const W = 700, H = 500;
|
|
||||||
const lines = [];
|
|
||||||
for (let y = 32; y < H; y += 32)
|
|
||||||
lines.push(`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#dde3ec" stroke-width="1"/>`);
|
|
||||||
lines.push(`<line x1="40" y1="0" x2="40" y2="${H}" stroke="#fca5a5" stroke-width="1"/>`);
|
|
||||||
const paths = strokes.map((s) => {
|
|
||||||
const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]);
|
|
||||||
const outline = getStroke(pts, {
|
|
||||||
size: s.size * H,
|
|
||||||
thinning: s.tool === 'highlighter' ? 0 : 0.5,
|
|
||||||
smoothing: 0.5,
|
|
||||||
streamline: 0.4,
|
|
||||||
});
|
|
||||||
const d = pathFromStroke(outline);
|
|
||||||
if (!d) return '';
|
|
||||||
return `<path d="${d}" fill="${escHtml(s.color)}" opacity="${s.tool === 'highlighter' ? '0.35' : '1'}"/>`;
|
|
||||||
}).filter(Boolean).join('');
|
|
||||||
return `<div class="ink-notes"><strong>INK NOTES:</strong><div style="margin-top:8px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;background:#fff;"><svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="display:block;max-width:100%;height:auto;">${lines.join('')}${paths}</svg></div></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildExportHtml(project) {
|
export function buildExportHtml(project) {
|
||||||
const style = `
|
const style = `
|
||||||
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
|
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
|
||||||
@@ -224,7 +200,7 @@ export function buildExportHtml(project) {
|
|||||||
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
|
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
|
||||||
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
|
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
|
||||||
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
|
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
|
||||||
.oia, .cross-refs, .greek, .ink-notes { margin-top: 1rem; }
|
.oia, .cross-refs, .greek { margin-top: 1rem; }
|
||||||
.oia-section { margin-bottom: 0.75rem; }
|
.oia-section { margin-bottom: 0.75rem; }
|
||||||
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||||
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
|
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
|
||||||
@@ -295,8 +271,6 @@ export function buildExportHtml(project) {
|
|||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<section class="chunk">
|
<section class="chunk">
|
||||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||||
@@ -311,7 +285,6 @@ export function buildExportHtml(project) {
|
|||||||
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
||||||
${extendedDefinitions}
|
${extendedDefinitions}
|
||||||
</div>
|
</div>
|
||||||
${inkHtml}
|
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// DOCX export — kept in its own module so the (large) `docx` library is only
|
||||||
|
// downloaded when the user actually exports, instead of on every page load.
|
||||||
|
import {
|
||||||
|
BorderStyle,
|
||||||
|
Document,
|
||||||
|
HeadingLevel,
|
||||||
|
Packer,
|
||||||
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
TextRun,
|
||||||
|
WidthType,
|
||||||
|
} from 'docx';
|
||||||
|
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
export function createParagraphsFromText(text) {
|
||||||
|
return text
|
||||||
|
.split(/\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCX_ACCENT = 'C9A84C'; // antique gold
|
||||||
|
const DOCX_HEADING_FONT = 'Cormorant Garamond';
|
||||||
|
const DOCX_BODY_FONT = 'Lora';
|
||||||
|
|
||||||
|
export function sectionLabel(text) {
|
||||||
|
return new Paragraph({
|
||||||
|
spacing: { before: 240, after: 80 },
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: text.toUpperCase(),
|
||||||
|
bold: true,
|
||||||
|
color: DOCX_ACCENT,
|
||||||
|
font: DOCX_BODY_FONT,
|
||||||
|
size: 20,
|
||||||
|
characterSpacing: 20,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||||
|
const children = [
|
||||||
|
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||||
|
new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({
|
||||||
|
text: `${project.translation} — ${chapterSummary}`,
|
||||||
|
italics: true,
|
||||||
|
color: '7A7060',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
spacing: { after: 300 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
|
||||||
|
|
||||||
|
project.chapters.forEach((ch, chapterIndex) => {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
text: `${ch.book} ${ch.chapter}`,
|
||||||
|
heading: HeadingLevel.HEADING_1,
|
||||||
|
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
|
||||||
|
spacing: { before: 360, after: 160 },
|
||||||
|
}));
|
||||||
|
ch.chunks.forEach((chunk) => {
|
||||||
|
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||||
|
|
||||||
|
const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
|
||||||
|
if (chunk.episodeNumber || chunk.episodeTitle) {
|
||||||
|
headingRuns.push(new TextRun({
|
||||||
|
text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
|
||||||
|
italics: true,
|
||||||
|
size: 22,
|
||||||
|
color: '7A7060',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
|
||||||
|
|
||||||
|
// Scripture text in a left-bordered table cell for a "callout" look (no fill)
|
||||||
|
const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
|
||||||
|
children: [
|
||||||
|
new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
|
||||||
|
new TextRun({ text: verse.text, italics: true }),
|
||||||
|
],
|
||||||
|
spacing: { after: 80 },
|
||||||
|
}));
|
||||||
|
children.push(new Table({
|
||||||
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||||
|
rows: [
|
||||||
|
new TableRow({
|
||||||
|
children: [
|
||||||
|
new TableCell({
|
||||||
|
margins: { top: 120, bottom: 120, left: 180, right: 180 },
|
||||||
|
borders: {
|
||||||
|
...{ top: noBorder, right: noBorder, bottom: noBorder },
|
||||||
|
left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
|
||||||
|
},
|
||||||
|
children: scriptureParas,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
||||||
|
|
||||||
|
if (chunk.generalNotes?.trim()) {
|
||||||
|
children.push(sectionLabel('Background / General Notes'));
|
||||||
|
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(sectionLabel('Observation'));
|
||||||
|
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
|
||||||
|
children.push(sectionLabel('Interpretation'));
|
||||||
|
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
|
||||||
|
children.push(sectionLabel('Application'));
|
||||||
|
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
|
||||||
|
|
||||||
|
if ((chunk.tags ?? []).length > 0) {
|
||||||
|
children.push(sectionLabel('Tags'));
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: chunk.tags.map((tag, i) => new TextRun({
|
||||||
|
text: i === 0 ? `#${tag}` : ` #${tag}`,
|
||||||
|
bold: true,
|
||||||
|
color: DOCX_ACCENT,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((chunk.crossReferences ?? []).length > 0) {
|
||||||
|
children.push(sectionLabel('Cross-References'));
|
||||||
|
children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.greekWords.length > 0) {
|
||||||
|
children.push(sectionLabel('Word Studies'));
|
||||||
|
const headerCellStyle = (label) => new TableCell({
|
||||||
|
width: { size: 20, type: WidthType.PERCENTAGE },
|
||||||
|
borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
|
||||||
|
});
|
||||||
|
const tableRows = [
|
||||||
|
new TableRow({
|
||||||
|
tableHeader: true,
|
||||||
|
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
|
||||||
|
}),
|
||||||
|
...chunk.greekWords.map((word) => new TableRow({
|
||||||
|
children: [
|
||||||
|
new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
|
||||||
|
new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
|
||||||
|
new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
|
||||||
|
new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
|
||||||
|
new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
|
||||||
|
chunk.greekWords.forEach((word) => {
|
||||||
|
if (word.definitionHtml) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
spacing: { before: 180, after: 80 },
|
||||||
|
children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
|
||||||
|
}));
|
||||||
|
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunk.finalScript?.trim()) {
|
||||||
|
children.push(sectionLabel('Final Script'));
|
||||||
|
children.push(...createParagraphsFromText(chunk.finalScript));
|
||||||
|
}
|
||||||
|
|
||||||
|
children.push(new Paragraph({
|
||||||
|
text: '',
|
||||||
|
spacing: { after: 200 },
|
||||||
|
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
styles: {
|
||||||
|
default: {
|
||||||
|
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
|
||||||
|
},
|
||||||
|
paragraphStyles: [
|
||||||
|
{
|
||||||
|
id: 'Title',
|
||||||
|
name: 'Title',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
next: 'Normal',
|
||||||
|
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Heading1',
|
||||||
|
name: 'Heading 1',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
next: 'Normal',
|
||||||
|
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Heading2',
|
||||||
|
name: 'Heading 2',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
next: 'Normal',
|
||||||
|
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sections: [{ children }],
|
||||||
|
});
|
||||||
|
return Packer.toBlob(doc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Markdown export and Claude prompt builders.
|
||||||
|
import {
|
||||||
|
formatChunkReference,
|
||||||
|
getChunkVerseEntries,
|
||||||
|
htmlToPlainText,
|
||||||
|
} from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export / prompt builders (exported for testing)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildMarkdownExport(project) {
|
||||||
|
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||||
|
|
||||||
|
const chapterSections = chapters.map((ch, chapterIndex) => {
|
||||||
|
const chunkSections = ch.chunks.map((chunk) => {
|
||||||
|
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||||
|
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||||
|
.map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
|
||||||
|
.join('\n>\n');
|
||||||
|
|
||||||
|
const observation = (chunk.observation ?? '').trim() || '_No observation._';
|
||||||
|
const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
|
||||||
|
const application = (chunk.application ?? '').trim() || '_No application._';
|
||||||
|
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||||
|
|
||||||
|
const crossRefsLine = (chunk.crossReferences ?? []).length > 0
|
||||||
|
? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const greekSection = chunk.greekWords.length === 0
|
||||||
|
? ''
|
||||||
|
: `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
|
||||||
|
const summary = [
|
||||||
|
word.strongNumber,
|
||||||
|
word.lexeme,
|
||||||
|
word.transliteration && `(${word.transliteration})`,
|
||||||
|
word.partOfSpeech,
|
||||||
|
word.shortDefinition,
|
||||||
|
].filter(Boolean).join(' — ');
|
||||||
|
const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
|
||||||
|
return `- ${summary}${definition}`;
|
||||||
|
}).join('\n')}\n\n`;
|
||||||
|
|
||||||
|
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||||
|
}).join('---\n\n');
|
||||||
|
|
||||||
|
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChunkBodies(project) {
|
||||||
|
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||||
|
let chunkIndex = 0;
|
||||||
|
return chapters.map((ch, chapterIndex) => {
|
||||||
|
return ch.chunks.map((chunk) => {
|
||||||
|
chunkIndex += 1;
|
||||||
|
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||||
|
|
||||||
|
const verses = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||||
|
.map((v) => `${v.chapter}:${v.number} ${v.text}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const observation = (chunk.observation ?? '').trim() || 'No observation.';
|
||||||
|
const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.';
|
||||||
|
const application = (chunk.application ?? '').trim() || 'No application.';
|
||||||
|
|
||||||
|
const crossRefs = (chunk.crossReferences ?? []).length > 0
|
||||||
|
? chunk.crossReferences.join(', ')
|
||||||
|
: 'None.';
|
||||||
|
|
||||||
|
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||||
|
const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim()
|
||||||
|
? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const greekWords = chunk.greekWords.length === 0
|
||||||
|
? 'None.'
|
||||||
|
: chunk.greekWords.map((word) => {
|
||||||
|
const summary = [
|
||||||
|
word.strongNumber,
|
||||||
|
word.lexeme,
|
||||||
|
word.transliteration && `(${word.transliteration})`,
|
||||||
|
word.partOfSpeech,
|
||||||
|
word.shortDefinition,
|
||||||
|
].filter(Boolean).join(' | ');
|
||||||
|
const definition = word.definitionHtml
|
||||||
|
? `\n ${htmlToPlainText(word.definitionHtml)}`
|
||||||
|
: '';
|
||||||
|
return `• ${summary}${definition}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `===
|
||||||
|
|
||||||
|
CHUNK ${chunkIndex} — ${ref}
|
||||||
|
${episodeLabelLine}
|
||||||
|
Scripture:
|
||||||
|
${verses}
|
||||||
|
${generalNotes ? `\nBackground / General Notes:\n${generalNotes}\n` : ''}
|
||||||
|
Observation:
|
||||||
|
${observation}
|
||||||
|
|
||||||
|
Interpretation:
|
||||||
|
${interpretation}
|
||||||
|
|
||||||
|
Application:
|
||||||
|
${application}
|
||||||
|
|
||||||
|
Cross-References: ${crossRefs}
|
||||||
|
|
||||||
|
Greek Words:
|
||||||
|
${greekWords}
|
||||||
|
`;
|
||||||
|
}).join('\n');
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClaudePrompt(project) {
|
||||||
|
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||||
|
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||||
|
|
||||||
|
const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide.
|
||||||
|
|
||||||
|
Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that:
|
||||||
|
- Synthesises my notes into coherent teaching points
|
||||||
|
- Naturally integrates the Greek word insights
|
||||||
|
- Includes 2–3 reflection questions per chunk
|
||||||
|
- Preserves the passage-by-passage structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
PROJECT: ${project.title}
|
||||||
|
TRANSLATION: ${project.translation}
|
||||||
|
PASSAGE: ${chapterLabel}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return header + buildChunkBodies(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPodcastPrompt(project, podcastName) {
|
||||||
|
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||||
|
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||||
|
const episodeLabel = 'EPISODE';
|
||||||
|
const showName = (podcastName ?? '').trim();
|
||||||
|
const showLine = showName || 'MY BIBLE STUDY PODCAST';
|
||||||
|
const signOffLine = showName
|
||||||
|
? `Introduce yourself, then sign off with: "...and this is ${showName}."`
|
||||||
|
: 'Introduce yourself and the name of the show as a sign-off.';
|
||||||
|
|
||||||
|
const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below.
|
||||||
|
|
||||||
|
Please write the script in this exact structure, using the section markers and tone shown:
|
||||||
|
|
||||||
|
${showLine.toUpperCase()}
|
||||||
|
${episodeLabel}
|
||||||
|
${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content]
|
||||||
|
|
||||||
|
✝ OPENING PRAYER
|
||||||
|
[Pause before opening the text — invite God into the study]
|
||||||
|
A short prayer (4-6 sentences) tying into the themes of this passage.
|
||||||
|
|
||||||
|
— ✦ —
|
||||||
|
|
||||||
|
🎙️ COLD OPEN
|
||||||
|
[Co-host delivers the cold open solo, if there is one — hands off to the host at the end]
|
||||||
|
A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show.
|
||||||
|
|
||||||
|
— ✦ —
|
||||||
|
|
||||||
|
📖 SEGMENT [N] — [SEGMENT TITLE]
|
||||||
|
[Brief stage direction in brackets]
|
||||||
|
One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally.
|
||||||
|
|
||||||
|
— ✦ —
|
||||||
|
|
||||||
|
📖 SEGMENT [N] — GREEK WORD STUDY
|
||||||
|
[Work through the key terms — keep it vivid, help the words come alive]
|
||||||
|
For each Greek/Hebrew word collected below, a block in this format:
|
||||||
|
|
||||||
|
[English Gloss] — [English meaning]
|
||||||
|
[DO NOT READ: original-language word (transliteration) — part of speech]
|
||||||
|
2-4 sentences unpacking the word's meaning and why it matters for this passage, conversational tone.
|
||||||
|
|
||||||
|
— ✦ —
|
||||||
|
|
||||||
|
💬 DISCUSSION QUESTIONS
|
||||||
|
[From the study guide — ${chapterLabel}]
|
||||||
|
3 reflection questions drawn from the application notes, numbered.
|
||||||
|
|
||||||
|
— ✦ —
|
||||||
|
|
||||||
|
✦ CLOSING
|
||||||
|
[Grounded and direct — send them away with something to carry]
|
||||||
|
A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
|
||||||
|
"Until next time — keep studying verse by verse."
|
||||||
|
|
||||||
|
— End of Episode —
|
||||||
|
|
||||||
|
${showLine} · ${episodeLabel} · ${chapterLabel}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Here are my study notes, organised by passage chunk, including my OIA notes, cross-references, and Greek/Hebrew word research:
|
||||||
|
|
||||||
|
PROJECT: ${project.title}
|
||||||
|
TRANSLATION: ${project.translation}
|
||||||
|
PASSAGE: ${chapterLabel}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return header + buildChunkBodies(project);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Parsers for typed cross-references and imported DOCX session lists.
|
||||||
|
import {
|
||||||
|
bookOptions,
|
||||||
|
} from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
|
||||||
|
export const BOOK_NAME_ALIASES = {
|
||||||
|
'gen': 'GEN', 'genesis': 'GEN', 'exo': 'EXO', 'exod': 'EXO', 'exodus': 'EXO',
|
||||||
|
'lev': 'LEV', 'leviticus': 'LEV', 'num': 'NUM', 'numbers': 'NUM',
|
||||||
|
'deut': 'DEU', 'deuteronomy': 'DEU', 'josh': 'JOS', 'joshua': 'JOS',
|
||||||
|
'judg': 'JDG', 'judges': 'JDG', 'ruth': 'RUT',
|
||||||
|
'1 sam': '1SA', '1 samuel': '1SA', '2 sam': '2SA', '2 samuel': '2SA',
|
||||||
|
'1 kgs': '1KI', '1 kings': '1KI', '2 kgs': '2KI', '2 kings': '2KI',
|
||||||
|
'1 chr': '1CH', '1 chronicles': '1CH', '2 chr': '2CH', '2 chronicles': '2CH',
|
||||||
|
'ezra': 'EZR', 'neh': 'NEH', 'nehemiah': 'NEH', 'esth': 'EST', 'esther': 'EST',
|
||||||
|
'job': 'JOB', 'ps': 'PSA', 'psa': 'PSA', 'psalm': 'PSA', 'psalms': 'PSA',
|
||||||
|
'prov': 'PRO', 'proverbs': 'PRO', 'eccl': 'ECC', 'ecclesiastes': 'ECC',
|
||||||
|
'song': 'SNG', 'isa': 'ISA', 'isaiah': 'ISA', 'jer': 'JER', 'jeremiah': 'JER',
|
||||||
|
'lam': 'LAM', 'lamentations': 'LAM', 'ezek': 'EZK', 'ezekiel': 'EZK',
|
||||||
|
'dan': 'DAN', 'daniel': 'DAN', 'hos': 'HOS', 'hosea': 'HOS', 'joel': 'JOL',
|
||||||
|
'amos': 'AMO', 'obad': 'OBA', 'obadiah': 'OBA', 'jonah': 'JON', 'mic': 'MIC',
|
||||||
|
'micah': 'MIC', 'nah': 'NAM', 'nahum': 'NAM', 'hab': 'HAB', 'habakkuk': 'HAB',
|
||||||
|
'zeph': 'ZEP', 'zephaniah': 'ZEP', 'hag': 'HAG', 'haggai': 'HAG',
|
||||||
|
'zech': 'ZEC', 'zechariah': 'ZEC', 'mal': 'MAL', 'malachi': 'MAL',
|
||||||
|
'matt': 'MAT', 'mt': 'MAT', 'matthew': 'MAT', 'mark': 'MRK', 'mk': 'MRK',
|
||||||
|
'luke': 'LUK', 'lk': 'LUK', 'john': 'JHN', 'jn': 'JHN', 'acts': 'ACT',
|
||||||
|
'rom': 'ROM', 'romans': 'ROM', '1 cor': '1CO', '1 corinthians': '1CO',
|
||||||
|
'2 cor': '2CO', '2 corinthians': '2CO', 'gal': 'GAL', 'galatians': 'GAL',
|
||||||
|
'eph': 'EPH', 'ephesians': 'EPH', 'phil': 'PHP', 'philippians': 'PHP',
|
||||||
|
'col': 'COL', 'colossians': 'COL', '1 thess': '1TH', '1 thessalonians': '1TH',
|
||||||
|
'2 thess': '2TH', '2 thessalonians': '2TH', '1 tim': '1TI', '1 timothy': '1TI',
|
||||||
|
'2 tim': '2TI', '2 timothy': '2TI', 'titus': 'TIT', 'phlm': 'PHM', 'philemon': 'PHM',
|
||||||
|
'heb': 'HEB', 'hebrews': 'HEB', 'jas': 'JAS', 'james': 'JAS',
|
||||||
|
'1 pet': '1PE', '1 peter': '1PE', '2 pet': '2PE', '2 peter': '2PE',
|
||||||
|
'1 jn': '1JN', '1 john': '1JN', '2 jn': '2JN', '2 john': '2JN',
|
||||||
|
'3 jn': '3JN', '3 john': '3JN', 'jude': 'JUD', 'rev': 'REV', 'revelation': 'REV',
|
||||||
|
};
|
||||||
|
|
||||||
|
bookOptions.forEach((b) => {
|
||||||
|
BOOK_NAME_ALIASES[b.name.toLowerCase()] = b.abbrev;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parses strings like "Romans 3:23", "1 Timothy 3:2-7" into { bookAbbrev, chapter, verse, endVerse }
|
||||||
|
export function parseCrossRefString(ref) {
|
||||||
|
const match = ref.trim().match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, bookPart, chapter, verse, endVerse] = match;
|
||||||
|
const abbrev = BOOK_NAME_ALIASES[bookPart.trim().toLowerCase()];
|
||||||
|
if (!abbrev) return null;
|
||||||
|
return {
|
||||||
|
bookAbbrev: abbrev,
|
||||||
|
chapter: Number(chapter),
|
||||||
|
verse: Number(verse),
|
||||||
|
endVerse: endVerse ? Number(endVerse) : Number(verse),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage)
|
||||||
|
export function parseEpisodePassage(raw) {
|
||||||
|
const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes
|
||||||
|
if (!str || /^[-–—]+$/.test(str)) return null;
|
||||||
|
const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/)
|
||||||
|
?? str.match(/^(\d+):(\d+)$/);
|
||||||
|
if (!match) return 'invalid';
|
||||||
|
const [, ch1, v1, ch2, v2raw] = match;
|
||||||
|
const endChapter = ch2 ? Number(ch2) : Number(ch1);
|
||||||
|
const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1);
|
||||||
|
return {
|
||||||
|
startChapter: Number(ch1),
|
||||||
|
startVerse: Number(v1),
|
||||||
|
endChapter,
|
||||||
|
endVerse,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts a "Session # / Title / Passage" list from a docx, including both
|
||||||
|
// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
|
||||||
|
export async function parseEpisodeListDocx(file) {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const { default: mammoth } = await import('mammoth');
|
||||||
|
const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
|
||||||
|
const parsedDoc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
|
||||||
|
const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage }
|
||||||
|
|
||||||
|
parsedDoc.querySelectorAll('table tr').forEach((row) => {
|
||||||
|
const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim());
|
||||||
|
if (cells.length < 3) return;
|
||||||
|
const [epRaw, title, passage] = cells;
|
||||||
|
if (!/^\d+$/.test(epRaw)) return; // header row
|
||||||
|
episodes.set(epRaw, { episodeNumber: epRaw, title, passage });
|
||||||
|
});
|
||||||
|
|
||||||
|
parsedDoc.querySelectorAll('p').forEach((p) => {
|
||||||
|
const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i);
|
||||||
|
if (!match) return;
|
||||||
|
const [, epRaw, title] = match;
|
||||||
|
if (!episodes.has(epRaw)) {
|
||||||
|
episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Project persistence in localStorage, plus migrations from older data shapes.
|
||||||
|
import {
|
||||||
|
makeId,
|
||||||
|
indexKey,
|
||||||
|
projectKey,
|
||||||
|
loadProjectIndex,
|
||||||
|
} from './bibleUtils.jsx';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Migration helpers (exported for testing)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function migrateChunk(chunk) {
|
||||||
|
if (chunk.observation !== undefined) {
|
||||||
|
return {
|
||||||
|
...chunk,
|
||||||
|
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
|
||||||
|
generalNotes: chunk.generalNotes ?? '',
|
||||||
|
episodeNumber: chunk.episodeNumber ?? '',
|
||||||
|
episodeTitle: chunk.episodeTitle ?? '',
|
||||||
|
finalScript: chunk.finalScript ?? '',
|
||||||
|
tags: chunk.tags ?? [],
|
||||||
|
}; // already new format
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...chunk,
|
||||||
|
observation: chunk.notes ?? '',
|
||||||
|
interpretation: '',
|
||||||
|
application: '',
|
||||||
|
crossReferences: [],
|
||||||
|
tags: [],
|
||||||
|
spilloverEndVerse: null,
|
||||||
|
generalNotes: '',
|
||||||
|
episodeNumber: '',
|
||||||
|
episodeTitle: '',
|
||||||
|
finalScript: '',
|
||||||
|
notes: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateProject(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
// Already new format
|
||||||
|
if (Array.isArray(raw.chapters)) {
|
||||||
|
return {
|
||||||
|
...raw,
|
||||||
|
chapters: raw.chapters.map((ch) => ({
|
||||||
|
...ch,
|
||||||
|
chunks: ch.chunks.map(migrateChunk),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Old flat format → wrap in chapters array
|
||||||
|
const id = raw.id ?? makeId();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: raw.title ?? '',
|
||||||
|
translation: raw.translation ?? 'BSB',
|
||||||
|
lastEdited: raw.lastEdited ?? Date.now(),
|
||||||
|
selectedChunkId: raw.selectedChunkId ?? null,
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
book: raw.book ?? '',
|
||||||
|
bookAbbrev: raw.bookAbbrev ?? '',
|
||||||
|
chapter: raw.chapter ?? '1',
|
||||||
|
verses: raw.verses ?? [],
|
||||||
|
chunks: (raw.chunks ?? []).map(migrateChunk),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Storage helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function saveProjectToStorage(project) {
|
||||||
|
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
||||||
|
// and the server always receive the identical timestamp.
|
||||||
|
const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
|
||||||
|
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
||||||
|
const index = loadProjectIndex();
|
||||||
|
const existing = index.findIndex((e) => e.id === updated.id);
|
||||||
|
const tags = Array.from(
|
||||||
|
new Set(
|
||||||
|
(updated.chapters ?? [])
|
||||||
|
.flatMap((ch) => ch.chunks ?? [])
|
||||||
|
.flatMap((chunk) => chunk.tags ?? []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const summary = {
|
||||||
|
id: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
lastEdited: updated.lastEdited,
|
||||||
|
chapterSummary: buildChapterSummary(updated),
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
if (existing >= 0) {
|
||||||
|
index[existing] = summary;
|
||||||
|
} else {
|
||||||
|
index.push(summary);
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||||
|
return updated; // caller can use this exact object for the server PUT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProjectFromStorage(id) {
|
||||||
|
window.localStorage.removeItem(projectKey(id));
|
||||||
|
const index = loadProjectIndex().filter((e) => e.id !== id);
|
||||||
|
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadProjectById(id) {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(projectKey(id));
|
||||||
|
return raw ? migrateProject(JSON.parse(raw)) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateOldStorageKeys() {
|
||||||
|
const keys = Object.keys(window.localStorage);
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(window.localStorage.getItem(key));
|
||||||
|
if (!raw || !raw.book) return;
|
||||||
|
const migrated = migrateProject(raw);
|
||||||
|
if (!migrated) return;
|
||||||
|
saveProjectToStorage(migrated);
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// skip corrupt entries
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChapterSummary(project) {
|
||||||
|
if (!Array.isArray(project.chapters)) return '';
|
||||||
|
return project.chapters
|
||||||
|
.map((ch) => `${ch.book} ${ch.chapter}`)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user