Spaces:
Running
Running
William Mattingly commited on
Commit ·
a9a9428
1
Parent(s): ac1cbf4
Add scripture detector app
Browse filesInitial implementation with screenshots tracked via Git LFS.
Made-with: Cursor
- .gitattributes +1 -0
- .gitignore +183 -0
- Dockerfile +50 -0
- LICENSE +21 -0
- README copy.md +180 -0
- app.py +728 -0
- data/bible.tsv +0 -0
- data/book_mapping.tsv +74 -0
- database.py +505 -0
- main.py +445 -0
- pyproject.toml +46 -0
- static/favicon.svg +17 -0
- static/logo.svg +35 -0
- static/screenshots/about.png +3 -0
- static/screenshots/dashboard.png +3 -0
- static/screenshots/settings.png +3 -0
- static/screenshots/sources.png +3 -0
- static/screenshots/viewer.png +3 -0
- static/style.css +478 -0
- tei.py +289 -0
- templates/about.html +550 -0
- templates/dashboard.html +277 -0
- templates/settings.html +189 -0
- templates/sources.html +1031 -0
- templates/viewer.html +865 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.db
|
| 2 |
+
.env
|
| 3 |
+
# Byte-compiled / optimized / DLL files
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
*$py.class
|
| 7 |
+
|
| 8 |
+
# C extensions
|
| 9 |
+
*.so
|
| 10 |
+
|
| 11 |
+
# Distribution / packaging
|
| 12 |
+
.Python
|
| 13 |
+
build/
|
| 14 |
+
develop-eggs/
|
| 15 |
+
dist/
|
| 16 |
+
downloads/
|
| 17 |
+
eggs/
|
| 18 |
+
.eggs/
|
| 19 |
+
lib/
|
| 20 |
+
lib64/
|
| 21 |
+
parts/
|
| 22 |
+
sdist/
|
| 23 |
+
var/
|
| 24 |
+
wheels/
|
| 25 |
+
share/python-wheels/
|
| 26 |
+
*.egg-info/
|
| 27 |
+
.installed.cfg
|
| 28 |
+
*.egg
|
| 29 |
+
MANIFEST
|
| 30 |
+
|
| 31 |
+
# PyInstaller
|
| 32 |
+
# Usually these files are written by a python script from a template
|
| 33 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 34 |
+
*.manifest
|
| 35 |
+
*.spec
|
| 36 |
+
|
| 37 |
+
# Installer logs
|
| 38 |
+
pip-log.txt
|
| 39 |
+
pip-delete-this-directory.txt
|
| 40 |
+
|
| 41 |
+
# Unit test / coverage reports
|
| 42 |
+
htmlcov/
|
| 43 |
+
.tox/
|
| 44 |
+
.nox/
|
| 45 |
+
.coverage
|
| 46 |
+
.coverage.*
|
| 47 |
+
.cache
|
| 48 |
+
nosetests.xml
|
| 49 |
+
coverage.xml
|
| 50 |
+
*.cover
|
| 51 |
+
*.py,cover
|
| 52 |
+
.hypothesis/
|
| 53 |
+
.pytest_cache/
|
| 54 |
+
cover/
|
| 55 |
+
|
| 56 |
+
# Translations
|
| 57 |
+
*.mo
|
| 58 |
+
*.pot
|
| 59 |
+
|
| 60 |
+
# Django stuff:
|
| 61 |
+
*.log
|
| 62 |
+
local_settings.py
|
| 63 |
+
db.sqlite3
|
| 64 |
+
db.sqlite3-journal
|
| 65 |
+
|
| 66 |
+
# Flask stuff:
|
| 67 |
+
instance/
|
| 68 |
+
.webassets-cache
|
| 69 |
+
|
| 70 |
+
# Scrapy stuff:
|
| 71 |
+
.scrapy
|
| 72 |
+
|
| 73 |
+
# Sphinx documentation
|
| 74 |
+
docs/_build/
|
| 75 |
+
|
| 76 |
+
# PyBuilder
|
| 77 |
+
.pybuilder/
|
| 78 |
+
target/
|
| 79 |
+
|
| 80 |
+
# Jupyter Notebook
|
| 81 |
+
.ipynb_checkpoints
|
| 82 |
+
|
| 83 |
+
# IPython
|
| 84 |
+
profile_default/
|
| 85 |
+
ipython_config.py
|
| 86 |
+
|
| 87 |
+
# pyenv
|
| 88 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 89 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 90 |
+
# .python-version
|
| 91 |
+
|
| 92 |
+
# pipenv
|
| 93 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 94 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 95 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 96 |
+
# install all needed dependencies.
|
| 97 |
+
#Pipfile.lock
|
| 98 |
+
|
| 99 |
+
# UV
|
| 100 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 101 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 102 |
+
# commonly ignored for libraries.
|
| 103 |
+
#uv.lock
|
| 104 |
+
|
| 105 |
+
# poetry
|
| 106 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 107 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 108 |
+
# commonly ignored for libraries.
|
| 109 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 110 |
+
#poetry.lock
|
| 111 |
+
|
| 112 |
+
# pdm
|
| 113 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 114 |
+
#pdm.lock
|
| 115 |
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
| 116 |
+
# in version control.
|
| 117 |
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
| 118 |
+
.pdm.toml
|
| 119 |
+
.pdm-python
|
| 120 |
+
.pdm-build/
|
| 121 |
+
|
| 122 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 123 |
+
__pypackages__/
|
| 124 |
+
|
| 125 |
+
# Celery stuff
|
| 126 |
+
celerybeat-schedule
|
| 127 |
+
celerybeat.pid
|
| 128 |
+
|
| 129 |
+
# SageMath parsed files
|
| 130 |
+
*.sage.py
|
| 131 |
+
|
| 132 |
+
# Environments
|
| 133 |
+
.env
|
| 134 |
+
.venv
|
| 135 |
+
env/
|
| 136 |
+
venv/
|
| 137 |
+
ENV/
|
| 138 |
+
env.bak/
|
| 139 |
+
venv.bak/
|
| 140 |
+
|
| 141 |
+
# Spyder project settings
|
| 142 |
+
.spyderproject
|
| 143 |
+
.spyproject
|
| 144 |
+
|
| 145 |
+
# Rope project settings
|
| 146 |
+
.ropeproject
|
| 147 |
+
|
| 148 |
+
# mkdocs documentation
|
| 149 |
+
/site
|
| 150 |
+
|
| 151 |
+
# mypy
|
| 152 |
+
.mypy_cache/
|
| 153 |
+
.dmypy.json
|
| 154 |
+
dmypy.json
|
| 155 |
+
|
| 156 |
+
# Pyre type checker
|
| 157 |
+
.pyre/
|
| 158 |
+
|
| 159 |
+
# pytype static type analyzer
|
| 160 |
+
.pytype/
|
| 161 |
+
|
| 162 |
+
# Cython debug symbols
|
| 163 |
+
cython_debug/
|
| 164 |
+
|
| 165 |
+
# PyCharm
|
| 166 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 167 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 168 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 169 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 170 |
+
#.idea/
|
| 171 |
+
|
| 172 |
+
# Ruff stuff:
|
| 173 |
+
.ruff_cache/
|
| 174 |
+
|
| 175 |
+
# PyPI configuration file
|
| 176 |
+
.pypirc
|
| 177 |
+
|
| 178 |
+
# Cursor
|
| 179 |
+
# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to
|
| 180 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 181 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 182 |
+
.cursorignore
|
| 183 |
+
.cursorindexingignore
|
Dockerfile
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Scripture Detector ────────────────────────────────────────────────────────
|
| 2 |
+
#
|
| 3 |
+
# Build:
|
| 4 |
+
# docker build -t scripture-detector .
|
| 5 |
+
#
|
| 6 |
+
# Run (ephemeral in-memory database — data resets on container restart):
|
| 7 |
+
# docker run -p 5001:5001 scripture-detector
|
| 8 |
+
#
|
| 9 |
+
# Run (persistent database — mount a host directory):
|
| 10 |
+
# docker run -p 5001:5001 \
|
| 11 |
+
# -v "$(pwd)/data_volume:/app/db" \
|
| 12 |
+
# -e SD_DB_DIR=/app/db \
|
| 13 |
+
# -e GEMINI_API_KEY=your_key_here \
|
| 14 |
+
# scripture-detector python app.py
|
| 15 |
+
#
|
| 16 |
+
# The default CMD uses --cache-db (in-memory SQLite) which is ideal for
|
| 17 |
+
# workshops and demos where persistence across restarts is not needed.
|
| 18 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
FROM python:3.12-slim
|
| 21 |
+
|
| 22 |
+
# Install uv (fast Python package manager)
|
| 23 |
+
RUN pip install --no-cache-dir uv
|
| 24 |
+
|
| 25 |
+
WORKDIR /app
|
| 26 |
+
|
| 27 |
+
# Clone the scripture-detector repository and change directory into it
|
| 28 |
+
RUN git clone https://github.com/yale-ch/scripture-detector.git /app && cd /app
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Copy dependency manifest first to leverage Docker layer caching.
|
| 32 |
+
# Dependencies are only re-installed when pyproject.toml / uv.lock changes.
|
| 33 |
+
COPY pyproject.toml uv.lock* ./
|
| 34 |
+
|
| 35 |
+
# Sync dependencies (no dev extras)
|
| 36 |
+
RUN uv sync --no-dev --frozen || uv sync --no-dev
|
| 37 |
+
|
| 38 |
+
# Copy application source
|
| 39 |
+
COPY . .
|
| 40 |
+
|
| 41 |
+
# Bind to all interfaces inside the container
|
| 42 |
+
ENV SD_HOST=0.0.0.0
|
| 43 |
+
ENV SD_PORT=5432
|
| 44 |
+
|
| 45 |
+
# Expose the Flask port
|
| 46 |
+
EXPOSE 5432
|
| 47 |
+
|
| 48 |
+
# Default: run with --cache-db (in-memory database, no volume needed).
|
| 49 |
+
# To use a persistent database instead, override CMD and set SD_DB_DIR.
|
| 50 |
+
CMD ["uv", "run", "python", "app.py", "--cache-db"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 William Mattingly
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README copy.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Scripture Detector
|
| 2 |
+
|
| 3 |
+
> **AI-powered detection and analysis of biblical quotations, paraphrases, and allusions in historical texts.**
|
| 4 |
+
|
| 5 |
+

|
| 6 |
+
|
| 7 |
+
Developed by **Dr. William J.B. Mattingly**, Cultural Heritage Data Scientist at Yale University.
|
| 8 |
+
|
| 9 |
+
Built for the international workshop **[Ruse of Reuse: Detecting Text-similarity with AI in Historical Sources](https://www.oeaw.ac.at/en/imafo/events/event-details/ruse-of-reuse)** — held **March 5–6, 2026** at the Austrian Academy of Sciences, Vienna.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Overview
|
| 14 |
+
|
| 15 |
+
Scripture Detector is a local web application that uses Google Gemini to automatically find, classify, and annotate every biblical reference in any text you provide — from full verbatim quotations to subtle allusions. It renders an interactive, color-coded view of the source text with side-by-side Bible verse lookup, distribution charts, and a manual annotation editor.
|
| 16 |
+
|
| 17 |
+
**The only external dependency is a free Gemini API key** from [Google AI Studio](https://aistudio.google.com/apikey). No cloud account, credit card, or additional infrastructure is required.
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## Screenshots
|
| 22 |
+
|
| 23 |
+
### Sources Page — with Advanced Search
|
| 24 |
+
Manage all your text sources. Use the search bar to search source content in real time, or open the **Advanced** panel to stack filters by Bible book, chapter, or verse with AND / OR logic. Match evidence appears directly on each result card.
|
| 25 |
+
|
| 26 |
+

|
| 27 |
+
|
| 28 |
+
### Text Viewer
|
| 29 |
+
Color-coded annotations appear directly in the text. Click any highlighted passage to see the matched Bible verse(s) in the panel on the right.
|
| 30 |
+
|
| 31 |
+

|
| 32 |
+
|
| 33 |
+
### Analytics Dashboard
|
| 34 |
+
Explore scripture distribution across all sources — broken down by Bible book, testament, and quote type.
|
| 35 |
+
|
| 36 |
+

|
| 37 |
+
|
| 38 |
+
### Settings
|
| 39 |
+
Configure your Gemini API key or Google Vertex AI credentials, and select which Gemini model to use.
|
| 40 |
+
|
| 41 |
+

|
| 42 |
+
|
| 43 |
+
### About Page
|
| 44 |
+
Full documentation of the application, its features, and how it works.
|
| 45 |
+
|
| 46 |
+

|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## Features
|
| 51 |
+
|
| 52 |
+
- **Full-document AI analysis** — send any text to Gemini and receive a structured list of every scripture reference with verse citations and classification types.
|
| 53 |
+
- **Four classification types**: Full, Partial, Paraphrase, and Allusion.
|
| 54 |
+
- **Color-coded in-text highlighting** — each type is rendered in a distinct color directly within the source text.
|
| 55 |
+
- **Click-to-explore interactivity** — click a highlighted passage to highlight the corresponding annotation card, and vice versa.
|
| 56 |
+
- **Selection-based re-analysis** — highlight any portion of the text to re-run AI detection on just that selection.
|
| 57 |
+
- **Manual annotation editor** with an integrated Bible verse picker (book → chapter → verse).
|
| 58 |
+
- **Advanced search** — real-time multi-filter search across all sources by text content, Bible book, chapter, or verse; filters stack with AND / OR logic and matched evidence appears inline on each result card.
|
| 59 |
+
- **Distribution charts** per source and across all sources (by Bible book, testament, and type).
|
| 60 |
+
- **Global analytics dashboard** aggregating data across all sources.
|
| 61 |
+
- **Model switching** — choose between available Gemini model versions in the UI.
|
| 62 |
+
- **Runs entirely locally** — your texts are never transmitted anywhere except for the Gemini API call itself.
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## Advanced Search
|
| 67 |
+
|
| 68 |
+
The Sources page includes a real-time multi-filter search system:
|
| 69 |
+
|
| 70 |
+
| Filter Type | What it matches |
|
| 71 |
+
|---|---|
|
| 72 |
+
| **Text Content** | Any source whose full text contains the search string |
|
| 73 |
+
| **Bible Book** | Any source with at least one reference to the chosen book |
|
| 74 |
+
| **Chapter** | Any source with a reference to the chosen chapter (e.g. Psalms 23) |
|
| 75 |
+
| **Verse** | Any source with a reference to the exact chosen verse (e.g. John 3:16) |
|
| 76 |
+
|
| 77 |
+
Filters can be stacked in any combination. The **AND** mode requires a source to satisfy *every* filter; **OR** mode returns sources satisfying *any* filter. Results update within ~350 ms of each keystroke or dropdown change. Matched text snippets and verse citations appear as inline evidence on each result card.
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Quick Start
|
| 82 |
+
|
| 83 |
+
### 1. Prerequisites
|
| 84 |
+
|
| 85 |
+
- Python 3.10+
|
| 86 |
+
- [uv](https://docs.astral.sh/uv/) (recommended) or pip
|
| 87 |
+
- A free **Gemini API key** from [Google AI Studio](https://aistudio.google.com/apikey)
|
| 88 |
+
|
| 89 |
+
### 2. Install & Run
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
git clone <repo-url>
|
| 93 |
+
cd scripture-detector
|
| 94 |
+
|
| 95 |
+
# Using uv (recommended)
|
| 96 |
+
uv run python app.py
|
| 97 |
+
|
| 98 |
+
# Or using pip
|
| 99 |
+
pip install -e .
|
| 100 |
+
python app.py
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
The app starts at **http://127.0.0.1:5001**.
|
| 104 |
+
|
| 105 |
+
### 3. Configure API Key
|
| 106 |
+
|
| 107 |
+
1. Open [http://127.0.0.1:5001/settings](http://127.0.0.1:5001/settings)
|
| 108 |
+
2. Select **Gemini API** as the provider
|
| 109 |
+
3. Paste your API key from [Google AI Studio](https://aistudio.google.com/apikey)
|
| 110 |
+
4. Click **Save Settings**
|
| 111 |
+
|
| 112 |
+
### 4. Analyze a Text
|
| 113 |
+
|
| 114 |
+
1. Go to the **Sources** page and click **Add Source**
|
| 115 |
+
2. Give your source a name and paste in the text to analyze
|
| 116 |
+
3. Click **View** to open the text viewer
|
| 117 |
+
4. Click **Process with AI** — Gemini will detect all scripture references within seconds
|
| 118 |
+
5. Click any highlighted passage to explore the matched Bible verses
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## Quote Classification
|
| 123 |
+
|
| 124 |
+
| Type | Description |
|
| 125 |
+
|---|---|
|
| 126 |
+
| **Full** | A complete or near-complete verse quoted verbatim |
|
| 127 |
+
| **Partial** | A recognizable portion of a verse with minor variation or truncation |
|
| 128 |
+
| **Paraphrase** | Biblical content restated in different words, preserving the meaning |
|
| 129 |
+
| **Allusion** | A brief phrase, thematic echo, or indirect reference to a specific verse |
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## Project Structure
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
scripture-detector/
|
| 137 |
+
├── app.py # Flask application and API routes
|
| 138 |
+
├── database.py # SQLite database layer
|
| 139 |
+
├── main.py # CLI batch evaluation script
|
| 140 |
+
├── data/
|
| 141 |
+
│ ├── bible.tsv # Full Bible verse database (35,000+ verses)
|
| 142 |
+
│ └── book_mapping.tsv
|
| 143 |
+
├── templates/
|
| 144 |
+
│ ├── sources.html # Sources listing page
|
| 145 |
+
│ ├── viewer.html # Annotated text viewer
|
| 146 |
+
│ ├── dashboard.html # Global analytics dashboard
|
| 147 |
+
│ ├── settings.html # API configuration
|
| 148 |
+
│ └── about.html # About / documentation page
|
| 149 |
+
└── static/
|
| 150 |
+
├── style.css # Yale color palette stylesheet
|
| 151 |
+
├── logo.svg # Application logo
|
| 152 |
+
└── favicon.svg # Browser tab icon
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## API Providers
|
| 158 |
+
|
| 159 |
+
### Gemini API (Free Tier)
|
| 160 |
+
The simplest option. Get a free key at [Google AI Studio](https://aistudio.google.com/apikey) — no billing required. Select **Gemini API** in Settings and paste your key.
|
| 161 |
+
|
| 162 |
+
### Google Vertex AI
|
| 163 |
+
For enterprise use or higher rate limits. Requires a Google Cloud project with Vertex AI enabled. Select **Vertex AI** in Settings and enter your project ID and location.
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## About
|
| 168 |
+
|
| 169 |
+
**Developer:** Dr. William J.B. Mattingly
|
| 170 |
+
**Affiliation:** Yale University, Cultural Heritage Data Scientist
|
| 171 |
+
**Workshop:** [Ruse of Reuse: Detecting Text-similarity with AI in Historical Sources](https://www.oeaw.ac.at/en/imafo/events/event-details/ruse-of-reuse)
|
| 172 |
+
**Workshop Dates:** March 5–6, 2026
|
| 173 |
+
**Workshop Venue:** Austrian Academy of Sciences, PSK Georg-Coch-Platz 2, 1010 Vienna
|
| 174 |
+
**Organisers:** Digital Lab, Institute for Medieval Research, Austrian Academy of Sciences & [SOLEMNE](https://canones.org/), Radboud University
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
## License
|
| 179 |
+
|
| 180 |
+
MIT
|
app.py
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
# ── parse custom flags BEFORE importing database (which reads env vars) ───────
|
| 5 |
+
_argv = sys.argv[1:]
|
| 6 |
+
if "--cache-db" in _argv:
|
| 7 |
+
os.environ["SCRIPTURE_DETECTOR_CACHE_DB"] = "1"
|
| 8 |
+
# Remove our custom flags so Flask/werkzeug doesn't choke on them
|
| 9 |
+
sys.argv = [sys.argv[0]] + [a for a in _argv if a != "--cache-db"]
|
| 10 |
+
|
| 11 |
+
import csv
|
| 12 |
+
import io
|
| 13 |
+
import json
|
| 14 |
+
import re
|
| 15 |
+
import zipfile
|
| 16 |
+
from datetime import date
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from flask import Flask, render_template, jsonify, request, redirect, url_for, Response
|
| 20 |
+
from google import genai
|
| 21 |
+
|
| 22 |
+
from database import (
|
| 23 |
+
init_db,
|
| 24 |
+
create_source, get_source, get_all_sources, delete_source,
|
| 25 |
+
add_quote, update_quote, delete_quote, get_quotes_for_source,
|
| 26 |
+
delete_quotes_for_source, delete_quotes_in_range,
|
| 27 |
+
get_setting, set_setting, get_all_settings,
|
| 28 |
+
get_book_distribution, get_quote_type_distribution, get_dashboard_data,
|
| 29 |
+
search_sources,
|
| 30 |
+
)
|
| 31 |
+
from tei import source_to_tei, tei_to_source_data
|
| 32 |
+
|
| 33 |
+
app = Flask(__name__)
|
| 34 |
+
|
| 35 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 36 |
+
BIBLE_TSV_PATH = PROJECT_ROOT / "data" / "bible.tsv"
|
| 37 |
+
BOOK_MAPPING_PATH = PROJECT_ROOT / "data" / "book_mapping.tsv"
|
| 38 |
+
|
| 39 |
+
MODELS = [
|
| 40 |
+
{"id": "gemini-3-pro-preview", "name": "Gemini 3 Pro Preview"},
|
| 41 |
+
{"id": "gemini-3-flash-preview", "name": "Gemini 3 Flash Preview"},
|
| 42 |
+
{"id": "gemini-3.1-pro-preview", "name": "Gemini 3.1 Pro Preview"},
|
| 43 |
+
{"id": "gemini-3.1-flash-lite-preview", "name": "Gemini 3.1 Flash Lite Preview"},
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
_bible_cache: dict[str, str] | None = None
|
| 47 |
+
_book_mapping_cache: dict[str, dict] | None = None
|
| 48 |
+
_bible_structure_cache: dict[str, dict[int, list[dict]]] | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def bible_verses() -> dict[str, str]:
|
| 52 |
+
global _bible_cache
|
| 53 |
+
if _bible_cache is not None:
|
| 54 |
+
return _bible_cache
|
| 55 |
+
verses: dict[str, str] = {}
|
| 56 |
+
with open(BIBLE_TSV_PATH, newline="", encoding="utf-8") as f:
|
| 57 |
+
for row in csv.DictReader(f, delimiter="\t"):
|
| 58 |
+
book = row["book_code"].strip().lower()
|
| 59 |
+
chapter = str(int(row["chapter_number"]))
|
| 60 |
+
verse = str(int(row["verse_index"]))
|
| 61 |
+
verses[f"{book}_{chapter}:{verse}"] = row["text"]
|
| 62 |
+
_bible_cache = verses
|
| 63 |
+
return verses
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def book_mapping() -> dict[str, dict]:
|
| 67 |
+
global _book_mapping_cache
|
| 68 |
+
if _book_mapping_cache is not None:
|
| 69 |
+
return _book_mapping_cache
|
| 70 |
+
mapping: dict[str, dict] = {}
|
| 71 |
+
with open(BOOK_MAPPING_PATH, newline="", encoding="utf-8") as f:
|
| 72 |
+
for row in csv.DictReader(f, delimiter="\t"):
|
| 73 |
+
code = row["book_code"].strip().lower()
|
| 74 |
+
mapping[code] = {"name": row["work_name"], "testament": row["testament"]}
|
| 75 |
+
_book_mapping_cache = mapping
|
| 76 |
+
return mapping
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def bible_structure() -> dict[str, dict[int, list[dict]]]:
|
| 80 |
+
global _bible_structure_cache
|
| 81 |
+
if _bible_structure_cache is not None:
|
| 82 |
+
return _bible_structure_cache
|
| 83 |
+
structure: dict[str, dict[int, list[dict]]] = {}
|
| 84 |
+
with open(BIBLE_TSV_PATH, newline="", encoding="utf-8") as f:
|
| 85 |
+
for row in csv.DictReader(f, delimiter="\t"):
|
| 86 |
+
book = row["book_code"].strip().lower()
|
| 87 |
+
chapter = int(row["chapter_number"])
|
| 88 |
+
verse = int(row["verse_index"])
|
| 89 |
+
text = row["text"]
|
| 90 |
+
if book not in structure:
|
| 91 |
+
structure[book] = {}
|
| 92 |
+
if chapter not in structure[book]:
|
| 93 |
+
structure[book][chapter] = []
|
| 94 |
+
structure[book][chapter].append({"verse": verse, "text": text})
|
| 95 |
+
for book in structure:
|
| 96 |
+
for chapter in structure[book]:
|
| 97 |
+
structure[book][chapter].sort(key=lambda v: v["verse"])
|
| 98 |
+
_bible_structure_cache = structure
|
| 99 |
+
return structure
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def get_valid_book_codes() -> list[str]:
|
| 103 |
+
return sorted(book_mapping().keys())
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ── AI Integration ───────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
def get_genai_client():
|
| 109 |
+
settings = get_all_settings()
|
| 110 |
+
provider = settings.get("api_provider", "gemini")
|
| 111 |
+
if provider == "vertex":
|
| 112 |
+
project_id = settings.get("vertex_project_id", "")
|
| 113 |
+
location = settings.get("vertex_location", "global")
|
| 114 |
+
if not project_id:
|
| 115 |
+
raise ValueError("Vertex AI Project ID not configured. Go to Settings.")
|
| 116 |
+
return genai.Client(vertexai=True, project=project_id, location=location)
|
| 117 |
+
else:
|
| 118 |
+
api_key = settings.get("gemini_api_key", "")
|
| 119 |
+
if not api_key:
|
| 120 |
+
raise ValueError("Gemini API Key not configured. Go to Settings.")
|
| 121 |
+
return genai.Client(api_key=api_key)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def get_model() -> str:
|
| 125 |
+
return get_setting("model", "gemini-3-pro-preview")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def build_prompt(text: str) -> str:
|
| 129 |
+
codes_str = ", ".join(get_valid_book_codes())
|
| 130 |
+
bm = book_mapping()
|
| 131 |
+
mapping_lines = "\n".join(f" {v['name']} -> {k}" for k, v in sorted(bm.items()))
|
| 132 |
+
return f"""You are an expert in biblical texts and scripture detection.
|
| 133 |
+
|
| 134 |
+
Given the following text, identify ALL scriptural (Biblical) quotations, partial quotations, paraphrases, and clear allusions to specific Bible verses.
|
| 135 |
+
|
| 136 |
+
For each identified passage:
|
| 137 |
+
1. Extract the EXACT text as it appears in the document — preserve the original spelling, punctuation, and word order verbatim.
|
| 138 |
+
2. Identify the specific Bible verse(s) being quoted or referenced.
|
| 139 |
+
3. Classify the type of reuse as one of:
|
| 140 |
+
- "full" — a complete or near-complete verse quoted verbatim.
|
| 141 |
+
- "partial" — a recognisable portion of a verse, quoted with minor variation or truncation.
|
| 142 |
+
- "paraphrase" — the biblical content is clearly restated in different words while preserving the meaning.
|
| 143 |
+
- "allusion" — a brief phrase, thematic echo, or indirect reference to a specific verse.
|
| 144 |
+
|
| 145 |
+
Reference format: book_chapter:verse (e.g. matt_5:9, ps_82:14, 1cor_15:33)
|
| 146 |
+
CRITICAL: Each reference must be a SINGLE verse. Never use ranges like matt_15:1-2.
|
| 147 |
+
Instead, list each verse separately: matt_15:1, matt_15:2.
|
| 148 |
+
|
| 149 |
+
Valid book codes: {codes_str}
|
| 150 |
+
|
| 151 |
+
Book name to code mapping:
|
| 152 |
+
{mapping_lines}
|
| 153 |
+
|
| 154 |
+
Important:
|
| 155 |
+
- Include both direct quotes and partial quotes / paraphrases / allusions.
|
| 156 |
+
- A single passage may reference multiple Bible verses — list all of them.
|
| 157 |
+
- Be thorough — identify even brief allusions to specific verses.
|
| 158 |
+
- The extracted text must be a verbatim substring of the input document.
|
| 159 |
+
|
| 160 |
+
TEXT:
|
| 161 |
+
{text}"""
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
_RANGE_RE = re.compile(r"^(.+_\d+):(\d+)-(\d+)$")
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def expand_range_references(refs: list[str]) -> list[str]:
|
| 168 |
+
expanded: list[str] = []
|
| 169 |
+
for ref in refs:
|
| 170 |
+
m = _RANGE_RE.match(ref.strip())
|
| 171 |
+
if m:
|
| 172 |
+
prefix, start, end = m.group(1), int(m.group(2)), int(m.group(3))
|
| 173 |
+
for v in range(start, end + 1):
|
| 174 |
+
expanded.append(f"{prefix}:{v}")
|
| 175 |
+
else:
|
| 176 |
+
expanded.append(ref.strip())
|
| 177 |
+
return expanded
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def extract_quotes_with_gemini(text: str) -> list[dict]:
|
| 181 |
+
client = get_genai_client()
|
| 182 |
+
model = get_model()
|
| 183 |
+
prompt = build_prompt(text)
|
| 184 |
+
|
| 185 |
+
response_schema = {
|
| 186 |
+
"type": "ARRAY",
|
| 187 |
+
"items": {
|
| 188 |
+
"type": "OBJECT",
|
| 189 |
+
"properties": {
|
| 190 |
+
"text": {
|
| 191 |
+
"type": "STRING",
|
| 192 |
+
"description": "The exact text of the scriptural quote as it appears verbatim in the document",
|
| 193 |
+
},
|
| 194 |
+
"resolved_references": {
|
| 195 |
+
"type": "ARRAY",
|
| 196 |
+
"items": {"type": "STRING"},
|
| 197 |
+
"description": "List of Bible verse references in format book_chapter:verse",
|
| 198 |
+
},
|
| 199 |
+
"quote_type": {
|
| 200 |
+
"type": "STRING",
|
| 201 |
+
"enum": ["full", "partial", "paraphrase", "allusion"],
|
| 202 |
+
"description": "Type of biblical reuse",
|
| 203 |
+
},
|
| 204 |
+
},
|
| 205 |
+
"required": ["text", "resolved_references", "quote_type"],
|
| 206 |
+
},
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
response = client.models.generate_content(
|
| 210 |
+
model=model,
|
| 211 |
+
contents=prompt,
|
| 212 |
+
config={
|
| 213 |
+
"response_mime_type": "application/json",
|
| 214 |
+
"response_schema": response_schema,
|
| 215 |
+
},
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
quotes = json.loads(response.text)
|
| 219 |
+
for q in quotes:
|
| 220 |
+
q["resolved_references"] = expand_range_references(q.get("resolved_references", []))
|
| 221 |
+
return quotes
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def find_spans(text: str, quotes: list[dict]) -> list[dict]:
|
| 225 |
+
results = []
|
| 226 |
+
for quote in quotes:
|
| 227 |
+
qt = quote["text"]
|
| 228 |
+
idx = text.find(qt)
|
| 229 |
+
if idx == -1:
|
| 230 |
+
idx = text.lower().find(qt.lower())
|
| 231 |
+
span_start = idx if idx != -1 else None
|
| 232 |
+
span_end = (idx + len(qt)) if idx != -1 else None
|
| 233 |
+
results.append({
|
| 234 |
+
"text": qt,
|
| 235 |
+
"span_start": span_start,
|
| 236 |
+
"span_end": span_end,
|
| 237 |
+
"resolved_references": quote["resolved_references"],
|
| 238 |
+
"quote_type": quote.get("quote_type", "allusion"),
|
| 239 |
+
})
|
| 240 |
+
return results
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def compute_segments(text: str, annotations: list[dict]) -> list[dict]:
|
| 244 |
+
boundaries: set[int] = {0, len(text)}
|
| 245 |
+
for a in annotations:
|
| 246 |
+
if a["span_start"] is not None:
|
| 247 |
+
boundaries.add(a["span_start"])
|
| 248 |
+
boundaries.add(a["span_end"])
|
| 249 |
+
ordered = sorted(boundaries)
|
| 250 |
+
|
| 251 |
+
segments = []
|
| 252 |
+
for i in range(len(ordered) - 1):
|
| 253 |
+
start, end = ordered[i], ordered[i + 1]
|
| 254 |
+
ann_ids = [
|
| 255 |
+
j for j, a in enumerate(annotations)
|
| 256 |
+
if a["span_start"] is not None
|
| 257 |
+
and a["span_start"] <= start and end <= a["span_end"]
|
| 258 |
+
]
|
| 259 |
+
segments.append({
|
| 260 |
+
"text": text[start:end],
|
| 261 |
+
"start": start,
|
| 262 |
+
"end": end,
|
| 263 |
+
"annotation_ids": ann_ids,
|
| 264 |
+
})
|
| 265 |
+
return segments
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ── Page Routes ──────────────────────────────────────────────────────────────
|
| 269 |
+
|
| 270 |
+
@app.route("/")
|
| 271 |
+
def sources_page():
|
| 272 |
+
return render_template("sources.html")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@app.route("/dashboard")
|
| 276 |
+
def dashboard():
|
| 277 |
+
return render_template("dashboard.html")
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@app.route("/viewer/<int:source_id>")
|
| 281 |
+
def viewer(source_id: int):
|
| 282 |
+
source = get_source(source_id)
|
| 283 |
+
if not source:
|
| 284 |
+
return redirect(url_for("sources_page"))
|
| 285 |
+
settings = get_all_settings()
|
| 286 |
+
current_model = settings.get("model", "gemini-3-pro-preview")
|
| 287 |
+
return render_template("viewer.html", source=source, models=MODELS, current_model=current_model)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
@app.route("/settings")
|
| 291 |
+
def settings_page():
|
| 292 |
+
return render_template("settings.html", models=MODELS)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@app.route("/about")
|
| 296 |
+
def about_page():
|
| 297 |
+
return render_template("about.html")
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ── API: Search ───────────────────────────────────────────────────────────────
|
| 301 |
+
|
| 302 |
+
@app.route("/api/search", methods=["POST"])
|
| 303 |
+
def api_search():
|
| 304 |
+
body = request.get_json() or {}
|
| 305 |
+
filters = body.get("filters", [])
|
| 306 |
+
logic = body.get("logic", "AND")
|
| 307 |
+
if logic not in ("AND", "OR"):
|
| 308 |
+
logic = "AND"
|
| 309 |
+
|
| 310 |
+
# Normalise filter values to lowercase; reject empties
|
| 311 |
+
clean = [
|
| 312 |
+
{"type": f.get("type", "text"), "value": str(f.get("value", "")).strip().lower()}
|
| 313 |
+
for f in filters
|
| 314 |
+
if str(f.get("value", "")).strip()
|
| 315 |
+
]
|
| 316 |
+
|
| 317 |
+
result = search_sources(clean, logic)
|
| 318 |
+
|
| 319 |
+
# Enrich book_distribution entries with human-readable names
|
| 320 |
+
bm = book_mapping()
|
| 321 |
+
for src in result["results"]:
|
| 322 |
+
for item in src.get("book_distribution", []):
|
| 323 |
+
info = bm.get(item["book_code"], {})
|
| 324 |
+
item["book_name"] = info.get("name", item["book_code"])
|
| 325 |
+
item["testament"] = info.get("testament", "")
|
| 326 |
+
|
| 327 |
+
return jsonify(result)
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
# ── API: Sources ─────────────────────────────────────────────────────────────
|
| 331 |
+
|
| 332 |
+
@app.route("/api/sources", methods=["POST"])
|
| 333 |
+
def api_create_source():
|
| 334 |
+
body = request.get_json()
|
| 335 |
+
name = body.get("name", "").strip()
|
| 336 |
+
text = body.get("text", "").strip()
|
| 337 |
+
if not name or not text:
|
| 338 |
+
return jsonify({"error": "Name and text are required"}), 400
|
| 339 |
+
source_id = create_source(name, text)
|
| 340 |
+
return jsonify({"id": source_id, "name": name})
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
@app.route("/api/sources/<int:source_id>", methods=["GET"])
|
| 344 |
+
def api_get_source(source_id: int):
|
| 345 |
+
source = get_source(source_id)
|
| 346 |
+
if not source:
|
| 347 |
+
return jsonify({"error": "Source not found"}), 404
|
| 348 |
+
|
| 349 |
+
quotes = get_quotes_for_source(source_id)
|
| 350 |
+
verses = bible_verses()
|
| 351 |
+
bm = book_mapping()
|
| 352 |
+
|
| 353 |
+
annotations = []
|
| 354 |
+
for q in quotes:
|
| 355 |
+
refs = [r["reference"] for r in q["references"]]
|
| 356 |
+
verse_lookup = []
|
| 357 |
+
for ref in refs:
|
| 358 |
+
ref_lower = ref.strip().lower()
|
| 359 |
+
book_code = ref_lower.split("_")[0] if "_" in ref_lower else ""
|
| 360 |
+
verse_lookup.append({
|
| 361 |
+
"ref": ref_lower,
|
| 362 |
+
"text": verses.get(ref_lower, ""),
|
| 363 |
+
"book_name": bm.get(book_code, {}).get("name", ""),
|
| 364 |
+
})
|
| 365 |
+
annotations.append({
|
| 366 |
+
"id": q["id"],
|
| 367 |
+
"span_start": q["span_start"],
|
| 368 |
+
"span_end": q["span_end"],
|
| 369 |
+
"quote_text": q["quote_text"],
|
| 370 |
+
"quote_type": q["quote_type"],
|
| 371 |
+
"refs": refs,
|
| 372 |
+
"verses": verse_lookup,
|
| 373 |
+
})
|
| 374 |
+
|
| 375 |
+
segments = compute_segments(source["text"], annotations)
|
| 376 |
+
|
| 377 |
+
return jsonify({
|
| 378 |
+
"source": {"id": source["id"], "name": source["name"]},
|
| 379 |
+
"segments": segments,
|
| 380 |
+
"annotations": annotations,
|
| 381 |
+
})
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
@app.route("/api/sources/<int:source_id>", methods=["DELETE"])
|
| 385 |
+
def api_delete_source(source_id: int):
|
| 386 |
+
delete_source(source_id)
|
| 387 |
+
return jsonify({"status": "ok"})
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ── API: TEI Export (single source) ──────────────────────────────────────────
|
| 391 |
+
|
| 392 |
+
@app.route("/api/sources/<int:source_id>/export/tei")
|
| 393 |
+
def api_export_tei(source_id: int):
|
| 394 |
+
source = get_source(source_id)
|
| 395 |
+
if not source:
|
| 396 |
+
return jsonify({"error": "Source not found"}), 404
|
| 397 |
+
|
| 398 |
+
quotes = get_quotes_for_source(source_id)
|
| 399 |
+
bm = book_mapping()
|
| 400 |
+
book_names = {code: info["name"] for code, info in bm.items()}
|
| 401 |
+
verses = bible_verses()
|
| 402 |
+
|
| 403 |
+
annotations = []
|
| 404 |
+
for q in quotes:
|
| 405 |
+
refs = [r["reference"] for r in q["references"]]
|
| 406 |
+
annotations.append({
|
| 407 |
+
"id": q["id"],
|
| 408 |
+
"span_start": q["span_start"],
|
| 409 |
+
"span_end": q["span_end"],
|
| 410 |
+
"quote_text": q["quote_text"],
|
| 411 |
+
"quote_type": q["quote_type"],
|
| 412 |
+
"refs": refs,
|
| 413 |
+
})
|
| 414 |
+
|
| 415 |
+
xml_bytes = source_to_tei(source, annotations, book_names)
|
| 416 |
+
safe_name = re.sub(r"[^\w\-]", "_", source["name"])[:60]
|
| 417 |
+
filename = f"{safe_name}.tei.xml"
|
| 418 |
+
|
| 419 |
+
return Response(
|
| 420 |
+
xml_bytes,
|
| 421 |
+
mimetype="application/xml",
|
| 422 |
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ── API: ZIP Export (all sources) ────────────────────────────────────────────
|
| 427 |
+
|
| 428 |
+
@app.route("/api/export/zip")
|
| 429 |
+
def api_export_zip():
|
| 430 |
+
sources = get_all_sources()
|
| 431 |
+
bm = book_mapping()
|
| 432 |
+
book_names = {code: info["name"] for code, info in bm.items()}
|
| 433 |
+
|
| 434 |
+
buf = io.BytesIO()
|
| 435 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 436 |
+
manifest = {
|
| 437 |
+
"version": "1.0",
|
| 438 |
+
"app": "Scripture Detector",
|
| 439 |
+
"exported": date.today().isoformat(),
|
| 440 |
+
"source_count": len(sources),
|
| 441 |
+
"sources": [],
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
for idx, src in enumerate(sources, start=1):
|
| 445 |
+
full_source = get_source(src["id"])
|
| 446 |
+
quotes = get_quotes_for_source(src["id"])
|
| 447 |
+
|
| 448 |
+
annotations = []
|
| 449 |
+
for q in quotes:
|
| 450 |
+
annotations.append({
|
| 451 |
+
"id": q["id"],
|
| 452 |
+
"span_start": q["span_start"],
|
| 453 |
+
"span_end": q["span_end"],
|
| 454 |
+
"quote_text": q["quote_text"],
|
| 455 |
+
"quote_type": q["quote_type"],
|
| 456 |
+
"refs": [r["reference"] for r in q["references"]],
|
| 457 |
+
})
|
| 458 |
+
|
| 459 |
+
xml_bytes = source_to_tei(full_source, annotations, book_names)
|
| 460 |
+
safe_name = re.sub(r"[^\w\-]", "_", src["name"])[:60]
|
| 461 |
+
fname = f"sources/{idx:04d}_{safe_name}.tei.xml"
|
| 462 |
+
zf.writestr(fname, xml_bytes)
|
| 463 |
+
manifest["sources"].append({"filename": fname, "name": src["name"]})
|
| 464 |
+
|
| 465 |
+
zf.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False))
|
| 466 |
+
|
| 467 |
+
buf.seek(0)
|
| 468 |
+
today = date.today().strftime("%Y%m%d")
|
| 469 |
+
return Response(
|
| 470 |
+
buf.read(),
|
| 471 |
+
mimetype="application/zip",
|
| 472 |
+
headers={
|
| 473 |
+
"Content-Disposition":
|
| 474 |
+
f'attachment; filename="scripture_detector_export_{today}.zip"'
|
| 475 |
+
},
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
# ── API: ZIP Import ───────────────────────────────────────────────────────────
|
| 480 |
+
|
| 481 |
+
@app.route("/api/import/zip", methods=["POST"])
|
| 482 |
+
def api_import_zip():
|
| 483 |
+
if "file" not in request.files:
|
| 484 |
+
return jsonify({"error": "No file uploaded"}), 400
|
| 485 |
+
|
| 486 |
+
f = request.files["file"]
|
| 487 |
+
if not f.filename.lower().endswith(".zip"):
|
| 488 |
+
return jsonify({"error": "File must be a .zip archive"}), 400
|
| 489 |
+
|
| 490 |
+
imported = 0
|
| 491 |
+
errors = []
|
| 492 |
+
|
| 493 |
+
try:
|
| 494 |
+
with zipfile.ZipFile(io.BytesIO(f.read()), "r") as zf:
|
| 495 |
+
# Collect TEI files (from sources/ sub-directory or root)
|
| 496 |
+
tei_names = sorted(
|
| 497 |
+
name for name in zf.namelist()
|
| 498 |
+
if name.lower().endswith(".tei.xml") or name.lower().endswith(".xml")
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
for name in tei_names:
|
| 502 |
+
try:
|
| 503 |
+
xml_bytes = zf.read(name)
|
| 504 |
+
src_data = tei_to_source_data(xml_bytes)
|
| 505 |
+
source_id = create_source(src_data["name"], src_data["text"])
|
| 506 |
+
for ann in src_data["annotations"]:
|
| 507 |
+
if ann.get("refs"):
|
| 508 |
+
add_quote(
|
| 509 |
+
source_id = source_id,
|
| 510 |
+
span_start = ann["span_start"],
|
| 511 |
+
span_end = ann["span_end"],
|
| 512 |
+
quote_text = ann["quote_text"],
|
| 513 |
+
quote_type = ann["quote_type"],
|
| 514 |
+
references = ann["refs"],
|
| 515 |
+
)
|
| 516 |
+
imported += 1
|
| 517 |
+
except Exception as exc:
|
| 518 |
+
errors.append({"file": name, "error": str(exc)})
|
| 519 |
+
|
| 520 |
+
except zipfile.BadZipFile:
|
| 521 |
+
return jsonify({"error": "Invalid or corrupt ZIP file"}), 400
|
| 522 |
+
|
| 523 |
+
return jsonify({
|
| 524 |
+
"status": "ok",
|
| 525 |
+
"imported": imported,
|
| 526 |
+
"errors": errors,
|
| 527 |
+
})
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
# ── API: Processing ──────────────────────────────────────────────────────────
|
| 531 |
+
|
| 532 |
+
@app.route("/api/sources/<int:source_id>/process", methods=["POST"])
|
| 533 |
+
def api_process_source(source_id: int):
|
| 534 |
+
source = get_source(source_id)
|
| 535 |
+
if not source:
|
| 536 |
+
return jsonify({"error": "Source not found"}), 404
|
| 537 |
+
try:
|
| 538 |
+
quotes = extract_quotes_with_gemini(source["text"])
|
| 539 |
+
quotes_with_spans = find_spans(source["text"], quotes)
|
| 540 |
+
delete_quotes_for_source(source_id)
|
| 541 |
+
for q in quotes_with_spans:
|
| 542 |
+
add_quote(
|
| 543 |
+
source_id=source_id,
|
| 544 |
+
span_start=q["span_start"],
|
| 545 |
+
span_end=q["span_end"],
|
| 546 |
+
quote_text=q["text"],
|
| 547 |
+
quote_type=q["quote_type"],
|
| 548 |
+
references=q["resolved_references"],
|
| 549 |
+
)
|
| 550 |
+
return jsonify({"status": "ok", "count": len(quotes_with_spans)})
|
| 551 |
+
except Exception as e:
|
| 552 |
+
return jsonify({"error": str(e)}), 500
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
@app.route("/api/sources/<int:source_id>/process-selection", methods=["POST"])
|
| 556 |
+
def api_process_selection(source_id: int):
|
| 557 |
+
source = get_source(source_id)
|
| 558 |
+
if not source:
|
| 559 |
+
return jsonify({"error": "Source not found"}), 404
|
| 560 |
+
body = request.get_json()
|
| 561 |
+
start = body.get("start")
|
| 562 |
+
end = body.get("end")
|
| 563 |
+
if start is None or end is None:
|
| 564 |
+
return jsonify({"error": "start and end are required"}), 400
|
| 565 |
+
|
| 566 |
+
selection_text = source["text"][start:end]
|
| 567 |
+
if not selection_text.strip():
|
| 568 |
+
return jsonify({"error": "Empty selection"}), 400
|
| 569 |
+
|
| 570 |
+
try:
|
| 571 |
+
quotes = extract_quotes_with_gemini(selection_text)
|
| 572 |
+
quotes_with_spans = find_spans(selection_text, quotes)
|
| 573 |
+
delete_quotes_in_range(source_id, start, end)
|
| 574 |
+
count = 0
|
| 575 |
+
for q in quotes_with_spans:
|
| 576 |
+
adj_start = (q["span_start"] + start) if q["span_start"] is not None else None
|
| 577 |
+
adj_end = (q["span_end"] + start) if q["span_end"] is not None else None
|
| 578 |
+
add_quote(
|
| 579 |
+
source_id=source_id,
|
| 580 |
+
span_start=adj_start,
|
| 581 |
+
span_end=adj_end,
|
| 582 |
+
quote_text=q["text"],
|
| 583 |
+
quote_type=q["quote_type"],
|
| 584 |
+
references=q["resolved_references"],
|
| 585 |
+
)
|
| 586 |
+
count += 1
|
| 587 |
+
return jsonify({"status": "ok", "count": count})
|
| 588 |
+
except Exception as e:
|
| 589 |
+
return jsonify({"error": str(e)}), 500
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
# ── API: Quotes ──────────────────────────────────────────────────────────────
|
| 593 |
+
|
| 594 |
+
@app.route("/api/quotes", methods=["POST"])
|
| 595 |
+
def api_add_quote():
|
| 596 |
+
body = request.get_json()
|
| 597 |
+
source_id = body.get("source_id")
|
| 598 |
+
if not source_id:
|
| 599 |
+
return jsonify({"error": "source_id is required"}), 400
|
| 600 |
+
quote_id = add_quote(
|
| 601 |
+
source_id=source_id,
|
| 602 |
+
span_start=body.get("span_start"),
|
| 603 |
+
span_end=body.get("span_end"),
|
| 604 |
+
quote_text=body.get("quote_text", ""),
|
| 605 |
+
quote_type=body.get("quote_type", "allusion"),
|
| 606 |
+
references=body.get("references", []),
|
| 607 |
+
)
|
| 608 |
+
return jsonify({"id": quote_id})
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
@app.route("/api/quotes/<int:quote_id>", methods=["PUT"])
|
| 612 |
+
def api_update_quote(quote_id: int):
|
| 613 |
+
body = request.get_json()
|
| 614 |
+
update_quote(
|
| 615 |
+
quote_id=quote_id,
|
| 616 |
+
quote_text=body.get("quote_text"),
|
| 617 |
+
quote_type=body.get("quote_type"),
|
| 618 |
+
span_start=body.get("span_start"),
|
| 619 |
+
span_end=body.get("span_end"),
|
| 620 |
+
references=body.get("references"),
|
| 621 |
+
)
|
| 622 |
+
return jsonify({"status": "ok"})
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
@app.route("/api/quotes/<int:quote_id>", methods=["DELETE"])
|
| 626 |
+
def api_delete_quote(quote_id: int):
|
| 627 |
+
delete_quote(quote_id)
|
| 628 |
+
return jsonify({"status": "ok"})
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
# ── API: Dashboard ───────────────────────────────────────────────────────────
|
| 632 |
+
|
| 633 |
+
@app.route("/api/dashboard")
|
| 634 |
+
def api_dashboard():
|
| 635 |
+
data = get_dashboard_data()
|
| 636 |
+
data["book_distribution"] = get_book_distribution()
|
| 637 |
+
data["type_distribution"] = get_quote_type_distribution()
|
| 638 |
+
|
| 639 |
+
bm = book_mapping()
|
| 640 |
+
for item in data["book_distribution"]:
|
| 641 |
+
info = bm.get(item["book_code"], {})
|
| 642 |
+
item["book_name"] = info.get("name", item["book_code"])
|
| 643 |
+
item["testament"] = info.get("testament", "")
|
| 644 |
+
|
| 645 |
+
for source in data["sources"]:
|
| 646 |
+
source["type_distribution"] = get_quote_type_distribution(source["id"])
|
| 647 |
+
source["book_distribution"] = get_book_distribution(source["id"])
|
| 648 |
+
|
| 649 |
+
return jsonify(data)
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
@app.route("/api/sources/<int:source_id>/distribution")
|
| 653 |
+
def api_source_distribution(source_id: int):
|
| 654 |
+
bm = book_mapping()
|
| 655 |
+
book_dist = get_book_distribution(source_id)
|
| 656 |
+
for item in book_dist:
|
| 657 |
+
info = bm.get(item["book_code"], {})
|
| 658 |
+
item["book_name"] = info.get("name", item["book_code"])
|
| 659 |
+
item["testament"] = info.get("testament", "")
|
| 660 |
+
type_dist = get_quote_type_distribution(source_id)
|
| 661 |
+
return jsonify({"book_distribution": book_dist, "type_distribution": type_dist})
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
# ── API: Settings ────────────────────────────────────────────────────────────
|
| 665 |
+
|
| 666 |
+
@app.route("/api/settings", methods=["GET"])
|
| 667 |
+
def api_get_settings():
|
| 668 |
+
settings = get_all_settings()
|
| 669 |
+
if "gemini_api_key" in settings:
|
| 670 |
+
key = settings["gemini_api_key"]
|
| 671 |
+
settings["gemini_api_key_masked"] = (
|
| 672 |
+
key[:4] + "..." + key[-4:] if len(key) > 8 else "****"
|
| 673 |
+
)
|
| 674 |
+
return jsonify(settings)
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
@app.route("/api/settings", methods=["POST"])
|
| 678 |
+
def api_save_settings():
|
| 679 |
+
body = request.get_json()
|
| 680 |
+
for key, value in body.items():
|
| 681 |
+
if value is not None and str(value).strip() != "":
|
| 682 |
+
set_setting(key, str(value))
|
| 683 |
+
return jsonify({"status": "ok"})
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
@app.route("/api/book-mapping")
|
| 687 |
+
def api_book_mapping():
|
| 688 |
+
return jsonify(book_mapping())
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
@app.route("/api/bible/books")
|
| 692 |
+
def api_bible_books():
|
| 693 |
+
bm = book_mapping()
|
| 694 |
+
books = [
|
| 695 |
+
{"code": code, "name": info["name"], "testament": info["testament"]}
|
| 696 |
+
for code, info in sorted(bm.items(), key=lambda x: x[1]["name"])
|
| 697 |
+
]
|
| 698 |
+
return jsonify(books)
|
| 699 |
+
|
| 700 |
+
|
| 701 |
+
@app.route("/api/bible/<book_code>/chapters")
|
| 702 |
+
def api_bible_chapters(book_code: str):
|
| 703 |
+
bs = bible_structure()
|
| 704 |
+
book = book_code.strip().lower()
|
| 705 |
+
if book not in bs:
|
| 706 |
+
return jsonify([])
|
| 707 |
+
return jsonify(sorted(bs[book].keys()))
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
@app.route("/api/bible/<book_code>/<int:chapter>/verses")
|
| 711 |
+
def api_bible_verses_list(book_code: str, chapter: int):
|
| 712 |
+
bs = bible_structure()
|
| 713 |
+
book = book_code.strip().lower()
|
| 714 |
+
if book not in bs or chapter not in bs[book]:
|
| 715 |
+
return jsonify([])
|
| 716 |
+
return jsonify(bs[book][chapter])
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
init_db()
|
| 720 |
+
|
| 721 |
+
if __name__ == "__main__":
|
| 722 |
+
_cache_db = bool(os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB"))
|
| 723 |
+
_host = os.environ.get("SD_HOST", "127.0.0.1")
|
| 724 |
+
_port = int(os.environ.get("SD_PORT", "5001"))
|
| 725 |
+
# Disable the reloader in cache-db mode: the reloader forks the process,
|
| 726 |
+
# which would create a fresh in-memory database and lose all data.
|
| 727 |
+
_debug = not _cache_db
|
| 728 |
+
app.run(debug=_debug, port=_port, host=_host, use_reloader=_debug)
|
data/bible.tsv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/book_mapping.tsv
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
testament work_name book_code
|
| 2 |
+
ot Genesis gen
|
| 3 |
+
ot Exodus exod
|
| 4 |
+
ot Leviticus lev
|
| 5 |
+
ot Numeri num
|
| 6 |
+
ot Deuteronomium deut
|
| 7 |
+
ot Iosue josh
|
| 8 |
+
ot Iudicum judg
|
| 9 |
+
ot Ruth ruth
|
| 10 |
+
ot I Samuelis 1sam
|
| 11 |
+
ot II Samuelis 2sam
|
| 12 |
+
ot I Regum 1kgs
|
| 13 |
+
ot II Regum 2kgs
|
| 14 |
+
ot I Paralipomenon 1chr
|
| 15 |
+
ot II Paralipomenon 2chr
|
| 16 |
+
ot Esdrae ezra
|
| 17 |
+
ot Nehemiae neh
|
| 18 |
+
ap Tobiae tob
|
| 19 |
+
ap Iudith jdt
|
| 20 |
+
ot Esther esth
|
| 21 |
+
ap I Machabaeorum 1macc
|
| 22 |
+
ap II Machabaeorum 2macc
|
| 23 |
+
ot Iob job
|
| 24 |
+
ot Psalmi ps
|
| 25 |
+
ot Proverbia prov
|
| 26 |
+
ot Ecclesiastes eccl
|
| 27 |
+
ot Canticum Canticorum song
|
| 28 |
+
ap Sapientia wis
|
| 29 |
+
ap Ecclesiasticus sir
|
| 30 |
+
ot Isaias isa
|
| 31 |
+
ot Ieremias jer
|
| 32 |
+
ot Lamentationes lam
|
| 33 |
+
ap Baruch bar
|
| 34 |
+
ot Ezechiel ezek
|
| 35 |
+
ot Daniel dan
|
| 36 |
+
ot Osee hos
|
| 37 |
+
ot Ioel joel
|
| 38 |
+
ot Amos amos
|
| 39 |
+
ot Abdias obad
|
| 40 |
+
ot Ionas jonah
|
| 41 |
+
ot Michaeas mic
|
| 42 |
+
ot Nahum nah
|
| 43 |
+
ot Habacuc hab
|
| 44 |
+
ot Sophonias zeph
|
| 45 |
+
ot Aggaeus hag
|
| 46 |
+
ot Zacharias zech
|
| 47 |
+
ot Malachias mal
|
| 48 |
+
nt Matthaeus matt
|
| 49 |
+
nt Marcus mark
|
| 50 |
+
nt Lucas luke
|
| 51 |
+
nt Ioannes john
|
| 52 |
+
nt Actus Apostolorum acts
|
| 53 |
+
nt Romanos rom
|
| 54 |
+
nt I Corinthios 1cor
|
| 55 |
+
nt II Corinthios 2cor
|
| 56 |
+
nt Galatas gal
|
| 57 |
+
nt Ephesios eph
|
| 58 |
+
nt Philippenses phil
|
| 59 |
+
nt Colossenses col
|
| 60 |
+
nt I Thessalonicenses 1thess
|
| 61 |
+
nt II Thessalonicenses 2thess
|
| 62 |
+
nt I Timotheum 1tim
|
| 63 |
+
nt II Timotheum 2tim
|
| 64 |
+
nt Titum titus
|
| 65 |
+
nt Philemonem phlm
|
| 66 |
+
nt Hebraeos heb
|
| 67 |
+
nt Iacobi jas
|
| 68 |
+
nt I Petri 1pet
|
| 69 |
+
nt II Petri 2pet
|
| 70 |
+
nt I Ioannis 1john
|
| 71 |
+
nt II Ioannis 2john
|
| 72 |
+
nt III Ioannis 3john
|
| 73 |
+
nt Iudae jude
|
| 74 |
+
nt Apocalypsis rev
|
database.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sqlite3
|
| 3 |
+
import re
|
| 4 |
+
import threading
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
|
| 8 |
+
# SD_DB_DIR lets Docker users put the database in a mounted volume.
|
| 9 |
+
_db_dir = os.environ.get("SD_DB_DIR")
|
| 10 |
+
DB_PATH = (
|
| 11 |
+
Path(_db_dir) / "scripture_detector.db"
|
| 12 |
+
if _db_dir
|
| 13 |
+
else Path(__file__).resolve().parent / "scripture_detector.db"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# ── cache-db (in-memory) mode ─────────────────────────────────────────────────
|
| 17 |
+
# Enabled by setting env var SCRIPTURE_DETECTOR_CACHE_DB=1 before this module
|
| 18 |
+
# is imported. app.py sets this when --cache-db flag is present.
|
| 19 |
+
_CACHE_MODE: bool = os.environ.get("SCRIPTURE_DETECTOR_CACHE_DB", "") in ("1", "true", "yes")
|
| 20 |
+
_cache_conn: sqlite3.Connection | None = None
|
| 21 |
+
_cache_lock: threading.Lock = threading.Lock()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _make_cache_connection() -> sqlite3.Connection:
|
| 25 |
+
conn = sqlite3.connect(":memory:", check_same_thread=False)
|
| 26 |
+
conn.row_factory = sqlite3.Row
|
| 27 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 28 |
+
return conn
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_connection() -> sqlite3.Connection:
|
| 32 |
+
global _cache_conn
|
| 33 |
+
if _CACHE_MODE:
|
| 34 |
+
if _cache_conn is None:
|
| 35 |
+
_cache_conn = _make_cache_connection()
|
| 36 |
+
return _cache_conn
|
| 37 |
+
conn = sqlite3.connect(str(DB_PATH))
|
| 38 |
+
conn.row_factory = sqlite3.Row
|
| 39 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 40 |
+
return conn
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@contextmanager
|
| 44 |
+
def get_db():
|
| 45 |
+
if _CACHE_MODE:
|
| 46 |
+
# Serialise access to the single in-memory connection.
|
| 47 |
+
with _cache_lock:
|
| 48 |
+
conn = get_connection()
|
| 49 |
+
try:
|
| 50 |
+
yield conn
|
| 51 |
+
conn.commit()
|
| 52 |
+
except Exception:
|
| 53 |
+
conn.rollback()
|
| 54 |
+
raise
|
| 55 |
+
else:
|
| 56 |
+
conn = get_connection()
|
| 57 |
+
try:
|
| 58 |
+
yield conn
|
| 59 |
+
conn.commit()
|
| 60 |
+
except Exception:
|
| 61 |
+
conn.rollback()
|
| 62 |
+
raise
|
| 63 |
+
finally:
|
| 64 |
+
conn.close()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def init_db():
|
| 68 |
+
with get_db() as conn:
|
| 69 |
+
conn.executescript("""
|
| 70 |
+
CREATE TABLE IF NOT EXISTS sources (
|
| 71 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 72 |
+
name TEXT NOT NULL,
|
| 73 |
+
text TEXT NOT NULL,
|
| 74 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 75 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 76 |
+
);
|
| 77 |
+
CREATE TABLE IF NOT EXISTS quotes (
|
| 78 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 79 |
+
source_id INTEGER NOT NULL,
|
| 80 |
+
span_start INTEGER,
|
| 81 |
+
span_end INTEGER,
|
| 82 |
+
quote_text TEXT NOT NULL,
|
| 83 |
+
quote_type TEXT NOT NULL DEFAULT 'allusion'
|
| 84 |
+
CHECK(quote_type IN ('full','partial','paraphrase','allusion')),
|
| 85 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 86 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 87 |
+
FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE
|
| 88 |
+
);
|
| 89 |
+
CREATE TABLE IF NOT EXISTS quote_references (
|
| 90 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 91 |
+
quote_id INTEGER NOT NULL,
|
| 92 |
+
reference TEXT NOT NULL,
|
| 93 |
+
book_code TEXT,
|
| 94 |
+
FOREIGN KEY (quote_id) REFERENCES quotes(id) ON DELETE CASCADE
|
| 95 |
+
);
|
| 96 |
+
CREATE TABLE IF NOT EXISTS settings (
|
| 97 |
+
key TEXT PRIMARY KEY,
|
| 98 |
+
value TEXT NOT NULL
|
| 99 |
+
);
|
| 100 |
+
""")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def extract_book_code(reference: str) -> str:
|
| 104 |
+
match = re.match(r"^([a-z0-9]+)_", reference.strip().lower())
|
| 105 |
+
return match.group(1) if match else ""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# ── Sources ──────────────────────────────────────────────────────────────────
|
| 109 |
+
|
| 110 |
+
def create_source(name: str, text: str) -> int:
|
| 111 |
+
with get_db() as conn:
|
| 112 |
+
cur = conn.execute(
|
| 113 |
+
"INSERT INTO sources (name, text) VALUES (?, ?)", (name, text)
|
| 114 |
+
)
|
| 115 |
+
return cur.lastrowid
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def get_source(source_id: int) -> dict | None:
|
| 119 |
+
with get_db() as conn:
|
| 120 |
+
row = conn.execute(
|
| 121 |
+
"SELECT * FROM sources WHERE id = ?", (source_id,)
|
| 122 |
+
).fetchone()
|
| 123 |
+
return dict(row) if row else None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_all_sources() -> list[dict]:
|
| 127 |
+
with get_db() as conn:
|
| 128 |
+
rows = conn.execute(
|
| 129 |
+
"""SELECT s.id, s.name, s.created_at, s.updated_at,
|
| 130 |
+
LENGTH(s.text) as text_length,
|
| 131 |
+
COUNT(q.id) as quote_count
|
| 132 |
+
FROM sources s
|
| 133 |
+
LEFT JOIN quotes q ON q.source_id = s.id
|
| 134 |
+
GROUP BY s.id
|
| 135 |
+
ORDER BY s.created_at DESC"""
|
| 136 |
+
).fetchall()
|
| 137 |
+
return [dict(r) for r in rows]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def delete_source(source_id: int):
|
| 141 |
+
with get_db() as conn:
|
| 142 |
+
conn.execute("DELETE FROM sources WHERE id = ?", (source_id,))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ── Quotes ───────────────────────────────────────────────────────────────────
|
| 146 |
+
|
| 147 |
+
def add_quote(
|
| 148 |
+
source_id: int,
|
| 149 |
+
span_start: int | None,
|
| 150 |
+
span_end: int | None,
|
| 151 |
+
quote_text: str,
|
| 152 |
+
quote_type: str,
|
| 153 |
+
references: list[str],
|
| 154 |
+
) -> int:
|
| 155 |
+
with get_db() as conn:
|
| 156 |
+
cur = conn.execute(
|
| 157 |
+
"""INSERT INTO quotes (source_id, span_start, span_end, quote_text, quote_type)
|
| 158 |
+
VALUES (?, ?, ?, ?, ?)""",
|
| 159 |
+
(source_id, span_start, span_end, quote_text, quote_type),
|
| 160 |
+
)
|
| 161 |
+
quote_id = cur.lastrowid
|
| 162 |
+
for ref in references:
|
| 163 |
+
ref_clean = ref.strip().lower()
|
| 164 |
+
book = extract_book_code(ref_clean)
|
| 165 |
+
conn.execute(
|
| 166 |
+
"INSERT INTO quote_references (quote_id, reference, book_code) VALUES (?, ?, ?)",
|
| 167 |
+
(quote_id, ref_clean, book),
|
| 168 |
+
)
|
| 169 |
+
return quote_id
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def update_quote(
|
| 173 |
+
quote_id: int,
|
| 174 |
+
quote_text: str = None,
|
| 175 |
+
quote_type: str = None,
|
| 176 |
+
span_start: int = None,
|
| 177 |
+
span_end: int = None,
|
| 178 |
+
references: list[str] = None,
|
| 179 |
+
):
|
| 180 |
+
with get_db() as conn:
|
| 181 |
+
if quote_text is not None:
|
| 182 |
+
conn.execute(
|
| 183 |
+
"UPDATE quotes SET quote_text=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
| 184 |
+
(quote_text, quote_id),
|
| 185 |
+
)
|
| 186 |
+
if quote_type is not None:
|
| 187 |
+
conn.execute(
|
| 188 |
+
"UPDATE quotes SET quote_type=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
| 189 |
+
(quote_type, quote_id),
|
| 190 |
+
)
|
| 191 |
+
if span_start is not None:
|
| 192 |
+
conn.execute(
|
| 193 |
+
"UPDATE quotes SET span_start=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
| 194 |
+
(span_start, quote_id),
|
| 195 |
+
)
|
| 196 |
+
if span_end is not None:
|
| 197 |
+
conn.execute(
|
| 198 |
+
"UPDATE quotes SET span_end=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
| 199 |
+
(span_end, quote_id),
|
| 200 |
+
)
|
| 201 |
+
if references is not None:
|
| 202 |
+
conn.execute("DELETE FROM quote_references WHERE quote_id=?", (quote_id,))
|
| 203 |
+
for ref in references:
|
| 204 |
+
ref_clean = ref.strip().lower()
|
| 205 |
+
book = extract_book_code(ref_clean)
|
| 206 |
+
conn.execute(
|
| 207 |
+
"INSERT INTO quote_references (quote_id, reference, book_code) VALUES (?, ?, ?)",
|
| 208 |
+
(quote_id, ref_clean, book),
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def delete_quote(quote_id: int):
|
| 213 |
+
with get_db() as conn:
|
| 214 |
+
conn.execute("DELETE FROM quotes WHERE id = ?", (quote_id,))
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def get_quotes_for_source(source_id: int) -> list[dict]:
|
| 218 |
+
with get_db() as conn:
|
| 219 |
+
quotes = conn.execute(
|
| 220 |
+
"SELECT * FROM quotes WHERE source_id = ? ORDER BY span_start",
|
| 221 |
+
(source_id,),
|
| 222 |
+
).fetchall()
|
| 223 |
+
result = []
|
| 224 |
+
for q in quotes:
|
| 225 |
+
qd = dict(q)
|
| 226 |
+
refs = conn.execute(
|
| 227 |
+
"SELECT reference, book_code FROM quote_references WHERE quote_id = ?",
|
| 228 |
+
(q["id"],),
|
| 229 |
+
).fetchall()
|
| 230 |
+
qd["references"] = [dict(r) for r in refs]
|
| 231 |
+
result.append(qd)
|
| 232 |
+
return result
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def delete_quotes_for_source(source_id: int):
|
| 236 |
+
with get_db() as conn:
|
| 237 |
+
conn.execute("DELETE FROM quotes WHERE source_id = ?", (source_id,))
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def delete_quotes_in_range(source_id: int, start: int, end: int):
|
| 241 |
+
with get_db() as conn:
|
| 242 |
+
conn.execute(
|
| 243 |
+
"""DELETE FROM quotes WHERE source_id = ?
|
| 244 |
+
AND span_start IS NOT NULL AND span_end IS NOT NULL
|
| 245 |
+
AND NOT (span_end <= ? OR span_start >= ?)""",
|
| 246 |
+
(source_id, start, end),
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ── Settings ─────────────────────────────────────────────────────────────────
|
| 251 |
+
|
| 252 |
+
def get_setting(key: str, default: str = None) -> str | None:
|
| 253 |
+
with get_db() as conn:
|
| 254 |
+
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
| 255 |
+
return row["value"] if row else default
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def set_setting(key: str, value: str):
|
| 259 |
+
with get_db() as conn:
|
| 260 |
+
conn.execute(
|
| 261 |
+
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
| 262 |
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
| 263 |
+
(key, value),
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def get_all_settings() -> dict:
|
| 268 |
+
with get_db() as conn:
|
| 269 |
+
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
| 270 |
+
return {r["key"]: r["value"] for r in rows}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ── Analytics ────────────────────────────────────────────────────────────────
|
| 274 |
+
|
| 275 |
+
def get_book_distribution(source_id: int = None) -> list[dict]:
|
| 276 |
+
with get_db() as conn:
|
| 277 |
+
if source_id:
|
| 278 |
+
rows = conn.execute(
|
| 279 |
+
"""SELECT qr.book_code, COUNT(*) as count
|
| 280 |
+
FROM quote_references qr
|
| 281 |
+
JOIN quotes q ON qr.quote_id = q.id
|
| 282 |
+
WHERE q.source_id = ? AND qr.book_code != ''
|
| 283 |
+
GROUP BY qr.book_code ORDER BY count DESC""",
|
| 284 |
+
(source_id,),
|
| 285 |
+
).fetchall()
|
| 286 |
+
else:
|
| 287 |
+
rows = conn.execute(
|
| 288 |
+
"""SELECT qr.book_code, COUNT(*) as count
|
| 289 |
+
FROM quote_references qr
|
| 290 |
+
JOIN quotes q ON qr.quote_id = q.id
|
| 291 |
+
WHERE qr.book_code != ''
|
| 292 |
+
GROUP BY qr.book_code ORDER BY count DESC"""
|
| 293 |
+
).fetchall()
|
| 294 |
+
return [dict(r) for r in rows]
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
def get_quote_type_distribution(source_id: int = None) -> dict:
|
| 298 |
+
with get_db() as conn:
|
| 299 |
+
if source_id:
|
| 300 |
+
rows = conn.execute(
|
| 301 |
+
"SELECT quote_type, COUNT(*) as count FROM quotes WHERE source_id=? GROUP BY quote_type",
|
| 302 |
+
(source_id,),
|
| 303 |
+
).fetchall()
|
| 304 |
+
else:
|
| 305 |
+
rows = conn.execute(
|
| 306 |
+
"SELECT quote_type, COUNT(*) as count FROM quotes GROUP BY quote_type"
|
| 307 |
+
).fetchall()
|
| 308 |
+
return {r["quote_type"]: r["count"] for r in rows}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def search_sources(filters: list[dict], logic: str = "AND") -> dict:
|
| 312 |
+
"""
|
| 313 |
+
Search sources by text content and/or scripture references.
|
| 314 |
+
|
| 315 |
+
filters: list of dicts, each with:
|
| 316 |
+
- type: 'text' | 'book' | 'chapter' | 'verse'
|
| 317 |
+
- value: the query string (already lowercased by caller)
|
| 318 |
+
logic: 'AND' (source must match all filters) |
|
| 319 |
+
'OR' (source must match any filter)
|
| 320 |
+
|
| 321 |
+
Returns {'total': int, 'results': [enriched source dicts]}
|
| 322 |
+
"""
|
| 323 |
+
valid = [f for f in filters if f.get("value", "").strip()]
|
| 324 |
+
if not valid:
|
| 325 |
+
return {"total": 0, "results": []}
|
| 326 |
+
|
| 327 |
+
with get_db() as conn:
|
| 328 |
+
# --- per-filter: map source_id → list of evidence dicts ---------------
|
| 329 |
+
filter_evidence: list[dict[int, list[dict]]] = []
|
| 330 |
+
|
| 331 |
+
for f in valid:
|
| 332 |
+
ftype = f["type"]
|
| 333 |
+
fval = f["value"].strip().lower()
|
| 334 |
+
ev: dict[int, list[dict]] = {}
|
| 335 |
+
|
| 336 |
+
if ftype == "text":
|
| 337 |
+
rows = conn.execute(
|
| 338 |
+
"SELECT id, name, text FROM sources "
|
| 339 |
+
"WHERE lower(text) LIKE ? OR lower(name) LIKE ?",
|
| 340 |
+
[f"%{fval}%", f"%{fval}%"],
|
| 341 |
+
).fetchall()
|
| 342 |
+
for r in rows:
|
| 343 |
+
full = r["text"]
|
| 344 |
+
idx = full.lower().find(fval)
|
| 345 |
+
if idx >= 0:
|
| 346 |
+
s0 = max(0, idx - 100)
|
| 347 |
+
s1 = min(len(full), idx + len(fval) + 100)
|
| 348 |
+
pre = ("…" if s0 > 0 else "")
|
| 349 |
+
post = ("…" if s1 < len(full) else "")
|
| 350 |
+
snippet = pre + full[s0:s1] + post
|
| 351 |
+
else:
|
| 352 |
+
# Query matched the source name, not the text body
|
| 353 |
+
snippet = r["name"]
|
| 354 |
+
ev[r["id"]] = ev.get(r["id"], [])
|
| 355 |
+
ev[r["id"]].append({
|
| 356 |
+
"kind": "text", "snippet": snippet,
|
| 357 |
+
"query": f["value"], "offset": idx,
|
| 358 |
+
})
|
| 359 |
+
|
| 360 |
+
elif ftype == "book":
|
| 361 |
+
rows = conn.execute(
|
| 362 |
+
"""SELECT DISTINCT q.source_id,
|
| 363 |
+
qr.reference, q.quote_text, q.quote_type
|
| 364 |
+
FROM quotes q
|
| 365 |
+
JOIN quote_references qr ON qr.quote_id = q.id
|
| 366 |
+
WHERE qr.book_code = ?""",
|
| 367 |
+
[fval],
|
| 368 |
+
).fetchall()
|
| 369 |
+
for r in rows:
|
| 370 |
+
sid = r["source_id"]
|
| 371 |
+
ev.setdefault(sid, [])
|
| 372 |
+
ev[sid].append({
|
| 373 |
+
"kind": "ref", "reference": r["reference"],
|
| 374 |
+
"quote_text": r["quote_text"][:120],
|
| 375 |
+
"quote_type": r["quote_type"],
|
| 376 |
+
})
|
| 377 |
+
|
| 378 |
+
elif ftype == "chapter":
|
| 379 |
+
# fval is e.g. "gen_1"
|
| 380 |
+
rows = conn.execute(
|
| 381 |
+
"""SELECT DISTINCT q.source_id,
|
| 382 |
+
qr.reference, q.quote_text, q.quote_type
|
| 383 |
+
FROM quotes q
|
| 384 |
+
JOIN quote_references qr ON qr.quote_id = q.id
|
| 385 |
+
WHERE qr.reference LIKE ?""",
|
| 386 |
+
[f"{fval}:%"],
|
| 387 |
+
).fetchall()
|
| 388 |
+
for r in rows:
|
| 389 |
+
sid = r["source_id"]
|
| 390 |
+
ev.setdefault(sid, [])
|
| 391 |
+
ev[sid].append({
|
| 392 |
+
"kind": "ref", "reference": r["reference"],
|
| 393 |
+
"quote_text": r["quote_text"][:120],
|
| 394 |
+
"quote_type": r["quote_type"],
|
| 395 |
+
})
|
| 396 |
+
|
| 397 |
+
elif ftype == "verse":
|
| 398 |
+
# fval is e.g. "gen_1:1"
|
| 399 |
+
rows = conn.execute(
|
| 400 |
+
"""SELECT DISTINCT q.source_id,
|
| 401 |
+
qr.reference, q.quote_text, q.quote_type
|
| 402 |
+
FROM quotes q
|
| 403 |
+
JOIN quote_references qr ON qr.quote_id = q.id
|
| 404 |
+
WHERE qr.reference = ?""",
|
| 405 |
+
[fval],
|
| 406 |
+
).fetchall()
|
| 407 |
+
for r in rows:
|
| 408 |
+
sid = r["source_id"]
|
| 409 |
+
ev.setdefault(sid, [])
|
| 410 |
+
ev[sid].append({
|
| 411 |
+
"kind": "ref", "reference": r["reference"],
|
| 412 |
+
"quote_text": r["quote_text"][:120],
|
| 413 |
+
"quote_type": r["quote_type"],
|
| 414 |
+
})
|
| 415 |
+
|
| 416 |
+
filter_evidence.append(ev)
|
| 417 |
+
|
| 418 |
+
# --- combine filter result sets ---------------------------------------
|
| 419 |
+
id_sets = [set(ev.keys()) for ev in filter_evidence]
|
| 420 |
+
if logic == "AND":
|
| 421 |
+
matching: set[int] = id_sets[0]
|
| 422 |
+
for s in id_sets[1:]:
|
| 423 |
+
matching &= s
|
| 424 |
+
else:
|
| 425 |
+
matching = set()
|
| 426 |
+
for s in id_sets:
|
| 427 |
+
matching |= s
|
| 428 |
+
|
| 429 |
+
if not matching:
|
| 430 |
+
return {"total": 0, "results": []}
|
| 431 |
+
|
| 432 |
+
# --- fetch source rows with aggregate stats --------------------------
|
| 433 |
+
placeholders = ",".join("?" * len(matching))
|
| 434 |
+
src_rows = conn.execute(
|
| 435 |
+
f"""SELECT s.id, s.name, s.created_at,
|
| 436 |
+
COUNT(q.id) as quote_count
|
| 437 |
+
FROM sources s
|
| 438 |
+
LEFT JOIN quotes q ON q.source_id = s.id
|
| 439 |
+
WHERE s.id IN ({placeholders})
|
| 440 |
+
GROUP BY s.id ORDER BY s.created_at DESC""",
|
| 441 |
+
list(matching),
|
| 442 |
+
).fetchall()
|
| 443 |
+
|
| 444 |
+
results = []
|
| 445 |
+
for src in src_rows:
|
| 446 |
+
sd = dict(src)
|
| 447 |
+
|
| 448 |
+
# type distribution
|
| 449 |
+
td_rows = conn.execute(
|
| 450 |
+
"SELECT quote_type, COUNT(*) as c FROM quotes "
|
| 451 |
+
"WHERE source_id=? GROUP BY quote_type",
|
| 452 |
+
[sd["id"]],
|
| 453 |
+
).fetchall()
|
| 454 |
+
sd["type_distribution"] = {r["quote_type"]: r["c"] for r in td_rows}
|
| 455 |
+
|
| 456 |
+
# book distribution (top 5)
|
| 457 |
+
bd_rows = conn.execute(
|
| 458 |
+
"""SELECT qr.book_code, COUNT(*) as count
|
| 459 |
+
FROM quote_references qr
|
| 460 |
+
JOIN quotes q ON qr.quote_id = q.id
|
| 461 |
+
WHERE q.source_id = ? AND qr.book_code != ''
|
| 462 |
+
GROUP BY qr.book_code ORDER BY count DESC LIMIT 5""",
|
| 463 |
+
[sd["id"]],
|
| 464 |
+
).fetchall()
|
| 465 |
+
sd["book_distribution"] = [dict(r) for r in bd_rows]
|
| 466 |
+
|
| 467 |
+
# aggregate evidence from all matching filters, deduplicate refs
|
| 468 |
+
all_ev: list[dict] = []
|
| 469 |
+
seen_refs: set[str] = set()
|
| 470 |
+
for ev in filter_evidence:
|
| 471 |
+
if sd["id"] in ev:
|
| 472 |
+
for item in ev[sd["id"]]:
|
| 473 |
+
if item["kind"] == "ref":
|
| 474 |
+
key = item["reference"]
|
| 475 |
+
if key in seen_refs:
|
| 476 |
+
continue
|
| 477 |
+
seen_refs.add(key)
|
| 478 |
+
all_ev.append(item)
|
| 479 |
+
sd["match_evidence"] = all_ev[:10]
|
| 480 |
+
|
| 481 |
+
results.append(sd)
|
| 482 |
+
|
| 483 |
+
return {"total": len(results), "results": results}
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def get_dashboard_data() -> dict:
|
| 487 |
+
with get_db() as conn:
|
| 488 |
+
source_count = conn.execute("SELECT COUNT(*) as c FROM sources").fetchone()["c"]
|
| 489 |
+
quote_count = conn.execute("SELECT COUNT(*) as c FROM quotes").fetchone()["c"]
|
| 490 |
+
ref_count = conn.execute("SELECT COUNT(*) as c FROM quote_references").fetchone()["c"]
|
| 491 |
+
|
| 492 |
+
sources = conn.execute(
|
| 493 |
+
"""SELECT s.id, s.name, s.created_at,
|
| 494 |
+
COUNT(q.id) as quote_count
|
| 495 |
+
FROM sources s
|
| 496 |
+
LEFT JOIN quotes q ON q.source_id = s.id
|
| 497 |
+
GROUP BY s.id ORDER BY s.created_at DESC"""
|
| 498 |
+
).fetchall()
|
| 499 |
+
|
| 500 |
+
return {
|
| 501 |
+
"source_count": source_count,
|
| 502 |
+
"quote_count": quote_count,
|
| 503 |
+
"reference_count": ref_count,
|
| 504 |
+
"sources": [dict(s) for s in sources],
|
| 505 |
+
}
|
main.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import csv
|
| 3 |
+
import re
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from google import genai
|
| 10 |
+
from rich.console import Console
|
| 11 |
+
from rich.table import Table
|
| 12 |
+
from rich.panel import Panel
|
| 13 |
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
|
| 14 |
+
from rich import box
|
| 15 |
+
|
| 16 |
+
PROJECT_ID = "cultural-heritage-gemini"
|
| 17 |
+
LOCATION = "global"
|
| 18 |
+
MODEL = "gemini-3-pro-preview"
|
| 19 |
+
|
| 20 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 21 |
+
PROBLEMS_DIR = PROJECT_ROOT / "data" / "task" / "problems"
|
| 22 |
+
SOLUTIONS_DIR = PROJECT_ROOT / "data" / "task" / "solutions"
|
| 23 |
+
REFERENCE_MAPPING_PATH = PROJECT_ROOT / "data" / "reference_mapping.json"
|
| 24 |
+
BIBLE_TSV_PATH = PROJECT_ROOT / "data" / "bible.tsv"
|
| 25 |
+
OUTPUT_DIR = PROJECT_ROOT / "output"
|
| 26 |
+
|
| 27 |
+
console = Console()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def load_reference_mapping() -> dict[str, str]:
|
| 31 |
+
with open(REFERENCE_MAPPING_PATH) as f:
|
| 32 |
+
return json.load(f)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_problem(problem_id: str) -> str:
|
| 36 |
+
return (PROBLEMS_DIR / f"{problem_id}.txt").read_text(encoding="utf-8")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_solution(problem_id: str) -> list[dict]:
|
| 40 |
+
with open(SOLUTIONS_DIR / f"{problem_id}.json") as f:
|
| 41 |
+
return json.load(f)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_valid_book_codes() -> list[str]:
|
| 45 |
+
codes: set[str] = set()
|
| 46 |
+
with open(BIBLE_TSV_PATH, newline="") as f:
|
| 47 |
+
for row in csv.DictReader(f, delimiter="\t"):
|
| 48 |
+
codes.add(row["book_code"].strip().lower())
|
| 49 |
+
return sorted(codes)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_prompt(text: str, valid_book_codes: list[str], ref_mapping: dict[str, str]) -> str:
|
| 53 |
+
codes_str = ", ".join(valid_book_codes)
|
| 54 |
+
mapping_lines = "\n".join(f" {k} -> {v}" for k, v in sorted(ref_mapping.items()))
|
| 55 |
+
return f"""You are an expert in medieval Latin texts and the Latin Vulgate Bible.
|
| 56 |
+
|
| 57 |
+
Given the following Latin text from a Carolingian-era ecclesiastical document, identify ALL scriptural (Biblical) quotations, partial quotations, paraphrases, and clear allusions to specific Bible verses.
|
| 58 |
+
|
| 59 |
+
For each identified passage:
|
| 60 |
+
1. Extract the EXACT text as it appears in the document — preserve the original spelling, punctuation, and word order verbatim.
|
| 61 |
+
2. Identify the specific Bible verse(s) being quoted or referenced.
|
| 62 |
+
3. Classify the type of reuse as one of:
|
| 63 |
+
- "full" — a complete or near-complete verse quoted verbatim from the Vulgate.
|
| 64 |
+
- "partial" — a recognisable portion of a verse, quoted with minor variation or truncation.
|
| 65 |
+
- "paraphrase" — the biblical content is clearly restated in different words while preserving the meaning.
|
| 66 |
+
- "allusion" — a brief phrase, thematic echo, or indirect reference to a specific verse without quoting or restating it.
|
| 67 |
+
|
| 68 |
+
Reference format: book_chapter:verse (e.g. matt_5:9, ps_82:14, 1cor_15:33, dan_4:14)
|
| 69 |
+
CRITICAL: Each reference must be a SINGLE verse. Never use ranges like matt_15:1-2.
|
| 70 |
+
Instead, list each verse separately: matt_15:1, matt_15:2.
|
| 71 |
+
|
| 72 |
+
Valid book codes: {codes_str}
|
| 73 |
+
|
| 74 |
+
Common abbreviation-to-code mapping (for your reference):
|
| 75 |
+
{mapping_lines}
|
| 76 |
+
|
| 77 |
+
Important:
|
| 78 |
+
- Include both direct quotes and partial quotes / paraphrases / allusions.
|
| 79 |
+
- A single passage may reference multiple Bible verses — list all of them.
|
| 80 |
+
- Use the Vulgate Latin text as your primary reference for identifying quotes.
|
| 81 |
+
- Be thorough — identify even brief allusions to specific verses.
|
| 82 |
+
- For Psalms, use the Vulgate / LXX numbering (which may differ from Hebrew numbering by 1).
|
| 83 |
+
- The extracted text must be a verbatim substring of the input document.
|
| 84 |
+
|
| 85 |
+
TEXT:
|
| 86 |
+
{text}"""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def extract_quotes_with_gemini(
|
| 90 |
+
text: str,
|
| 91 |
+
valid_book_codes: list[str],
|
| 92 |
+
ref_mapping: dict[str, str],
|
| 93 |
+
) -> list[dict]:
|
| 94 |
+
client = genai.Client(vertexai=True, project=PROJECT_ID, location=LOCATION)
|
| 95 |
+
|
| 96 |
+
prompt = build_prompt(text, valid_book_codes, ref_mapping)
|
| 97 |
+
|
| 98 |
+
response_schema = {
|
| 99 |
+
"type": "ARRAY",
|
| 100 |
+
"items": {
|
| 101 |
+
"type": "OBJECT",
|
| 102 |
+
"properties": {
|
| 103 |
+
"text": {
|
| 104 |
+
"type": "STRING",
|
| 105 |
+
"description": (
|
| 106 |
+
"The exact text of the scriptural quote or allusion "
|
| 107 |
+
"as it appears verbatim in the document"
|
| 108 |
+
),
|
| 109 |
+
},
|
| 110 |
+
"resolved_references": {
|
| 111 |
+
"type": "ARRAY",
|
| 112 |
+
"items": {"type": "STRING"},
|
| 113 |
+
"description": (
|
| 114 |
+
"List of Bible verse references in format "
|
| 115 |
+
"book_chapter:verse (e.g. matt_5:9)"
|
| 116 |
+
),
|
| 117 |
+
},
|
| 118 |
+
"quote_type": {
|
| 119 |
+
"type": "STRING",
|
| 120 |
+
"enum": ["full", "partial", "paraphrase", "allusion"],
|
| 121 |
+
"description": (
|
| 122 |
+
"full = complete verse quoted verbatim, "
|
| 123 |
+
"partial = recognisable portion with minor variation, "
|
| 124 |
+
"paraphrase = biblical content restated in different words, "
|
| 125 |
+
"allusion = brief phrase or thematic echo"
|
| 126 |
+
),
|
| 127 |
+
},
|
| 128 |
+
},
|
| 129 |
+
"required": ["text", "resolved_references", "quote_type"],
|
| 130 |
+
},
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
response = client.models.generate_content(
|
| 134 |
+
model=MODEL,
|
| 135 |
+
contents=prompt,
|
| 136 |
+
config={
|
| 137 |
+
"response_mime_type": "application/json",
|
| 138 |
+
"response_schema": response_schema,
|
| 139 |
+
},
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
quotes = json.loads(response.text)
|
| 143 |
+
for q in quotes:
|
| 144 |
+
q["resolved_references"] = expand_range_references(q.get("resolved_references", []))
|
| 145 |
+
return quotes
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def find_spans(text: str, quotes: list[dict]) -> list[dict]:
|
| 149 |
+
results = []
|
| 150 |
+
for quote in quotes:
|
| 151 |
+
qt = quote["text"]
|
| 152 |
+
idx = text.find(qt)
|
| 153 |
+
if idx == -1:
|
| 154 |
+
idx = text.lower().find(qt.lower())
|
| 155 |
+
span_start = idx if idx != -1 else None
|
| 156 |
+
span_end = (idx + len(qt)) if idx != -1 else None
|
| 157 |
+
results.append({
|
| 158 |
+
"text": qt,
|
| 159 |
+
"span_start": span_start,
|
| 160 |
+
"span_end": span_end,
|
| 161 |
+
"resolved_references": quote["resolved_references"],
|
| 162 |
+
"quote_type": quote.get("quote_type", "allusion"),
|
| 163 |
+
})
|
| 164 |
+
return results
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
_RANGE_RE = re.compile(r"^(.+_\d+):(\d+)-(\d+)$")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def expand_range_references(refs: list[str]) -> list[str]:
|
| 171 |
+
expanded: list[str] = []
|
| 172 |
+
for ref in refs:
|
| 173 |
+
m = _RANGE_RE.match(ref.strip())
|
| 174 |
+
if m:
|
| 175 |
+
prefix, start, end = m.group(1), int(m.group(2)), int(m.group(3))
|
| 176 |
+
for v in range(start, end + 1):
|
| 177 |
+
expanded.append(f"{prefix}:{v}")
|
| 178 |
+
else:
|
| 179 |
+
expanded.append(ref.strip())
|
| 180 |
+
return expanded
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def normalize_reference(ref: str) -> str:
|
| 184 |
+
return ref.strip().lower()
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def build_predictions(problem_id: str, quotes: list[dict]) -> list[dict]:
|
| 188 |
+
predictions = []
|
| 189 |
+
for quote in quotes:
|
| 190 |
+
for ref in quote.get("resolved_references", []):
|
| 191 |
+
predictions.append({
|
| 192 |
+
"problem_id": problem_id,
|
| 193 |
+
"reference": normalize_reference(ref),
|
| 194 |
+
"text": quote.get("text", ""),
|
| 195 |
+
})
|
| 196 |
+
return predictions
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def load_ground_truth(problem_id: str) -> dict[str, list[str]]:
|
| 200 |
+
solution = load_solution(problem_id)
|
| 201 |
+
refs: set[str] = set()
|
| 202 |
+
for item in solution:
|
| 203 |
+
for ref in item.get("resolved_references", []):
|
| 204 |
+
refs.add(normalize_reference(ref))
|
| 205 |
+
return {problem_id: sorted(refs)}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def score_predictions(
|
| 209 |
+
predictions: list[dict],
|
| 210 |
+
ground_truth_by_problem: dict[str, list[str]],
|
| 211 |
+
) -> dict:
|
| 212 |
+
pred_pairs: set[tuple[str, str]] = set()
|
| 213 |
+
for row in predictions:
|
| 214 |
+
pid = str(row.get("problem_id", "")).strip()
|
| 215 |
+
ref = normalize_reference(row.get("reference", ""))
|
| 216 |
+
if pid and ref:
|
| 217 |
+
pred_pairs.add((pid, ref))
|
| 218 |
+
|
| 219 |
+
true_pairs: set[tuple[str, str]] = set()
|
| 220 |
+
for problem_id, refs in ground_truth_by_problem.items():
|
| 221 |
+
for ref in refs:
|
| 222 |
+
true_pairs.add((problem_id, normalize_reference(ref)))
|
| 223 |
+
|
| 224 |
+
tp = len(pred_pairs & true_pairs)
|
| 225 |
+
fp = len(pred_pairs - true_pairs)
|
| 226 |
+
fn = len(true_pairs - pred_pairs)
|
| 227 |
+
|
| 228 |
+
precision = tp / (tp + fp) if (tp + fp) else 0.0
|
| 229 |
+
recall = tp / (tp + fn) if (tp + fn) else 0.0
|
| 230 |
+
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) else 0.0
|
| 231 |
+
|
| 232 |
+
return {
|
| 233 |
+
"true_positives": tp,
|
| 234 |
+
"false_positives": fp,
|
| 235 |
+
"false_negatives": fn,
|
| 236 |
+
"precision": precision,
|
| 237 |
+
"recall": recall,
|
| 238 |
+
"f1": f1,
|
| 239 |
+
"pred_pairs": pred_pairs,
|
| 240 |
+
"true_pairs": true_pairs,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def display_results(
|
| 245 |
+
problem_id: str,
|
| 246 |
+
quotes_with_spans: list[dict],
|
| 247 |
+
metrics: dict,
|
| 248 |
+
ground_truth: dict[str, list[str]],
|
| 249 |
+
) -> None:
|
| 250 |
+
console.print()
|
| 251 |
+
console.print(
|
| 252 |
+
Panel(
|
| 253 |
+
f"[bold]{TEAM_NAME}[/bold] | Problem: [cyan]{problem_id}[/cyan] | Model: [green]{MODEL}[/green]",
|
| 254 |
+
title="Ruse of Reuse — Scriptural Quote Detection",
|
| 255 |
+
border_style="blue",
|
| 256 |
+
)
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
qt = Table(title="Extracted Quotes", box=box.ROUNDED, show_lines=True)
|
| 260 |
+
qt.add_column("#", style="dim", width=3)
|
| 261 |
+
qt.add_column("Text", style="white", max_width=70)
|
| 262 |
+
qt.add_column("Type", style="magenta", width=8)
|
| 263 |
+
qt.add_column("References", style="cyan")
|
| 264 |
+
qt.add_column("Span", style="yellow")
|
| 265 |
+
|
| 266 |
+
type_colors = {"full": "green", "partial": "yellow", "paraphrase": "cyan", "allusion": "red"}
|
| 267 |
+
for i, q in enumerate(quotes_with_spans, 1):
|
| 268 |
+
span = (
|
| 269 |
+
f"{q['span_start']}–{q['span_end']}"
|
| 270 |
+
if q["span_start"] is not None
|
| 271 |
+
else "[red]NOT FOUND[/red]"
|
| 272 |
+
)
|
| 273 |
+
refs = ", ".join(q["resolved_references"])
|
| 274 |
+
t = q["text"]
|
| 275 |
+
display = (t[:67] + "...") if len(t) > 70 else t
|
| 276 |
+
qtype = q.get("quote_type", "allusion")
|
| 277 |
+
tc = type_colors.get(qtype, "white")
|
| 278 |
+
qt.add_row(str(i), display, f"[{tc}]{qtype}[/{tc}]", refs, span)
|
| 279 |
+
console.print(qt)
|
| 280 |
+
|
| 281 |
+
mt = Table(title="Evaluation Metrics", box=box.DOUBLE_EDGE)
|
| 282 |
+
mt.add_column("Metric", style="bold")
|
| 283 |
+
mt.add_column("Value", justify="right")
|
| 284 |
+
f1c = "green" if metrics["f1"] >= 0.7 else "yellow" if metrics["f1"] >= 0.4 else "red"
|
| 285 |
+
mt.add_row("True Positives", f"[green]{metrics['true_positives']}[/green]")
|
| 286 |
+
mt.add_row("False Positives", f"[red]{metrics['false_positives']}[/red]")
|
| 287 |
+
mt.add_row("False Negatives", f"[red]{metrics['false_negatives']}[/red]")
|
| 288 |
+
mt.add_row("Precision", f"{metrics['precision']:.4f}")
|
| 289 |
+
mt.add_row("Recall", f"{metrics['recall']:.4f}")
|
| 290 |
+
mt.add_row("F1 Score", f"[{f1c}]{metrics['f1']:.4f}[/{f1c}]")
|
| 291 |
+
console.print(mt)
|
| 292 |
+
|
| 293 |
+
pred_refs = {ref for _, ref in metrics["pred_pairs"]}
|
| 294 |
+
true_refs = {ref for _, ref in metrics["true_pairs"]}
|
| 295 |
+
|
| 296 |
+
ct = Table(title="Reference Comparison", box=box.ROUNDED, show_lines=True)
|
| 297 |
+
ct.add_column("Reference", style="white")
|
| 298 |
+
ct.add_column("Status", justify="center")
|
| 299 |
+
|
| 300 |
+
for ref in sorted(pred_refs | true_refs):
|
| 301 |
+
in_pred = ref in pred_refs
|
| 302 |
+
in_true = ref in true_refs
|
| 303 |
+
if in_pred and in_true:
|
| 304 |
+
status = "[green]TP (correct)[/green]"
|
| 305 |
+
elif in_pred:
|
| 306 |
+
status = "[red]FP (spurious)[/red]"
|
| 307 |
+
else:
|
| 308 |
+
status = "[yellow]FN (missed)[/yellow]"
|
| 309 |
+
ct.add_row(ref, status)
|
| 310 |
+
console.print(ct)
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
def process_single(problem_id: str, valid_book_codes: list[str], ref_mapping: dict[str, str]) -> dict:
|
| 314 |
+
text = load_problem(problem_id)
|
| 315 |
+
quotes = extract_quotes_with_gemini(text, valid_book_codes, ref_mapping)
|
| 316 |
+
quotes_with_spans = find_spans(text, quotes)
|
| 317 |
+
predictions = build_predictions(problem_id, quotes)
|
| 318 |
+
ground_truth = load_ground_truth(problem_id)
|
| 319 |
+
metrics = score_predictions(predictions, ground_truth)
|
| 320 |
+
|
| 321 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 322 |
+
serialisable_metrics = {
|
| 323 |
+
k: v for k, v in metrics.items() if k not in ("pred_pairs", "true_pairs")
|
| 324 |
+
}
|
| 325 |
+
output_payload = {
|
| 326 |
+
"problem_id": problem_id,
|
| 327 |
+
"team_name": TEAM_NAME,
|
| 328 |
+
"model": MODEL,
|
| 329 |
+
"quotes": [
|
| 330 |
+
{
|
| 331 |
+
"text": q["text"],
|
| 332 |
+
"span_start": q["span_start"],
|
| 333 |
+
"span_end": q["span_end"],
|
| 334 |
+
"resolved_references": q["resolved_references"],
|
| 335 |
+
"quote_type": q.get("quote_type", "allusion"),
|
| 336 |
+
}
|
| 337 |
+
for q in quotes_with_spans
|
| 338 |
+
],
|
| 339 |
+
"metrics": serialisable_metrics,
|
| 340 |
+
}
|
| 341 |
+
out_path = OUTPUT_DIR / f"{problem_id}.json"
|
| 342 |
+
out_path.write_text(json.dumps(output_payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 343 |
+
return {"problem_id": problem_id, "num_quotes": len(quotes), **serialisable_metrics}
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def all_problem_ids() -> list[str]:
|
| 347 |
+
return sorted(p.stem for p in PROBLEMS_DIR.glob("*.txt"))
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def main() -> None:
|
| 351 |
+
threads = 20
|
| 352 |
+
if len(sys.argv) > 1 and sys.argv[1] != "--all":
|
| 353 |
+
problem_ids = [sys.argv[1]]
|
| 354 |
+
else:
|
| 355 |
+
problem_ids = all_problem_ids()
|
| 356 |
+
|
| 357 |
+
console.print(
|
| 358 |
+
Panel(
|
| 359 |
+
f"[bold]{TEAM_NAME}[/bold] | Model: [green]{MODEL}[/green] | "
|
| 360 |
+
f"Problems: [cyan]{len(problem_ids)}[/cyan] | Threads: [cyan]{threads}[/cyan]",
|
| 361 |
+
title="Ruse of Reuse — Batch Extraction",
|
| 362 |
+
border_style="blue",
|
| 363 |
+
)
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
valid_book_codes = get_valid_book_codes()
|
| 367 |
+
ref_mapping = load_reference_mapping()
|
| 368 |
+
|
| 369 |
+
results: list[dict] = []
|
| 370 |
+
errors: list[tuple[str, str]] = []
|
| 371 |
+
t0 = time.time()
|
| 372 |
+
|
| 373 |
+
with Progress(
|
| 374 |
+
SpinnerColumn(),
|
| 375 |
+
TextColumn("[progress.description]{task.description}"),
|
| 376 |
+
BarColumn(),
|
| 377 |
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
| 378 |
+
TextColumn("{task.completed}/{task.total}"),
|
| 379 |
+
TimeElapsedColumn(),
|
| 380 |
+
console=console,
|
| 381 |
+
) as progress:
|
| 382 |
+
task = progress.add_task("Processing", total=len(problem_ids))
|
| 383 |
+
|
| 384 |
+
with ThreadPoolExecutor(max_workers=threads) as pool:
|
| 385 |
+
futures = {
|
| 386 |
+
pool.submit(process_single, pid, valid_book_codes, ref_mapping): pid
|
| 387 |
+
for pid in problem_ids
|
| 388 |
+
}
|
| 389 |
+
for future in as_completed(futures):
|
| 390 |
+
pid = futures[future]
|
| 391 |
+
try:
|
| 392 |
+
res = future.result()
|
| 393 |
+
results.append(res)
|
| 394 |
+
except Exception as exc:
|
| 395 |
+
errors.append((pid, str(exc)))
|
| 396 |
+
progress.update(task, advance=1, description=f"Done: {pid}")
|
| 397 |
+
|
| 398 |
+
elapsed = time.time() - t0
|
| 399 |
+
console.print(f"\n[bold green]Completed in {elapsed:.1f}s[/bold green]")
|
| 400 |
+
|
| 401 |
+
if errors:
|
| 402 |
+
et = Table(title="Errors", box=box.ROUNDED, style="red")
|
| 403 |
+
et.add_column("Problem")
|
| 404 |
+
et.add_column("Error")
|
| 405 |
+
for pid, err in errors:
|
| 406 |
+
et.add_row(pid, err[:120])
|
| 407 |
+
console.print(et)
|
| 408 |
+
|
| 409 |
+
results.sort(key=lambda r: r["problem_id"])
|
| 410 |
+
rt = Table(title="Results Summary", box=box.ROUNDED, show_lines=True)
|
| 411 |
+
rt.add_column("Problem", style="cyan")
|
| 412 |
+
rt.add_column("Quotes", justify="right")
|
| 413 |
+
rt.add_column("TP", justify="right", style="green")
|
| 414 |
+
rt.add_column("FP", justify="right", style="red")
|
| 415 |
+
rt.add_column("FN", justify="right", style="red")
|
| 416 |
+
rt.add_column("Prec", justify="right")
|
| 417 |
+
rt.add_column("Rec", justify="right")
|
| 418 |
+
rt.add_column("F1", justify="right")
|
| 419 |
+
for r in results:
|
| 420 |
+
f1v = r["f1"]
|
| 421 |
+
f1c = "green" if f1v >= 0.7 else "yellow" if f1v >= 0.4 else "red"
|
| 422 |
+
rt.add_row(
|
| 423 |
+
r["problem_id"], str(r["num_quotes"]),
|
| 424 |
+
str(r["true_positives"]), str(r["false_positives"]), str(r["false_negatives"]),
|
| 425 |
+
f"{r['precision']:.3f}", f"{r['recall']:.3f}", f"[{f1c}]{f1v:.3f}[/{f1c}]",
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
total_tp = sum(r["true_positives"] for r in results)
|
| 429 |
+
total_fp = sum(r["false_positives"] for r in results)
|
| 430 |
+
total_fn = sum(r["false_negatives"] for r in results)
|
| 431 |
+
total_p = total_tp / (total_tp + total_fp) if (total_tp + total_fp) else 0
|
| 432 |
+
total_r = total_tp / (total_tp + total_fn) if (total_tp + total_fn) else 0
|
| 433 |
+
total_f1 = 2 * total_p * total_r / (total_p + total_r) if (total_p + total_r) else 0
|
| 434 |
+
f1c = "green" if total_f1 >= 0.7 else "yellow" if total_f1 >= 0.4 else "red"
|
| 435 |
+
rt.add_row(
|
| 436 |
+
"[bold]TOTAL[/bold]", str(sum(r["num_quotes"] for r in results)),
|
| 437 |
+
f"[bold]{total_tp}[/bold]", f"[bold]{total_fp}[/bold]", f"[bold]{total_fn}[/bold]",
|
| 438 |
+
f"[bold]{total_p:.3f}[/bold]", f"[bold]{total_r:.3f}[/bold]",
|
| 439 |
+
f"[bold][{f1c}]{total_f1:.3f}[/{f1c}][/bold]",
|
| 440 |
+
)
|
| 441 |
+
console.print(rt)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
if __name__ == "__main__":
|
| 445 |
+
main()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68.2.0", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "scripture_detector"
|
| 7 |
+
version = "0.0.1"
|
| 8 |
+
description = "Scripture Detector: A tool for detecting scripture in medieval texts."
|
| 9 |
+
readme = {text = "", content-type = "text/plain"}
|
| 10 |
+
authors = [
|
| 11 |
+
{ name = "Martin Rocek", email = "silence.sys@gmail.com"},
|
| 12 |
+
{ name = "Gleb Schmidt", email = "gleb.schmidt@ru.nl" },
|
| 13 |
+
]
|
| 14 |
+
license = "MIT"
|
| 15 |
+
classifiers = [
|
| 16 |
+
"Programming Language :: Python",
|
| 17 |
+
"Programming Language :: Python :: 3",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
dependencies = [
|
| 21 |
+
"tomli; python_version < '3.11'",
|
| 22 |
+
"python-dotenv",
|
| 23 |
+
"tqdm",
|
| 24 |
+
"pandas",
|
| 25 |
+
"gdown",
|
| 26 |
+
"bs4",
|
| 27 |
+
"lxml",
|
| 28 |
+
"requests",
|
| 29 |
+
"openai",
|
| 30 |
+
"chromadb",
|
| 31 |
+
"sentence_transformers",
|
| 32 |
+
"google-genai>=1.66.0",
|
| 33 |
+
"rich>=14.3.3",
|
| 34 |
+
"flask>=3.1.3",
|
| 35 |
+
]
|
| 36 |
+
requires-python = ">=3.11.9"
|
| 37 |
+
|
| 38 |
+
[project.optional-dependencies]
|
| 39 |
+
dev = ["pytest"]
|
| 40 |
+
|
| 41 |
+
[project.urls]
|
| 42 |
+
Homepage = "https://github.com/glsch"
|
| 43 |
+
|
| 44 |
+
[tool.setuptools]
|
| 45 |
+
py-modules = ["app", "database", "main"]
|
| 46 |
+
packages = []
|
static/favicon.svg
ADDED
|
|
static/logo.svg
ADDED
|
|
static/screenshots/about.png
ADDED
|
Git LFS Details
|
static/screenshots/dashboard.png
ADDED
|
Git LFS Details
|
static/screenshots/settings.png
ADDED
|
Git LFS Details
|
static/screenshots/sources.png
ADDED
|
Git LFS Details
|
static/screenshots/viewer.png
ADDED
|
Git LFS Details
|
static/style.css
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
| 2 |
+
|
| 3 |
+
/*
|
| 4 |
+
* Yale University Color Palette
|
| 5 |
+
* Primary: Yale Blue #00356b
|
| 6 |
+
* Bright Blue: #286dc0
|
| 7 |
+
* Light Blue: #63aaff
|
| 8 |
+
* Warm Gray: #978d85
|
| 9 |
+
* Light Gray bg: #f9f9f9
|
| 10 |
+
* Accent Gold: #f9be00
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
:root {
|
| 14 |
+
--bg: #f4f4f2;
|
| 15 |
+
--bg-secondary: #ededeb;
|
| 16 |
+
--surface: #ffffff;
|
| 17 |
+
--text: #111827;
|
| 18 |
+
--text-secondary: #1f2937;
|
| 19 |
+
--muted: #6b7280;
|
| 20 |
+
--border: #ddd9d3;
|
| 21 |
+
--border-light: #ebe8e3;
|
| 22 |
+
|
| 23 |
+
/* Yale Primary Colors */
|
| 24 |
+
--primary: #00356b;
|
| 25 |
+
--primary-mid: #286dc0;
|
| 26 |
+
--primary-light: #63aaff;
|
| 27 |
+
--primary-pale: #e8f0fb;
|
| 28 |
+
--primary-subtle: rgba(0,53,107,.07);
|
| 29 |
+
|
| 30 |
+
/* Yale Accent */
|
| 31 |
+
--accent: #bd8b13; /* Yale gold – muted for readability */
|
| 32 |
+
--accent-bright: #f9be00; /* Yale gold – full saturation */
|
| 33 |
+
--accent-hover: #a37710;
|
| 34 |
+
|
| 35 |
+
/* Semantic colors (keeping semantic meaning, Yale-adjacent tones) */
|
| 36 |
+
--green: #2f7d32;
|
| 37 |
+
--red: #c62828;
|
| 38 |
+
--amber: #bd8b13;
|
| 39 |
+
--blue: #286dc0;
|
| 40 |
+
|
| 41 |
+
/* Quote type colors – adjusted for Yale palette harmony */
|
| 42 |
+
--full: #2f7d32;
|
| 43 |
+
--partial: #bd8b13;
|
| 44 |
+
--paraphrase: #0277bd;
|
| 45 |
+
--allusion: #6a1b9a;
|
| 46 |
+
--full-bg: rgba(47,125,50,.12);
|
| 47 |
+
--partial-bg: rgba(189,139,19,.12);
|
| 48 |
+
--paraphrase-bg: rgba(2,119,189,.12);
|
| 49 |
+
--allusion-bg: rgba(106,27,154,.12);
|
| 50 |
+
|
| 51 |
+
--radius: 10px;
|
| 52 |
+
--radius-lg: 14px;
|
| 53 |
+
--radius-sm: 7px;
|
| 54 |
+
|
| 55 |
+
--shadow-sm: 0 1px 2px rgba(0,0,0,.05), 0 1px 3px rgba(0,0,0,.06);
|
| 56 |
+
--shadow-md: 0 4px 6px rgba(0,0,0,.04), 0 2px 4px rgba(0,0,0,.06);
|
| 57 |
+
--shadow-lg: 0 10px 25px rgba(0,0,0,.07), 0 4px 10px rgba(0,0,0,.05);
|
| 58 |
+
--shadow-xl: 0 20px 40px rgba(0,0,0,.09), 0 8px 16px rgba(0,0,0,.05);
|
| 59 |
+
|
| 60 |
+
--transition: .2s cubic-bezier(.4,0,.2,1);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 64 |
+
|
| 65 |
+
body {
|
| 66 |
+
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
| 67 |
+
background: var(--bg);
|
| 68 |
+
color: var(--text);
|
| 69 |
+
-webkit-font-smoothing: antialiased;
|
| 70 |
+
-moz-osx-font-smoothing: grayscale;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/* ── Header ─────────────────────────────────────────────────────────── */
|
| 74 |
+
|
| 75 |
+
.header {
|
| 76 |
+
background: var(--primary);
|
| 77 |
+
color: #ffffff;
|
| 78 |
+
padding: 0 32px;
|
| 79 |
+
height: 68px;
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
gap: 16px;
|
| 83 |
+
box-shadow: 0 2px 12px rgba(0,53,107,.35);
|
| 84 |
+
position: relative;
|
| 85 |
+
z-index: 100;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Yale gold accent stripe at top */
|
| 89 |
+
.header::before {
|
| 90 |
+
content: '';
|
| 91 |
+
position: absolute;
|
| 92 |
+
top: 0;
|
| 93 |
+
left: 0;
|
| 94 |
+
right: 0;
|
| 95 |
+
height: 3px;
|
| 96 |
+
background: var(--accent-bright);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.header-brand {
|
| 100 |
+
display: flex;
|
| 101 |
+
align-items: center;
|
| 102 |
+
gap: 12px;
|
| 103 |
+
text-decoration: none;
|
| 104 |
+
color: inherit;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.header-logo {
|
| 108 |
+
width: 36px;
|
| 109 |
+
height: 36px;
|
| 110 |
+
flex-shrink: 0;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.header-title {
|
| 114 |
+
font-size: 1.1rem;
|
| 115 |
+
font-weight: 800;
|
| 116 |
+
letter-spacing: -.01em;
|
| 117 |
+
color: #ffffff;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.header-subtitle {
|
| 121 |
+
font-size: .72rem;
|
| 122 |
+
color: rgba(255,255,255,.65);
|
| 123 |
+
font-weight: 500;
|
| 124 |
+
letter-spacing: .02em;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.header nav {
|
| 128 |
+
margin-left: auto;
|
| 129 |
+
display: flex;
|
| 130 |
+
gap: 4px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.header nav a {
|
| 134 |
+
color: rgba(255,255,255,.75);
|
| 135 |
+
text-decoration: none;
|
| 136 |
+
font-size: .84rem;
|
| 137 |
+
font-weight: 500;
|
| 138 |
+
padding: 8px 16px;
|
| 139 |
+
border-radius: var(--radius-sm);
|
| 140 |
+
transition: var(--transition);
|
| 141 |
+
position: relative;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.header nav a:hover {
|
| 145 |
+
color: #fff;
|
| 146 |
+
background: rgba(255,255,255,.12);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.header nav a.active {
|
| 150 |
+
color: #fff;
|
| 151 |
+
background: rgba(255,255,255,.14);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.header nav a.active::after {
|
| 155 |
+
content: '';
|
| 156 |
+
position: absolute;
|
| 157 |
+
bottom: 0px;
|
| 158 |
+
left: 50%;
|
| 159 |
+
transform: translateX(-50%);
|
| 160 |
+
width: 20px;
|
| 161 |
+
height: 2px;
|
| 162 |
+
background: var(--accent-bright);
|
| 163 |
+
border-radius: 1px;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* ── Buttons ────────────────────────────────────────────────────────── */
|
| 167 |
+
|
| 168 |
+
.btn {
|
| 169 |
+
padding: 9px 20px;
|
| 170 |
+
border-radius: var(--radius-sm);
|
| 171 |
+
border: none;
|
| 172 |
+
font-size: .84rem;
|
| 173 |
+
font-weight: 600;
|
| 174 |
+
cursor: pointer;
|
| 175 |
+
transition: var(--transition);
|
| 176 |
+
display: inline-flex;
|
| 177 |
+
align-items: center;
|
| 178 |
+
gap: 6px;
|
| 179 |
+
font-family: inherit;
|
| 180 |
+
line-height: 1.4;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
| 184 |
+
|
| 185 |
+
.btn-primary {
|
| 186 |
+
background: var(--primary);
|
| 187 |
+
color: #fff;
|
| 188 |
+
box-shadow: 0 2px 8px rgba(0,53,107,.25);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.btn-primary:hover:not(:disabled) {
|
| 192 |
+
background: #004a96;
|
| 193 |
+
box-shadow: 0 4px 12px rgba(0,53,107,.35);
|
| 194 |
+
transform: translateY(-1px);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.btn-ghost {
|
| 198 |
+
background: transparent;
|
| 199 |
+
color: var(--muted);
|
| 200 |
+
border: 1.5px solid var(--border);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.btn-ghost:hover {
|
| 204 |
+
background: var(--bg);
|
| 205 |
+
color: var(--text);
|
| 206 |
+
border-color: #b8b3ac;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.btn-danger {
|
| 210 |
+
background: var(--red);
|
| 211 |
+
color: #fff;
|
| 212 |
+
box-shadow: 0 2px 8px rgba(198,40,40,.25);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.btn-danger:hover {
|
| 216 |
+
background: #b71c1c;
|
| 217 |
+
transform: translateY(-1px);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.btn-accent {
|
| 221 |
+
background: var(--primary);
|
| 222 |
+
color: #fff;
|
| 223 |
+
border-left: 3px solid var(--accent-bright);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.btn-sm { padding: 5px 12px; font-size: .78rem; }
|
| 227 |
+
.btn-lg { padding: 12px 28px; font-size: .95rem; border-radius: var(--radius); }
|
| 228 |
+
|
| 229 |
+
/* ── Container ──────────────────────────────────────────────────────── */
|
| 230 |
+
|
| 231 |
+
.container {
|
| 232 |
+
max-width: 1200px;
|
| 233 |
+
margin: 0 auto;
|
| 234 |
+
padding: 32px 24px;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* ── Cards ──────────────────────────────────────────────────────────── */
|
| 238 |
+
|
| 239 |
+
.card {
|
| 240 |
+
background: var(--surface);
|
| 241 |
+
border-radius: var(--radius-lg);
|
| 242 |
+
box-shadow: var(--shadow-sm);
|
| 243 |
+
border: 1px solid var(--border-light);
|
| 244 |
+
padding: 24px;
|
| 245 |
+
transition: var(--transition);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.card:hover { box-shadow: var(--shadow-md); }
|
| 249 |
+
|
| 250 |
+
.card h2 {
|
| 251 |
+
font-size: 1rem;
|
| 252 |
+
font-weight: 700;
|
| 253 |
+
margin-bottom: 20px;
|
| 254 |
+
padding-bottom: 14px;
|
| 255 |
+
border-bottom: 2px solid var(--primary-pale);
|
| 256 |
+
color: var(--primary);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.card h3 {
|
| 260 |
+
font-size: .8rem;
|
| 261 |
+
font-weight: 700;
|
| 262 |
+
margin-bottom: 14px;
|
| 263 |
+
text-transform: uppercase;
|
| 264 |
+
letter-spacing: .06em;
|
| 265 |
+
color: var(--muted);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/* ── Forms ──────────────────────────────────────────────────────────── */
|
| 269 |
+
|
| 270 |
+
.field { margin-bottom: 18px; }
|
| 271 |
+
|
| 272 |
+
.field label {
|
| 273 |
+
display: block;
|
| 274 |
+
font-size: .82rem;
|
| 275 |
+
font-weight: 600;
|
| 276 |
+
margin-bottom: 6px;
|
| 277 |
+
color: var(--text-secondary);
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.field input, .field select, .field textarea {
|
| 281 |
+
width: 100%;
|
| 282 |
+
padding: 10px 14px;
|
| 283 |
+
border: 1.5px solid var(--border);
|
| 284 |
+
border-radius: var(--radius-sm);
|
| 285 |
+
font-size: .9rem;
|
| 286 |
+
font-family: inherit;
|
| 287 |
+
color: var(--text);
|
| 288 |
+
background: var(--surface);
|
| 289 |
+
transition: var(--transition);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.field input:focus, .field select:focus, .field textarea:focus {
|
| 293 |
+
outline: none;
|
| 294 |
+
border-color: var(--primary-mid);
|
| 295 |
+
box-shadow: 0 0 0 3px var(--primary-subtle);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.field .hint {
|
| 299 |
+
font-size: .74rem;
|
| 300 |
+
color: var(--muted);
|
| 301 |
+
margin-top: 6px;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.field .hint a { color: var(--primary-mid); text-decoration: none; }
|
| 305 |
+
.field .hint a:hover { text-decoration: underline; }
|
| 306 |
+
|
| 307 |
+
/* ── Modal ──────────────────────────────────────────────────────────── */
|
| 308 |
+
|
| 309 |
+
.modal-overlay {
|
| 310 |
+
position: fixed;
|
| 311 |
+
inset: 0;
|
| 312 |
+
background: rgba(0,0,0,.5);
|
| 313 |
+
backdrop-filter: blur(4px);
|
| 314 |
+
-webkit-backdrop-filter: blur(4px);
|
| 315 |
+
z-index: 1000;
|
| 316 |
+
display: none;
|
| 317 |
+
align-items: center;
|
| 318 |
+
justify-content: center;
|
| 319 |
+
padding: 20px;
|
| 320 |
+
overflow-y: auto;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.modal-overlay.active { display: flex; }
|
| 324 |
+
|
| 325 |
+
.modal {
|
| 326 |
+
background: var(--surface);
|
| 327 |
+
border-radius: var(--radius-lg);
|
| 328 |
+
padding: 32px;
|
| 329 |
+
width: 90%;
|
| 330 |
+
max-width: 600px;
|
| 331 |
+
box-shadow: var(--shadow-xl);
|
| 332 |
+
max-height: 90vh;
|
| 333 |
+
overflow-y: auto;
|
| 334 |
+
animation: modalIn .25s ease-out;
|
| 335 |
+
border-top: 4px solid var(--primary);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
@keyframes modalIn {
|
| 339 |
+
from { opacity: 0; transform: scale(.96) translateY(8px); }
|
| 340 |
+
to { opacity: 1; transform: scale(1) translateY(0); }
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.modal h2 {
|
| 344 |
+
font-size: 1.15rem;
|
| 345 |
+
font-weight: 700;
|
| 346 |
+
margin-bottom: 20px;
|
| 347 |
+
color: var(--primary);
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.modal label {
|
| 351 |
+
display: block;
|
| 352 |
+
font-size: .82rem;
|
| 353 |
+
font-weight: 600;
|
| 354 |
+
margin-bottom: 6px;
|
| 355 |
+
margin-top: 18px;
|
| 356 |
+
color: var(--text-secondary);
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.modal label:first-of-type { margin-top: 0; }
|
| 360 |
+
|
| 361 |
+
.modal input, .modal textarea, .modal select {
|
| 362 |
+
width: 100%;
|
| 363 |
+
padding: 10px 14px;
|
| 364 |
+
border: 1.5px solid var(--border);
|
| 365 |
+
border-radius: var(--radius-sm);
|
| 366 |
+
font-size: .9rem;
|
| 367 |
+
font-family: inherit;
|
| 368 |
+
color: var(--text);
|
| 369 |
+
transition: var(--transition);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.modal input:focus, .modal textarea:focus, .modal select:focus {
|
| 373 |
+
outline: none;
|
| 374 |
+
border-color: var(--primary-mid);
|
| 375 |
+
box-shadow: 0 0 0 3px var(--primary-subtle);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.modal textarea { min-height: 200px; resize: vertical; }
|
| 379 |
+
.modal .actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
|
| 380 |
+
|
| 381 |
+
/* ── Toast ──────────────────────────────────────────────────────────── */
|
| 382 |
+
|
| 383 |
+
.toast {
|
| 384 |
+
position: fixed;
|
| 385 |
+
bottom: 24px;
|
| 386 |
+
right: 24px;
|
| 387 |
+
padding: 14px 24px;
|
| 388 |
+
border-radius: var(--radius);
|
| 389 |
+
color: #fff;
|
| 390 |
+
font-size: .88rem;
|
| 391 |
+
font-weight: 600;
|
| 392 |
+
box-shadow: var(--shadow-lg);
|
| 393 |
+
z-index: 1100;
|
| 394 |
+
transition: opacity .3s;
|
| 395 |
+
animation: toastIn .3s ease-out;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
@keyframes toastIn {
|
| 399 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 400 |
+
to { opacity: 1; transform: translateY(0); }
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.toast-ok { background: var(--green); }
|
| 404 |
+
.toast-err { background: var(--red); }
|
| 405 |
+
|
| 406 |
+
/* ── Empty State ────────────────────────────────────────────────────── */
|
| 407 |
+
|
| 408 |
+
.empty-state { text-align: center; padding: 80px 20px; }
|
| 409 |
+
.empty-state .empty-icon { width: 80px; height: 80px; margin: 0 auto 20px; opacity: .25; }
|
| 410 |
+
.empty-state h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 10px; color: var(--text); }
|
| 411 |
+
.empty-state p { color: var(--muted); margin-bottom: 24px; font-size: .95rem; max-width: 400px; margin-left: auto; margin-right: auto; }
|
| 412 |
+
|
| 413 |
+
/* ── Loading ────────────────────────────────────────────────────────── */
|
| 414 |
+
|
| 415 |
+
.loading {
|
| 416 |
+
display: flex;
|
| 417 |
+
align-items: center;
|
| 418 |
+
justify-content: center;
|
| 419 |
+
padding: 60px;
|
| 420 |
+
color: var(--muted);
|
| 421 |
+
font-size: .95rem;
|
| 422 |
+
gap: 12px;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 426 |
+
|
| 427 |
+
.spinner {
|
| 428 |
+
width: 22px;
|
| 429 |
+
height: 22px;
|
| 430 |
+
border: 2.5px solid var(--border);
|
| 431 |
+
border-top-color: var(--primary-mid);
|
| 432 |
+
border-radius: 50%;
|
| 433 |
+
animation: spin .7s linear infinite;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* ── Type badges ────────────────────────────────────────────────────── */
|
| 437 |
+
|
| 438 |
+
.type-badge {
|
| 439 |
+
display: inline-block;
|
| 440 |
+
padding: 2px 8px;
|
| 441 |
+
border-radius: 4px;
|
| 442 |
+
font-size: .68rem;
|
| 443 |
+
font-weight: 700;
|
| 444 |
+
text-transform: uppercase;
|
| 445 |
+
letter-spacing: .04em;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.type-full { background: rgba(47,125,50,.12); color: var(--full); }
|
| 449 |
+
.type-partial { background: rgba(189,139,19,.12); color: var(--partial); }
|
| 450 |
+
.type-paraphrase { background: rgba(2,119,189,.12); color: var(--paraphrase); }
|
| 451 |
+
.type-allusion { background: rgba(106,27,154,.12); color: var(--allusion); }
|
| 452 |
+
|
| 453 |
+
/* ── Type bar ───────────────────────────────────────────────────────── */
|
| 454 |
+
|
| 455 |
+
.type-bar {
|
| 456 |
+
display: flex;
|
| 457 |
+
height: 8px;
|
| 458 |
+
border-radius: 4px;
|
| 459 |
+
overflow: hidden;
|
| 460 |
+
background: var(--bg-secondary);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.type-bar div { min-width: 3px; transition: width .4s ease; }
|
| 464 |
+
|
| 465 |
+
.type-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; }
|
| 466 |
+
.type-legend-item { display: flex; align-items: center; gap: 5px; font-size: .74rem; color: var(--muted); }
|
| 467 |
+
.type-legend-swatch { width: 10px; height: 10px; border-radius: 3px; }
|
| 468 |
+
|
| 469 |
+
/* ── Scrollbar ──────────────────────────────────────────────────────── */
|
| 470 |
+
|
| 471 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 472 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 473 |
+
::-webkit-scrollbar-thumb { background: #c5bfb8; border-radius: 3px; }
|
| 474 |
+
::-webkit-scrollbar-thumb:hover { background: #9e9891; }
|
| 475 |
+
|
| 476 |
+
/* ── Selection ──────────────────────────────────────────────────────── */
|
| 477 |
+
|
| 478 |
+
::selection { background: rgba(40,109,192,.2); }
|
tei.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TEI-XML export and import for Scripture Detector.
|
| 2 |
+
|
| 3 |
+
Export schema
|
| 4 |
+
─────────────
|
| 5 |
+
TEI
|
| 6 |
+
├── teiHeader / fileDesc, encodingDesc
|
| 7 |
+
├── text / body / ab ← source text with inline <seg> for annotated spans
|
| 8 |
+
└── standOff / listAnnotation ← one <annotation> per quote
|
| 9 |
+
|
| 10 |
+
Import
|
| 11 |
+
──────
|
| 12 |
+
Reads a TEI file produced by this module and reconstructs source name,
|
| 13 |
+
full text, and annotations (with character-offset spans).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import re
|
| 19 |
+
from datetime import date
|
| 20 |
+
|
| 21 |
+
from lxml import etree
|
| 22 |
+
|
| 23 |
+
TEI_NS = "http://www.tei-c.org/ns/1.0"
|
| 24 |
+
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
| 25 |
+
|
| 26 |
+
_T = f"{{{TEI_NS}}}" # prefix shortcut
|
| 27 |
+
_X = f"{{{XML_NS}}}" # xml: namespace prefix
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
def _compute_segments(text: str, annotations: list[dict]) -> list[dict]:
|
| 33 |
+
"""Split *text* at annotation boundaries (same logic as app.compute_segments)."""
|
| 34 |
+
boundaries: set[int] = {0, len(text)}
|
| 35 |
+
for a in annotations:
|
| 36 |
+
if a.get("span_start") is not None and a.get("span_end") is not None:
|
| 37 |
+
boundaries.add(a["span_start"])
|
| 38 |
+
boundaries.add(a["span_end"])
|
| 39 |
+
ordered = sorted(boundaries)
|
| 40 |
+
segments = []
|
| 41 |
+
for i in range(len(ordered) - 1):
|
| 42 |
+
start, end = ordered[i], ordered[i + 1]
|
| 43 |
+
ann_ids = [
|
| 44 |
+
j for j, a in enumerate(annotations)
|
| 45 |
+
if a.get("span_start") is not None
|
| 46 |
+
and a["span_start"] <= start and end <= a["span_end"]
|
| 47 |
+
]
|
| 48 |
+
segments.append({"text": text[start:end], "start": start, "end": end,
|
| 49 |
+
"annotation_ids": ann_ids})
|
| 50 |
+
return segments
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _ref_label(ref: str, book_names: dict[str, str]) -> str:
|
| 54 |
+
"""'gen_1:5' → 'Genesis 1:5'"""
|
| 55 |
+
ref = ref.strip().lower()
|
| 56 |
+
m = re.match(r"^([a-z0-9]+)_(\d+):(\d+)$", ref)
|
| 57 |
+
if m:
|
| 58 |
+
book_code, ch, vs = m.groups()
|
| 59 |
+
book = book_names.get(book_code, book_code.capitalize())
|
| 60 |
+
return f"{book} {ch}:{vs}"
|
| 61 |
+
m2 = re.match(r"^([a-z0-9]+)_(\d+)$", ref)
|
| 62 |
+
if m2:
|
| 63 |
+
book_code, ch = m2.groups()
|
| 64 |
+
book = book_names.get(book_code, book_code.capitalize())
|
| 65 |
+
return f"{book} {ch}"
|
| 66 |
+
return ref
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ── export ────────────────────────────────────────────────────────────────────
|
| 70 |
+
|
| 71 |
+
def source_to_tei(
|
| 72 |
+
source: dict,
|
| 73 |
+
annotations: list[dict],
|
| 74 |
+
book_names: dict[str, str] | None = None,
|
| 75 |
+
) -> bytes:
|
| 76 |
+
"""
|
| 77 |
+
Serialise *source* + *annotations* as UTF-8 TEI XML bytes.
|
| 78 |
+
|
| 79 |
+
source: dict with keys id, name, text, created_at
|
| 80 |
+
annotations: list of dicts with keys id, span_start, span_end,
|
| 81 |
+
quote_text, quote_type, refs
|
| 82 |
+
book_names: {book_code: human_name} — used for human-readable <ref> labels
|
| 83 |
+
"""
|
| 84 |
+
book_names = book_names or {}
|
| 85 |
+
|
| 86 |
+
NSMAP = {None: TEI_NS}
|
| 87 |
+
root = etree.Element(f"{_T}TEI", nsmap=NSMAP)
|
| 88 |
+
|
| 89 |
+
# ── teiHeader ────────────────────────────────────────────────────────────
|
| 90 |
+
header = etree.SubElement(root, f"{_T}teiHeader")
|
| 91 |
+
fileDesc = etree.SubElement(header, f"{_T}fileDesc")
|
| 92 |
+
titleStmt = etree.SubElement(fileDesc, f"{_T}titleStmt")
|
| 93 |
+
title_el = etree.SubElement(titleStmt, f"{_T}title")
|
| 94 |
+
title_el.text = source["name"]
|
| 95 |
+
resp = etree.SubElement(titleStmt, f"{_T}respStmt")
|
| 96 |
+
resp_resp = etree.SubElement(resp, f"{_T}resp")
|
| 97 |
+
resp_resp.text = "Analyzed by"
|
| 98 |
+
resp_name = etree.SubElement(resp, f"{_T}name")
|
| 99 |
+
resp_name.text = "Scripture Detector (Dr. William J.B. Mattingly, Yale University)"
|
| 100 |
+
pubStmt = etree.SubElement(fileDesc, f"{_T}publicationStmt")
|
| 101 |
+
pub_p = etree.SubElement(pubStmt, f"{_T}p")
|
| 102 |
+
pub_p.text = (
|
| 103 |
+
f"Exported from Scripture Detector on {date.today().isoformat()}. "
|
| 104 |
+
"Scripture Detector is developed by Dr. William J.B. Mattingly, "
|
| 105 |
+
"Cultural Heritage Data Scientist, Yale University."
|
| 106 |
+
)
|
| 107 |
+
srcDesc = etree.SubElement(fileDesc, f"{_T}sourceDesc")
|
| 108 |
+
src_p = etree.SubElement(srcDesc, f"{_T}p")
|
| 109 |
+
src_p.text = "AI-assisted detection of biblical quotations, paraphrases, and allusions."
|
| 110 |
+
|
| 111 |
+
encDesc = etree.SubElement(header, f"{_T}encodingDesc")
|
| 112 |
+
projDesc = etree.SubElement(encDesc, f"{_T}projectDesc")
|
| 113 |
+
proj_p = etree.SubElement(projDesc, f"{_T}p")
|
| 114 |
+
proj_p.text = (
|
| 115 |
+
"Scripture Detector uses Google Gemini to identify and classify biblical "
|
| 116 |
+
"references in historical texts. Reference types follow a four-level taxonomy."
|
| 117 |
+
)
|
| 118 |
+
clasDecl = etree.SubElement(encDesc, f"{_T}classDecl")
|
| 119 |
+
taxonomy = etree.SubElement(clasDecl, f"{_T}taxonomy")
|
| 120 |
+
taxonomy.set(f"{_X}id", "sd-types")
|
| 121 |
+
for cat_id, desc in [
|
| 122 |
+
("sd-full", "Full quotation: verbatim or near-verbatim citation of a biblical verse"),
|
| 123 |
+
("sd-partial", "Partial quotation: a recognisable portion of a verse"),
|
| 124 |
+
("sd-paraphrase", "Paraphrase: biblical content restated in different words"),
|
| 125 |
+
("sd-allusion", "Allusion: brief thematic or verbal echo of a scriptural passage"),
|
| 126 |
+
]:
|
| 127 |
+
cat = etree.SubElement(taxonomy, f"{_T}category")
|
| 128 |
+
cat.set(f"{_X}id", cat_id)
|
| 129 |
+
catDesc = etree.SubElement(cat, f"{_T}catDesc")
|
| 130 |
+
catDesc.text = desc
|
| 131 |
+
|
| 132 |
+
# ── text / body / ab ─────────────────────────────────────────────────────
|
| 133 |
+
text_el = etree.SubElement(root, f"{_T}text")
|
| 134 |
+
body = etree.SubElement(text_el, f"{_T}body")
|
| 135 |
+
ab = etree.SubElement(body, f"{_T}ab")
|
| 136 |
+
ab.set(f"{_X}id", "source-text")
|
| 137 |
+
|
| 138 |
+
segments = _compute_segments(source["text"], annotations)
|
| 139 |
+
last_el = None # most-recently appended child element
|
| 140 |
+
|
| 141 |
+
for seg in segments:
|
| 142 |
+
raw = seg["text"]
|
| 143 |
+
if not seg["annotation_ids"]:
|
| 144 |
+
# plain text: append to .text of <ab> or .tail of last element
|
| 145 |
+
if last_el is None:
|
| 146 |
+
ab.text = (ab.text or "") + raw
|
| 147 |
+
else:
|
| 148 |
+
last_el.tail = (last_el.tail or "") + raw
|
| 149 |
+
else:
|
| 150 |
+
ann_refs = " ".join(
|
| 151 |
+
f"#ann{annotations[i]['id']}" for i in seg["annotation_ids"]
|
| 152 |
+
)
|
| 153 |
+
subtypes = {annotations[i]["quote_type"] for i in seg["annotation_ids"]}
|
| 154 |
+
subtype = next(iter(subtypes)) if len(subtypes) == 1 else "mixed"
|
| 155 |
+
|
| 156 |
+
seg_el = etree.SubElement(ab, f"{_T}seg")
|
| 157 |
+
seg_el.set(f"{_X}id", f"seg{seg['start']}x{seg['end']}")
|
| 158 |
+
seg_el.set("ana", ann_refs)
|
| 159 |
+
seg_el.set("type", "biblical-reference")
|
| 160 |
+
seg_el.set("subtype", subtype)
|
| 161 |
+
seg_el.text = raw
|
| 162 |
+
last_el = seg_el
|
| 163 |
+
|
| 164 |
+
# ── standOff ─────────────────────────────────────────────────────────────
|
| 165 |
+
stand_off = etree.SubElement(root, f"{_T}standOff")
|
| 166 |
+
list_ann = etree.SubElement(stand_off, f"{_T}listAnnotation")
|
| 167 |
+
|
| 168 |
+
for a in annotations:
|
| 169 |
+
ann_el = etree.SubElement(list_ann, f"{_T}annotation")
|
| 170 |
+
ann_el.set(f"{_X}id", f"ann{a['id']}")
|
| 171 |
+
ann_el.set("type", "biblical-reference")
|
| 172 |
+
ann_el.set("subtype", a.get("quote_type", "allusion"))
|
| 173 |
+
ann_el.set("ana", f"#sd-{a.get('quote_type','allusion')}")
|
| 174 |
+
|
| 175 |
+
note_el = etree.SubElement(ann_el, f"{_T}note")
|
| 176 |
+
note_el.set("type", "quotedText")
|
| 177 |
+
note_el.text = a.get("quote_text", "")
|
| 178 |
+
|
| 179 |
+
refs_el = etree.SubElement(ann_el, f"{_T}listRef")
|
| 180 |
+
for ref in (a.get("refs") or []):
|
| 181 |
+
ref_clean = ref.strip().lower()
|
| 182 |
+
ref_el = etree.SubElement(refs_el, f"{_T}ref")
|
| 183 |
+
ref_el.set("target", f"bible:{ref_clean}")
|
| 184 |
+
ref_el.text = _ref_label(ref_clean, book_names)
|
| 185 |
+
|
| 186 |
+
return etree.tostring(
|
| 187 |
+
root,
|
| 188 |
+
pretty_print=True,
|
| 189 |
+
xml_declaration=True,
|
| 190 |
+
encoding="UTF-8",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ── import ────────────────────────────────────────────────────────────────────
|
| 195 |
+
|
| 196 |
+
def tei_to_source_data(xml_bytes: bytes) -> dict:
|
| 197 |
+
"""
|
| 198 |
+
Parse a TEI file produced by :func:`source_to_tei`.
|
| 199 |
+
|
| 200 |
+
Returns a dict::
|
| 201 |
+
|
| 202 |
+
{
|
| 203 |
+
"name": str,
|
| 204 |
+
"text": str,
|
| 205 |
+
"annotations": [
|
| 206 |
+
{
|
| 207 |
+
"quote_text": str,
|
| 208 |
+
"quote_type": str,
|
| 209 |
+
"refs": [str, ...],
|
| 210 |
+
"span_start": int | None,
|
| 211 |
+
"span_end": int | None,
|
| 212 |
+
},
|
| 213 |
+
...
|
| 214 |
+
]
|
| 215 |
+
}
|
| 216 |
+
"""
|
| 217 |
+
root = etree.fromstring(xml_bytes)
|
| 218 |
+
|
| 219 |
+
# ── source name ──────────────────────────────────────────────────────────
|
| 220 |
+
title_el = root.find(f".//{_T}teiHeader//{_T}titleStmt/{_T}title")
|
| 221 |
+
name = (title_el.text or "Untitled").strip() if title_el is not None else "Untitled"
|
| 222 |
+
|
| 223 |
+
# ── reconstruct plain text + offset map for <seg> ids ────────────────────
|
| 224 |
+
ab = root.find(f".//{_T}body//{_T}ab")
|
| 225 |
+
if ab is None:
|
| 226 |
+
ab = root.find(f".//{_T}body")
|
| 227 |
+
|
| 228 |
+
text_parts: list[str] = []
|
| 229 |
+
# Maps xml:id → (start_char, end_char) offsets within the joined text
|
| 230 |
+
offset_map: dict[str, tuple[int, int]] = {}
|
| 231 |
+
|
| 232 |
+
def _walk(el: etree._Element) -> None:
|
| 233 |
+
if el.text:
|
| 234 |
+
text_parts.append(el.text)
|
| 235 |
+
for child in el:
|
| 236 |
+
child_start = sum(len(p) for p in text_parts)
|
| 237 |
+
_walk(child)
|
| 238 |
+
child_end = sum(len(p) for p in text_parts)
|
| 239 |
+
xml_id = child.get(f"{_X}id")
|
| 240 |
+
if xml_id:
|
| 241 |
+
offset_map[xml_id] = (child_start, child_end)
|
| 242 |
+
if child.tail:
|
| 243 |
+
text_parts.append(child.tail)
|
| 244 |
+
|
| 245 |
+
if ab is not None:
|
| 246 |
+
_walk(ab)
|
| 247 |
+
|
| 248 |
+
full_text = "".join(text_parts)
|
| 249 |
+
|
| 250 |
+
# ── parse standOff annotations ────────────────────────────────────────────
|
| 251 |
+
annotations: list[dict] = []
|
| 252 |
+
|
| 253 |
+
for ann_el in root.findall(f".//{_T}standOff//{_T}annotation"):
|
| 254 |
+
ann_xml_id = ann_el.get(f"{_X}id", "")
|
| 255 |
+
subtype = ann_el.get("subtype", "allusion")
|
| 256 |
+
|
| 257 |
+
note_el = ann_el.find(f"{_T}note[@type='quotedText']")
|
| 258 |
+
quote_text = (note_el.text or "").strip() if note_el is not None else ""
|
| 259 |
+
|
| 260 |
+
refs: list[str] = []
|
| 261 |
+
for ref_el in ann_el.findall(f".//{_T}ref"):
|
| 262 |
+
target = ref_el.get("target", "")
|
| 263 |
+
if target.startswith("bible:"):
|
| 264 |
+
refs.append(target[6:])
|
| 265 |
+
|
| 266 |
+
# Determine character span from the seg elements referencing this annotation
|
| 267 |
+
span_start = span_end = None
|
| 268 |
+
if ab is not None and ann_xml_id:
|
| 269 |
+
ref_key = f"#{ann_xml_id}"
|
| 270 |
+
seg_offsets = []
|
| 271 |
+
for seg_el in ab.iter(f"{_T}seg"):
|
| 272 |
+
ana_val = seg_el.get("ana", "")
|
| 273 |
+
if ref_key in ana_val.split():
|
| 274 |
+
seg_id = seg_el.get(f"{_X}id")
|
| 275 |
+
if seg_id and seg_id in offset_map:
|
| 276 |
+
seg_offsets.append(offset_map[seg_id])
|
| 277 |
+
if seg_offsets:
|
| 278 |
+
span_start = min(s for s, _ in seg_offsets)
|
| 279 |
+
span_end = max(e for _, e in seg_offsets)
|
| 280 |
+
|
| 281 |
+
annotations.append({
|
| 282 |
+
"quote_text": quote_text,
|
| 283 |
+
"quote_type": subtype,
|
| 284 |
+
"refs": refs,
|
| 285 |
+
"span_start": span_start,
|
| 286 |
+
"span_end": span_end,
|
| 287 |
+
})
|
| 288 |
+
|
| 289 |
+
return {"name": name, "text": full_text, "annotations": annotations}
|
templates/about.html
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Scripture Detector — About</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
<style>
|
| 10 |
+
.container { max-width: 860px; }
|
| 11 |
+
|
| 12 |
+
/* Hero */
|
| 13 |
+
.hero {
|
| 14 |
+
background: var(--primary);
|
| 15 |
+
color: #fff;
|
| 16 |
+
padding: 56px 32px;
|
| 17 |
+
text-align: center;
|
| 18 |
+
position: relative;
|
| 19 |
+
overflow: hidden;
|
| 20 |
+
}
|
| 21 |
+
.hero::before {
|
| 22 |
+
content: '';
|
| 23 |
+
position: absolute;
|
| 24 |
+
top: 0; left: 0; right: 0;
|
| 25 |
+
height: 3px;
|
| 26 |
+
background: var(--accent-bright);
|
| 27 |
+
}
|
| 28 |
+
.hero-logo {
|
| 29 |
+
width: 72px;
|
| 30 |
+
height: 72px;
|
| 31 |
+
margin: 0 auto 20px;
|
| 32 |
+
filter: brightness(10);
|
| 33 |
+
opacity: .9;
|
| 34 |
+
}
|
| 35 |
+
.hero h1 {
|
| 36 |
+
font-size: 2rem;
|
| 37 |
+
font-weight: 900;
|
| 38 |
+
letter-spacing: -.03em;
|
| 39 |
+
margin-bottom: 12px;
|
| 40 |
+
color: #fff;
|
| 41 |
+
}
|
| 42 |
+
.hero p {
|
| 43 |
+
font-size: 1.08rem;
|
| 44 |
+
color: rgba(255,255,255,.8);
|
| 45 |
+
max-width: 560px;
|
| 46 |
+
margin: 0 auto;
|
| 47 |
+
line-height: 1.65;
|
| 48 |
+
}
|
| 49 |
+
.hero-badges {
|
| 50 |
+
display: flex;
|
| 51 |
+
gap: 10px;
|
| 52 |
+
justify-content: center;
|
| 53 |
+
margin-top: 22px;
|
| 54 |
+
flex-wrap: wrap;
|
| 55 |
+
}
|
| 56 |
+
.hero-badge {
|
| 57 |
+
display: inline-flex;
|
| 58 |
+
align-items: center;
|
| 59 |
+
gap: 6px;
|
| 60 |
+
background: rgba(255,255,255,.12);
|
| 61 |
+
border: 1px solid rgba(255,255,255,.2);
|
| 62 |
+
color: #fff;
|
| 63 |
+
font-size: .8rem;
|
| 64 |
+
font-weight: 600;
|
| 65 |
+
padding: 6px 14px;
|
| 66 |
+
border-radius: 20px;
|
| 67 |
+
}
|
| 68 |
+
.hero-badge .dot {
|
| 69 |
+
width: 7px; height: 7px;
|
| 70 |
+
border-radius: 50%;
|
| 71 |
+
background: var(--accent-bright);
|
| 72 |
+
flex-shrink: 0;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* Cards */
|
| 76 |
+
.about-grid { display: grid; gap: 24px; margin-top: 32px; }
|
| 77 |
+
|
| 78 |
+
.about-card {
|
| 79 |
+
background: var(--surface);
|
| 80 |
+
border-radius: var(--radius-lg);
|
| 81 |
+
box-shadow: var(--shadow-sm);
|
| 82 |
+
border: 1px solid var(--border-light);
|
| 83 |
+
padding: 28px 32px;
|
| 84 |
+
border-top: 3px solid var(--primary);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.about-card h2 {
|
| 88 |
+
font-size: 1.05rem;
|
| 89 |
+
font-weight: 800;
|
| 90 |
+
color: var(--primary);
|
| 91 |
+
margin-bottom: 16px;
|
| 92 |
+
display: flex;
|
| 93 |
+
align-items: center;
|
| 94 |
+
gap: 10px;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.about-card h2 .icon {
|
| 98 |
+
width: 32px;
|
| 99 |
+
height: 32px;
|
| 100 |
+
background: var(--primary-pale);
|
| 101 |
+
border-radius: 8px;
|
| 102 |
+
display: flex;
|
| 103 |
+
align-items: center;
|
| 104 |
+
justify-content: center;
|
| 105 |
+
color: var(--primary);
|
| 106 |
+
flex-shrink: 0;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.about-card p {
|
| 110 |
+
color: var(--text-secondary);
|
| 111 |
+
line-height: 1.75;
|
| 112 |
+
font-size: .92rem;
|
| 113 |
+
margin-bottom: 14px;
|
| 114 |
+
}
|
| 115 |
+
.about-card p:last-child { margin-bottom: 0; }
|
| 116 |
+
|
| 117 |
+
.about-card ul {
|
| 118 |
+
list-style: none;
|
| 119 |
+
padding: 0;
|
| 120 |
+
margin: 0;
|
| 121 |
+
display: flex;
|
| 122 |
+
flex-direction: column;
|
| 123 |
+
gap: 10px;
|
| 124 |
+
}
|
| 125 |
+
.about-card ul li {
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: flex-start;
|
| 128 |
+
gap: 10px;
|
| 129 |
+
font-size: .9rem;
|
| 130 |
+
color: var(--text-secondary);
|
| 131 |
+
line-height: 1.55;
|
| 132 |
+
}
|
| 133 |
+
.about-card ul li::before {
|
| 134 |
+
content: '';
|
| 135 |
+
width: 6px;
|
| 136 |
+
height: 6px;
|
| 137 |
+
border-radius: 50%;
|
| 138 |
+
background: var(--accent-bright);
|
| 139 |
+
margin-top: 7px;
|
| 140 |
+
flex-shrink: 0;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/* Steps */
|
| 144 |
+
.steps { display: flex; flex-direction: column; gap: 0; }
|
| 145 |
+
.step {
|
| 146 |
+
display: grid;
|
| 147 |
+
grid-template-columns: 40px 1fr;
|
| 148 |
+
gap: 16px;
|
| 149 |
+
position: relative;
|
| 150 |
+
}
|
| 151 |
+
.step:not(:last-child)::after {
|
| 152 |
+
content: '';
|
| 153 |
+
position: absolute;
|
| 154 |
+
left: 19px;
|
| 155 |
+
top: 44px;
|
| 156 |
+
bottom: -14px;
|
| 157 |
+
width: 2px;
|
| 158 |
+
background: var(--primary-pale);
|
| 159 |
+
}
|
| 160 |
+
.step-num {
|
| 161 |
+
width: 40px;
|
| 162 |
+
height: 40px;
|
| 163 |
+
border-radius: 50%;
|
| 164 |
+
background: var(--primary);
|
| 165 |
+
color: #fff;
|
| 166 |
+
font-weight: 800;
|
| 167 |
+
font-size: .9rem;
|
| 168 |
+
display: flex;
|
| 169 |
+
align-items: center;
|
| 170 |
+
justify-content: center;
|
| 171 |
+
flex-shrink: 0;
|
| 172 |
+
position: relative;
|
| 173 |
+
z-index: 1;
|
| 174 |
+
}
|
| 175 |
+
.step-body { padding-bottom: 24px; }
|
| 176 |
+
.step-body h3 { font-size: .95rem; font-weight: 700; color: var(--text); margin-bottom: 4px; }
|
| 177 |
+
.step-body p { font-size: .86rem; color: var(--muted); line-height: 1.6; }
|
| 178 |
+
|
| 179 |
+
/* Quote types */
|
| 180 |
+
.type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
| 181 |
+
@media (max-width: 600px) { .type-grid { grid-template-columns: 1fr; } }
|
| 182 |
+
.type-item {
|
| 183 |
+
padding: 14px 16px;
|
| 184 |
+
border-radius: var(--radius);
|
| 185 |
+
border-left: 4px solid;
|
| 186 |
+
}
|
| 187 |
+
.type-item.full { background: var(--full-bg); border-color: var(--full); }
|
| 188 |
+
.type-item.partial { background: var(--partial-bg); border-color: var(--partial); }
|
| 189 |
+
.type-item.paraphrase { background: var(--paraphrase-bg); border-color: var(--paraphrase); }
|
| 190 |
+
.type-item.allusion { background: var(--allusion-bg); border-color: var(--allusion); }
|
| 191 |
+
.type-item strong { font-size: .84rem; font-weight: 700; display: block; margin-bottom: 3px; }
|
| 192 |
+
.type-item p { font-size: .8rem; color: var(--muted); line-height: 1.5; margin: 0; }
|
| 193 |
+
.type-item.full strong { color: var(--full); }
|
| 194 |
+
.type-item.partial strong { color: var(--partial); }
|
| 195 |
+
.type-item.paraphrase strong { color: var(--paraphrase); }
|
| 196 |
+
.type-item.allusion strong { color: var(--allusion); }
|
| 197 |
+
|
| 198 |
+
/* Screenshot gallery */
|
| 199 |
+
.screenshot-grid { display: grid; gap: 20px; }
|
| 200 |
+
.screenshot-item { border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); box-shadow: var(--shadow-md); }
|
| 201 |
+
.screenshot-item img { width: 100%; display: block; }
|
| 202 |
+
.screenshot-caption { padding: 10px 14px; background: var(--bg-secondary); font-size: .8rem; color: var(--muted); font-weight: 500; }
|
| 203 |
+
|
| 204 |
+
/* Author card */
|
| 205 |
+
.author-card {
|
| 206 |
+
background: var(--primary);
|
| 207 |
+
color: #fff;
|
| 208 |
+
border-radius: var(--radius-lg);
|
| 209 |
+
padding: 28px 32px;
|
| 210 |
+
display: flex;
|
| 211 |
+
gap: 24px;
|
| 212 |
+
align-items: flex-start;
|
| 213 |
+
flex-wrap: wrap;
|
| 214 |
+
margin-top: 24px;
|
| 215 |
+
position: relative;
|
| 216 |
+
overflow: hidden;
|
| 217 |
+
}
|
| 218 |
+
.author-card::after {
|
| 219 |
+
content: '';
|
| 220 |
+
position: absolute;
|
| 221 |
+
bottom: 0; right: 0;
|
| 222 |
+
width: 160px; height: 160px;
|
| 223 |
+
background: rgba(255,255,255,.04);
|
| 224 |
+
border-radius: 50%;
|
| 225 |
+
transform: translate(40%, 40%);
|
| 226 |
+
}
|
| 227 |
+
.author-icon {
|
| 228 |
+
width: 60px; height: 60px;
|
| 229 |
+
border-radius: 50%;
|
| 230 |
+
background: rgba(255,255,255,.15);
|
| 231 |
+
display: flex; align-items: center; justify-content: center;
|
| 232 |
+
font-size: 1.8rem;
|
| 233 |
+
flex-shrink: 0;
|
| 234 |
+
}
|
| 235 |
+
.author-info h3 { font-size: 1.1rem; font-weight: 800; margin-bottom: 3px; color: #fff; }
|
| 236 |
+
.author-info .role { font-size: .84rem; color: rgba(255,255,255,.7); margin-bottom: 10px; }
|
| 237 |
+
.author-info p { font-size: .88rem; color: rgba(255,255,255,.8); line-height: 1.65; max-width: 540px; }
|
| 238 |
+
.author-info a { color: var(--accent-bright); text-decoration: none; }
|
| 239 |
+
.author-info a:hover { text-decoration: underline; }
|
| 240 |
+
|
| 241 |
+
/* Workshop banner */
|
| 242 |
+
.workshop-banner {
|
| 243 |
+
background: var(--primary-pale);
|
| 244 |
+
border: 1.5px solid #c9dcf5;
|
| 245 |
+
border-radius: var(--radius);
|
| 246 |
+
padding: 18px 22px;
|
| 247 |
+
display: flex;
|
| 248 |
+
gap: 16px;
|
| 249 |
+
align-items: flex-start;
|
| 250 |
+
margin-top: 12px;
|
| 251 |
+
}
|
| 252 |
+
.workshop-banner .ws-icon {
|
| 253 |
+
width: 40px; height: 40px;
|
| 254 |
+
background: var(--primary);
|
| 255 |
+
border-radius: 8px;
|
| 256 |
+
display: flex; align-items: center; justify-content: center;
|
| 257 |
+
color: #fff;
|
| 258 |
+
flex-shrink: 0;
|
| 259 |
+
}
|
| 260 |
+
.workshop-banner .ws-info h4 { font-size: .9rem; font-weight: 700; color: var(--primary); margin-bottom: 3px; }
|
| 261 |
+
.workshop-banner .ws-info p { font-size: .82rem; color: var(--muted); line-height: 1.5; margin: 0; }
|
| 262 |
+
.workshop-banner .ws-info a { color: var(--primary-mid); }
|
| 263 |
+
|
| 264 |
+
/* API key note */
|
| 265 |
+
.api-note {
|
| 266 |
+
display: flex;
|
| 267 |
+
align-items: flex-start;
|
| 268 |
+
gap: 14px;
|
| 269 |
+
background: rgba(249,190,0,.1);
|
| 270 |
+
border: 1.5px solid rgba(189,139,19,.3);
|
| 271 |
+
border-radius: var(--radius);
|
| 272 |
+
padding: 16px 20px;
|
| 273 |
+
margin-top: 14px;
|
| 274 |
+
}
|
| 275 |
+
.api-note-icon {
|
| 276 |
+
font-size: 1.4rem;
|
| 277 |
+
flex-shrink: 0;
|
| 278 |
+
line-height: 1;
|
| 279 |
+
}
|
| 280 |
+
.api-note p { font-size: .86rem; color: var(--text-secondary); line-height: 1.6; margin: 0; }
|
| 281 |
+
.api-note a { color: var(--primary-mid); font-weight: 600; text-decoration: none; }
|
| 282 |
+
.api-note a:hover { text-decoration: underline; }
|
| 283 |
+
|
| 284 |
+
/* Screenshots section */
|
| 285 |
+
.screenshots-container { margin-top: 12px; }
|
| 286 |
+
.screenshots-container .screenshot-item img { border-radius: 0; }
|
| 287 |
+
</style>
|
| 288 |
+
</head>
|
| 289 |
+
<body>
|
| 290 |
+
|
| 291 |
+
<div class="header">
|
| 292 |
+
<a href="/" class="header-brand">
|
| 293 |
+
<img src="/static/logo.svg" alt="Logo" class="header-logo">
|
| 294 |
+
<div>
|
| 295 |
+
<div class="header-title">Scripture Detector</div>
|
| 296 |
+
<div class="header-subtitle">About</div>
|
| 297 |
+
</div>
|
| 298 |
+
</a>
|
| 299 |
+
<nav>
|
| 300 |
+
<a href="/">Sources</a>
|
| 301 |
+
<a href="/dashboard">Dashboard</a>
|
| 302 |
+
<a href="/about" class="active">About</a>
|
| 303 |
+
<a href="/settings">Settings</a>
|
| 304 |
+
</nav>
|
| 305 |
+
</div>
|
| 306 |
+
|
| 307 |
+
<!-- Hero -->
|
| 308 |
+
<div class="hero">
|
| 309 |
+
<img src="/static/logo.svg" alt="Scripture Detector Logo" class="hero-logo">
|
| 310 |
+
<h1>Scripture Detector</h1>
|
| 311 |
+
<p>An AI-powered tool for detecting, classifying, and analyzing biblical quotations, paraphrases, and allusions in historical texts — powered by Google Gemini.</p>
|
| 312 |
+
<div class="hero-badges">
|
| 313 |
+
<span class="hero-badge"><span class="dot"></span>Powered by Google Gemini</span>
|
| 314 |
+
<span class="hero-badge"><span class="dot"></span>Yale University</span>
|
| 315 |
+
<span class="hero-badge"><span class="dot"></span>Open Source</span>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div class="container">
|
| 320 |
+
<div class="about-grid">
|
| 321 |
+
|
| 322 |
+
<!-- Author -->
|
| 323 |
+
<div class="author-card">
|
| 324 |
+
<div class="author-icon">👤</div>
|
| 325 |
+
<div class="author-info">
|
| 326 |
+
<h3>Dr. William J.B. Mattingly</h3>
|
| 327 |
+
<div class="role">Cultural Heritage Data Scientist · Yale University</div>
|
| 328 |
+
<p>
|
| 329 |
+
Scripture Detector was developed by Dr. William J.B. Mattingly, Cultural Heritage Data Scientist at Yale University. The application was built for the international workshop
|
| 330 |
+
<strong><a href="https://www.oeaw.ac.at/en/imafo/events/event-details/ruse-of-reuse" target="_blank">Ruse of Reuse: Detecting Text-similarity with AI in Historical Sources</a></strong>
|
| 331 |
+
(March 5–6, 2026), hosted by the Austrian Academy of Sciences in Vienna.
|
| 332 |
+
</p>
|
| 333 |
+
<div class="workshop-banner" style="background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.15);margin-top:14px">
|
| 334 |
+
<div class="ws-icon">
|
| 335 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
| 336 |
+
</div>
|
| 337 |
+
<div class="ws-info" style="color:rgba(255,255,255,.85)">
|
| 338 |
+
<h4 style="color:#f9be00">Ruse of Reuse Workshop · March 5–6, 2026</h4>
|
| 339 |
+
<p style="color:rgba(255,255,255,.7)">Austrian Academy of Sciences · PSK, Georg-Coch-Platz 2, 1010 Vienna<br>
|
| 340 |
+
Organised by the Digital Lab, Institute for Medieval Research, Austrian Academy of Sciences & SOLEMNE, Radboud University</p>
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<!-- What it does -->
|
| 347 |
+
<div class="about-card">
|
| 348 |
+
<h2>
|
| 349 |
+
<span class="icon">
|
| 350 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 351 |
+
</span>
|
| 352 |
+
What is Scripture Detector?
|
| 353 |
+
</h2>
|
| 354 |
+
<p>
|
| 355 |
+
Scripture Detector is an AI-powered web application that analyzes historical and literary texts to find and classify all the ways biblical scripture appears within them. Given any piece of text, the application uses large language models to locate exact quotations, partial quotations, paraphrases, and allusions to specific Bible verses.
|
| 356 |
+
</p>
|
| 357 |
+
<p>
|
| 358 |
+
Each detected passage is linked to the exact verse(s) it references, displayed in context within the original document, and explored through interactive visualizations. The application is designed for scholars, students, and researchers working with texts that draw on the biblical tradition.
|
| 359 |
+
</p>
|
| 360 |
+
</div>
|
| 361 |
+
|
| 362 |
+
<!-- How it works -->
|
| 363 |
+
<div class="about-card">
|
| 364 |
+
<h2>
|
| 365 |
+
<span class="icon">
|
| 366 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
| 367 |
+
</span>
|
| 368 |
+
How It Works
|
| 369 |
+
</h2>
|
| 370 |
+
<div class="steps">
|
| 371 |
+
<div class="step">
|
| 372 |
+
<div class="step-num">1</div>
|
| 373 |
+
<div class="step-body">
|
| 374 |
+
<h3>Add a Source Text</h3>
|
| 375 |
+
<p>Paste any text you want to analyze — a sermon, a letter, a medieval chronicle, a theological treatise — into the Sources page. The text is stored locally in a SQLite database.</p>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
<div class="step">
|
| 379 |
+
<div class="step-num">2</div>
|
| 380 |
+
<div class="step-body">
|
| 381 |
+
<h3>AI Detection via Google Gemini</h3>
|
| 382 |
+
<p>Click "Process with AI" to send the text to Google Gemini with a carefully engineered prompt. The model returns a structured JSON list of all detected scriptural passages, their verse references, and classification types. Only a free Gemini API key is required.</p>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
+
<div class="step">
|
| 386 |
+
<div class="step-num">3</div>
|
| 387 |
+
<div class="step-body">
|
| 388 |
+
<h3>Span Location & Annotation</h3>
|
| 389 |
+
<p>The app finds the exact character span of each detected quote within the original text, then stores annotations linking text spans to specific Bible verses using a full verse database.</p>
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
<div class="step">
|
| 393 |
+
<div class="step-num">4</div>
|
| 394 |
+
<div class="step-body">
|
| 395 |
+
<h3>Interactive Exploration</h3>
|
| 396 |
+
<p>View color-coded highlights directly in the text, click any passage to see the verse references and original Bible text side-by-side, edit or add annotations manually, and explore distribution charts by Bible book, testament, and quote type.</p>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
|
| 402 |
+
<!-- Quote type classifications -->
|
| 403 |
+
<div class="about-card">
|
| 404 |
+
<h2>
|
| 405 |
+
<span class="icon">
|
| 406 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18"/></svg>
|
| 407 |
+
</span>
|
| 408 |
+
Quote Classification Types
|
| 409 |
+
</h2>
|
| 410 |
+
<p>The AI classifies each detected passage into one of four categories:</p>
|
| 411 |
+
<div class="type-grid">
|
| 412 |
+
<div class="type-item full">
|
| 413 |
+
<strong>Full</strong>
|
| 414 |
+
<p>A complete or near-complete verse quoted verbatim from the Bible.</p>
|
| 415 |
+
</div>
|
| 416 |
+
<div class="type-item partial">
|
| 417 |
+
<strong>Partial</strong>
|
| 418 |
+
<p>A recognizable portion of a verse, quoted with minor variation or truncation.</p>
|
| 419 |
+
</div>
|
| 420 |
+
<div class="type-item paraphrase">
|
| 421 |
+
<strong>Paraphrase</strong>
|
| 422 |
+
<p>Biblical content clearly restated in different words while preserving the meaning.</p>
|
| 423 |
+
</div>
|
| 424 |
+
<div class="type-item allusion">
|
| 425 |
+
<strong>Allusion</strong>
|
| 426 |
+
<p>A brief phrase, thematic echo, or indirect reference to a specific verse.</p>
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
|
| 431 |
+
<!-- Technical requirements -->
|
| 432 |
+
<div class="about-card">
|
| 433 |
+
<h2>
|
| 434 |
+
<span class="icon">
|
| 435 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
| 436 |
+
</span>
|
| 437 |
+
Technical Requirements
|
| 438 |
+
</h2>
|
| 439 |
+
<ul>
|
| 440 |
+
<li>Python 3.10+ with Flask — the entire backend runs as a lightweight local web application.</li>
|
| 441 |
+
<li>SQLite — all sources, annotations, and settings are stored in a local database with no external server required.</li>
|
| 442 |
+
<li>A full Bible verse database (35,000+ verses) is included and used for verse lookup and reference validation.</li>
|
| 443 |
+
<li>Google Vertex AI or a Gemini API key from <a href="https://aistudio.google.com/apikey" target="_blank" style="color:var(--primary-mid);font-weight:600">Google AI Studio</a> — this is the only external dependency and is free to obtain.</li>
|
| 444 |
+
</ul>
|
| 445 |
+
<div class="api-note">
|
| 446 |
+
<div class="api-note-icon">🔑</div>
|
| 447 |
+
<p>
|
| 448 |
+
<strong>Only a free Gemini API key is required.</strong> Get yours at
|
| 449 |
+
<a href="https://aistudio.google.com/apikey" target="_blank">aistudio.google.com/apikey</a>
|
| 450 |
+
and enter it in the <a href="/settings">Settings</a> page to start using the app immediately.
|
| 451 |
+
No cloud account, credit card, or additional setup is needed.
|
| 452 |
+
</p>
|
| 453 |
+
</div>
|
| 454 |
+
</div>
|
| 455 |
+
|
| 456 |
+
<!-- Advanced Search -->
|
| 457 |
+
<div class="about-card">
|
| 458 |
+
<h2>
|
| 459 |
+
<span class="icon">
|
| 460 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 461 |
+
</span>
|
| 462 |
+
Advanced Search
|
| 463 |
+
</h2>
|
| 464 |
+
<p>
|
| 465 |
+
The Sources page includes a powerful multi-layered search system that lets you find sources by their text content, the Bible books they reference, specific chapters, or individual verses — all updating in real time as you type.
|
| 466 |
+
</p>
|
| 467 |
+
<div class="type-grid" style="margin-top:14px">
|
| 468 |
+
<div class="type-item" style="background:rgba(0,53,107,.06);border-color:var(--primary)">
|
| 469 |
+
<strong style="color:var(--primary)">Text Content</strong>
|
| 470 |
+
<p>Full-text search across all source documents. Matching passages are highlighted in context with the search term shown inline.</p>
|
| 471 |
+
</div>
|
| 472 |
+
<div class="type-item" style="background:rgba(0,53,107,.06);border-color:var(--primary-mid)">
|
| 473 |
+
<strong style="color:var(--primary-mid)">Bible Book</strong>
|
| 474 |
+
<p>Find all sources that contain any reference to a chosen Bible book, using a searchable dropdown of all 73 books.</p>
|
| 475 |
+
</div>
|
| 476 |
+
<div class="type-item" style="background:rgba(0,53,107,.06);border-color:var(--primary-light)">
|
| 477 |
+
<strong style="color:var(--primary)">Chapter</strong>
|
| 478 |
+
<p>Narrow results to sources referencing a specific chapter (e.g. Psalms 23) using cascading book → chapter selectors.</p>
|
| 479 |
+
</div>
|
| 480 |
+
<div class="type-item" style="background:rgba(249,190,0,.1);border-color:var(--accent)">
|
| 481 |
+
<strong style="color:var(--accent-hover)">Verse</strong>
|
| 482 |
+
<p>Pinpoint sources that reference a specific verse (e.g. John 3:16) using book → chapter → verse number selectors.</p>
|
| 483 |
+
</div>
|
| 484 |
+
</div>
|
| 485 |
+
<p style="margin-top:16px">
|
| 486 |
+
<strong>Stacked filters with AND / OR logic</strong> — click <em>Advanced</em> to open the filter builder and stack any number of filters. Toggle between AND (source must match <em>all</em> filters) and OR (source must match <em>any</em> filter). Active filters are summarized as removable chips below the search bar. Match evidence appears directly on each source card, showing the exact text snippet or the matched verse and quote.
|
| 487 |
+
</p>
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<!-- Features -->
|
| 491 |
+
<div class="about-card">
|
| 492 |
+
<h2>
|
| 493 |
+
<span class="icon">
|
| 494 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
| 495 |
+
</span>
|
| 496 |
+
Key Features
|
| 497 |
+
</h2>
|
| 498 |
+
<ul>
|
| 499 |
+
<li>Full-text AI analysis using Google Gemini with structured JSON output.</li>
|
| 500 |
+
<li>Color-coded in-text annotations with click-to-explore interactivity.</li>
|
| 501 |
+
<li>Selection-based re-analysis: highlight any passage and re-run AI detection on just that section.</li>
|
| 502 |
+
<li>Manual annotation editor with a Bible verse picker (book → chapter → verse).</li>
|
| 503 |
+
<li><strong>Advanced search</strong> with real-time filtering by text content, Bible book, chapter, or verse — stackable filters with AND / OR logic and inline match evidence.</li>
|
| 504 |
+
<li>Distribution charts showing which Bible books and testaments are most referenced.</li>
|
| 505 |
+
<li>Global analytics dashboard across all sources.</li>
|
| 506 |
+
<li>Support for both Gemini API (free tier) and Google Vertex AI.</li>
|
| 507 |
+
<li>Model switching — choose between available Gemini model versions.</li>
|
| 508 |
+
<li>Runs entirely locally — your texts never leave your machine except for the AI API call.</li>
|
| 509 |
+
</ul>
|
| 510 |
+
</div>
|
| 511 |
+
|
| 512 |
+
<!-- Screenshots -->
|
| 513 |
+
<div class="about-card">
|
| 514 |
+
<h2>
|
| 515 |
+
<span class="icon">
|
| 516 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
| 517 |
+
</span>
|
| 518 |
+
Screenshots
|
| 519 |
+
</h2>
|
| 520 |
+
<div class="screenshots-container screenshot-grid">
|
| 521 |
+
<div class="screenshot-item">
|
| 522 |
+
<img src="/static/screenshots/sources.png" alt="Sources page" onerror="this.closest('.screenshot-item').style.display='none'">
|
| 523 |
+
<div class="screenshot-caption">Sources page — manage your text sources and see detection statistics at a glance</div>
|
| 524 |
+
</div>
|
| 525 |
+
<div class="screenshot-item">
|
| 526 |
+
<img src="/static/screenshots/viewer.png" alt="Text viewer" onerror="this.closest('.screenshot-item').style.display='none'">
|
| 527 |
+
<div class="screenshot-caption">Text viewer — color-coded annotations with side-by-side verse references</div>
|
| 528 |
+
</div>
|
| 529 |
+
<div class="screenshot-item">
|
| 530 |
+
<img src="/static/screenshots/dashboard.png" alt="Analytics dashboard" onerror="this.closest('.screenshot-item').style.display='none'">
|
| 531 |
+
<div class="screenshot-caption">Analytics dashboard — explore scripture distribution across all sources</div>
|
| 532 |
+
</div>
|
| 533 |
+
<div class="screenshot-item">
|
| 534 |
+
<img src="/static/screenshots/settings.png" alt="Settings page" onerror="this.closest('.screenshot-item').style.display='none'">
|
| 535 |
+
<div class="screenshot-caption">Settings — configure your Gemini API key and model selection</div>
|
| 536 |
+
</div>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
|
| 540 |
+
</div>
|
| 541 |
+
|
| 542 |
+
<!-- Footer -->
|
| 543 |
+
<div style="text-align:center;padding:40px 0 24px;color:var(--muted);font-size:.8rem;border-top:1px solid var(--border-light);margin-top:40px">
|
| 544 |
+
Developed by Dr. William J.B. Mattingly · Yale University · 2026<br>
|
| 545 |
+
Built for the <a href="https://www.oeaw.ac.at/en/imafo/events/event-details/ruse-of-reuse" target="_blank" style="color:var(--primary-mid)">Ruse of Reuse</a> workshop, Austrian Academy of Sciences, Vienna · March 5–6, 2026
|
| 546 |
+
</div>
|
| 547 |
+
</div>
|
| 548 |
+
|
| 549 |
+
</body>
|
| 550 |
+
</html>
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Scripture Detector — Dashboard</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 10 |
+
<style>
|
| 11 |
+
.container { max-width: 1400px; }
|
| 12 |
+
|
| 13 |
+
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
| 14 |
+
gap: 16px; margin-bottom: 28px; }
|
| 15 |
+
.kpi {
|
| 16 |
+
background: var(--surface);
|
| 17 |
+
border-radius: var(--radius-lg);
|
| 18 |
+
padding: 24px;
|
| 19 |
+
box-shadow: var(--shadow-sm);
|
| 20 |
+
border: 1px solid var(--border-light);
|
| 21 |
+
text-align: center;
|
| 22 |
+
transition: var(--transition);
|
| 23 |
+
}
|
| 24 |
+
.kpi:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
| 25 |
+
.kpi .val { font-size: 2.4rem; font-weight: 900; line-height: 1.1; letter-spacing: -.02em; }
|
| 26 |
+
.kpi .lbl { font-size: .7rem; color: var(--muted); text-transform: uppercase;
|
| 27 |
+
letter-spacing: .06em; margin-top: 8px; font-weight: 600; }
|
| 28 |
+
.kpi-primary .val { color: var(--primary); }
|
| 29 |
+
.kpi-green .val { color: var(--green); }
|
| 30 |
+
.kpi-accent .val { color: var(--accent); }
|
| 31 |
+
.kpi-purple .val { color: var(--allusion); }
|
| 32 |
+
|
| 33 |
+
.cards-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; }
|
| 34 |
+
@media (max-width: 800px) { .cards-row { grid-template-columns: 1fr; } }
|
| 35 |
+
|
| 36 |
+
.chart-wrap { position: relative; width: 100%; height: 280px; }
|
| 37 |
+
|
| 38 |
+
.type-bar { height: 36px; border-radius: var(--radius-sm); margin-bottom: 12px; }
|
| 39 |
+
.type-bar div { display: flex; align-items: center; justify-content: center;
|
| 40 |
+
font-size: .72rem; font-weight: 700; color: #fff; min-width: 28px; transition: width .4s; }
|
| 41 |
+
|
| 42 |
+
.section-title { font-size: .9rem; font-weight: 700; margin-bottom: 16px;
|
| 43 |
+
text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
| 44 |
+
|
| 45 |
+
table { width: 100%; border-collapse: collapse; font-size: .84rem; }
|
| 46 |
+
th { text-align: left; padding: 12px 14px; border-bottom: 2px solid var(--border);
|
| 47 |
+
font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
|
| 48 |
+
cursor: pointer; user-select: none; white-space: nowrap; font-weight: 700; }
|
| 49 |
+
th:hover { color: var(--text); }
|
| 50 |
+
td { padding: 12px 14px; border-bottom: 1px solid var(--border-light); }
|
| 51 |
+
tr:hover td { background: var(--bg); }
|
| 52 |
+
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
| 53 |
+
.link-cell a { color: var(--primary); text-decoration: none; font-weight: 600; }
|
| 54 |
+
.link-cell a:hover { text-decoration: underline; }
|
| 55 |
+
.mini-bar { display: flex; height: 18px; border-radius: 4px; overflow: hidden; min-width: 80px;
|
| 56 |
+
background: var(--bg-secondary); }
|
| 57 |
+
.mini-bar div { min-width: 2px; }
|
| 58 |
+
</style>
|
| 59 |
+
</head>
|
| 60 |
+
<body>
|
| 61 |
+
|
| 62 |
+
<div class="header">
|
| 63 |
+
<a href="/" class="header-brand">
|
| 64 |
+
<img src="/static/logo.svg" alt="Logo" class="header-logo">
|
| 65 |
+
<div>
|
| 66 |
+
<div class="header-title">Scripture Detector</div>
|
| 67 |
+
<div class="header-subtitle">Analytics Dashboard</div>
|
| 68 |
+
</div>
|
| 69 |
+
</a>
|
| 70 |
+
<nav>
|
| 71 |
+
<a href="/">Sources</a>
|
| 72 |
+
<a href="/dashboard" class="active">Dashboard</a>
|
| 73 |
+
<a href="/about">About</a>
|
| 74 |
+
<a href="/settings">Settings</a>
|
| 75 |
+
</nav>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div class="container" id="app">
|
| 79 |
+
<div class="loading"><div class="spinner"></div>Loading dashboard data...</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<script>
|
| 83 |
+
const app = document.getElementById('app');
|
| 84 |
+
let RAW = null;
|
| 85 |
+
let sortCol = 'name', sortAsc = true;
|
| 86 |
+
|
| 87 |
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
| 88 |
+
|
| 89 |
+
function render() {
|
| 90 |
+
if (!RAW) return;
|
| 91 |
+
const {source_count, quote_count, reference_count, sources, book_distribution, type_distribution} = RAW;
|
| 92 |
+
|
| 93 |
+
if (source_count === 0) {
|
| 94 |
+
app.innerHTML = `<div class="empty-state">
|
| 95 |
+
<img src="/static/logo.svg" alt="" class="empty-icon">
|
| 96 |
+
<h2>No data yet</h2>
|
| 97 |
+
<p>Add sources and process them to see analytics here.</p>
|
| 98 |
+
<a href="/" class="btn btn-primary">Go to Sources</a>
|
| 99 |
+
</div>`;
|
| 100 |
+
return;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const tc = type_distribution || {};
|
| 104 |
+
const totalTypes = Object.values(tc).reduce((s,v) => s + v, 0);
|
| 105 |
+
const pct = v => totalTypes ? ((v / totalTypes) * 100).toFixed(1) : 0;
|
| 106 |
+
|
| 107 |
+
let sorted = [...(sources || [])];
|
| 108 |
+
sorted.sort((x, y) => {
|
| 109 |
+
let va = x[sortCol] ?? '', vb = y[sortCol] ?? '';
|
| 110 |
+
if (typeof va === 'string') { va = va.toLowerCase(); vb = vb.toLowerCase(); }
|
| 111 |
+
return sortAsc ? (va < vb ? -1 : va > vb ? 1 : 0) : (va > vb ? -1 : va < vb ? 1 : 0);
|
| 112 |
+
});
|
| 113 |
+
const arrow = col => col === sortCol ? (sortAsc ? ' ▲' : ' ▼') : '';
|
| 114 |
+
|
| 115 |
+
const bd = book_distribution || [];
|
| 116 |
+
const topBook = bd.length > 0 ? (bd[0].book_name || bd[0].book_code) : '—';
|
| 117 |
+
|
| 118 |
+
app.innerHTML = `
|
| 119 |
+
<div class="kpi-grid">
|
| 120 |
+
<div class="kpi kpi-primary"><div class="val">${source_count}</div><div class="lbl">Sources</div></div>
|
| 121 |
+
<div class="kpi kpi-green"><div class="val">${quote_count}</div><div class="lbl">Quotes Found</div></div>
|
| 122 |
+
<div class="kpi kpi-accent"><div class="val">${reference_count}</div><div class="lbl">Scripture References</div></div>
|
| 123 |
+
<div class="kpi"><div class="val">${bd.length}</div><div class="lbl">Books Referenced</div></div>
|
| 124 |
+
<div class="kpi kpi-purple"><div class="val" style="font-size:1.4rem">${esc(topBook)}</div><div class="lbl">Most Referenced</div></div>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div class="cards-row">
|
| 128 |
+
<div class="card">
|
| 129 |
+
<h3>Quote Type Distribution</h3>
|
| 130 |
+
${totalTypes > 0 ? `
|
| 131 |
+
<div class="type-bar">
|
| 132 |
+
${tc.full ? `<div style="width:${pct(tc.full)}%;background:var(--full)">${tc.full}</div>` : ''}
|
| 133 |
+
${tc.partial ? `<div style="width:${pct(tc.partial)}%;background:var(--partial)">${tc.partial}</div>` : ''}
|
| 134 |
+
${tc.paraphrase ? `<div style="width:${pct(tc.paraphrase)}%;background:var(--paraphrase)">${tc.paraphrase}</div>` : ''}
|
| 135 |
+
${tc.allusion ? `<div style="width:${pct(tc.allusion)}%;background:var(--allusion)">${tc.allusion}</div>` : ''}
|
| 136 |
+
</div>
|
| 137 |
+
<div class="type-legend">
|
| 138 |
+
<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--full)"></div>Full (${tc.full||0})</div>
|
| 139 |
+
<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--partial)"></div>Partial (${tc.partial||0})</div>
|
| 140 |
+
<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--paraphrase)"></div>Paraphrase (${tc.paraphrase||0})</div>
|
| 141 |
+
<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--allusion)"></div>Allusion (${tc.allusion||0})</div>
|
| 142 |
+
</div>` : '<p style="color:var(--muted);font-size:.85rem">No quotes detected yet.</p>'}
|
| 143 |
+
</div>
|
| 144 |
+
<div class="card">
|
| 145 |
+
<h3>Top Bible Books Referenced</h3>
|
| 146 |
+
<div class="chart-wrap"><canvas id="book-chart"></canvas></div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="cards-row">
|
| 151 |
+
<div class="card">
|
| 152 |
+
<h3>Scripture Distribution by Testament</h3>
|
| 153 |
+
<div class="chart-wrap"><canvas id="testament-chart"></canvas></div>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="card">
|
| 156 |
+
<h3>Quotes per Source</h3>
|
| 157 |
+
<div class="chart-wrap"><canvas id="source-chart"></canvas></div>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
|
| 161 |
+
<div class="section-title">Per-Source Breakdown</div>
|
| 162 |
+
<div class="card" style="overflow-x:auto">
|
| 163 |
+
<table>
|
| 164 |
+
<thead><tr>
|
| 165 |
+
<th data-col="name">Source${arrow('name')}</th>
|
| 166 |
+
<th data-col="quote_count" class="num">Quotes${arrow('quote_count')}</th>
|
| 167 |
+
<th>Type Breakdown</th>
|
| 168 |
+
<th>Top Books</th>
|
| 169 |
+
<th data-col="created_at">Added${arrow('created_at')}</th>
|
| 170 |
+
<th></th>
|
| 171 |
+
</tr></thead>
|
| 172 |
+
<tbody>
|
| 173 |
+
${sorted.map(s => {
|
| 174 |
+
const td = s.type_distribution || {};
|
| 175 |
+
const total = (td.full||0) + (td.partial||0) + (td.paraphrase||0) + (td.allusion||0);
|
| 176 |
+
const p = v => total ? ((v/total)*100).toFixed(0) : 0;
|
| 177 |
+
const sbd = s.book_distribution || [];
|
| 178 |
+
const topBooks = sbd.slice(0,3).map(b => b.book_name || b.book_code).join(', ');
|
| 179 |
+
return `<tr>
|
| 180 |
+
<td><strong>${esc(s.name)}</strong></td>
|
| 181 |
+
<td class="num">${s.quote_count}</td>
|
| 182 |
+
<td>${total > 0 ? `<div class="mini-bar">
|
| 183 |
+
${td.full?`<div style="width:${p(td.full)}%;background:var(--full)"></div>`:''}
|
| 184 |
+
${td.partial?`<div style="width:${p(td.partial)}%;background:var(--partial)"></div>`:''}
|
| 185 |
+
${td.paraphrase?`<div style="width:${p(td.paraphrase)}%;background:var(--paraphrase)"></div>`:''}
|
| 186 |
+
${td.allusion?`<div style="width:${p(td.allusion)}%;background:var(--allusion)"></div>`:''}
|
| 187 |
+
</div>` : '<span style="color:var(--muted);font-size:.78rem">—</span>'}</td>
|
| 188 |
+
<td style="font-size:.78rem;color:var(--muted)">${topBooks || '—'}</td>
|
| 189 |
+
<td style="font-size:.78rem;color:var(--muted)">${new Date(s.created_at).toLocaleDateString()}</td>
|
| 190 |
+
<td class="link-cell"><a href="/viewer/${s.id}">View</a></td>
|
| 191 |
+
</tr>`;
|
| 192 |
+
}).join('')}
|
| 193 |
+
</tbody>
|
| 194 |
+
</table>
|
| 195 |
+
</div>`;
|
| 196 |
+
|
| 197 |
+
document.querySelectorAll('th[data-col]').forEach(th => {
|
| 198 |
+
th.addEventListener('click', () => {
|
| 199 |
+
const col = th.dataset.col;
|
| 200 |
+
if (sortCol === col) sortAsc = !sortAsc;
|
| 201 |
+
else { sortCol = col; sortAsc = col === 'name'; }
|
| 202 |
+
render();
|
| 203 |
+
});
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
renderCharts(bd, sources);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function renderCharts(bookDist, sources) {
|
| 210 |
+
const chartFont = { family: "'Inter', system-ui, sans-serif" };
|
| 211 |
+
|
| 212 |
+
const bookCanvas = document.getElementById('book-chart');
|
| 213 |
+
if (bookCanvas && bookDist.length > 0) {
|
| 214 |
+
const top = bookDist.slice(0, 15);
|
| 215 |
+
const colors = top.map(d => d.testament === 'nt' ? '#6366f1' : d.testament === 'ot' ? '#f59e0b' : '#9ca3af');
|
| 216 |
+
new Chart(bookCanvas, {
|
| 217 |
+
type: 'bar',
|
| 218 |
+
data: {
|
| 219 |
+
labels: top.map(d => d.book_name || d.book_code),
|
| 220 |
+
datasets: [{ data: top.map(d => d.count), backgroundColor: colors, borderRadius: 6 }],
|
| 221 |
+
},
|
| 222 |
+
options: {
|
| 223 |
+
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
| 224 |
+
plugins: { legend: { display: false } },
|
| 225 |
+
scales: {
|
| 226 |
+
x: { beginAtZero: true, ticks: { precision: 0, font: chartFont }, grid: { color: '#f0ede7' } },
|
| 227 |
+
y: { ticks: { font: { ...chartFont, size: 11 } }, grid: { display: false } },
|
| 228 |
+
},
|
| 229 |
+
},
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const testCanvas = document.getElementById('testament-chart');
|
| 234 |
+
if (testCanvas && bookDist.length > 0) {
|
| 235 |
+
const tally = {ot: 0, nt: 0, ap: 0};
|
| 236 |
+
bookDist.forEach(d => { tally[d.testament] = (tally[d.testament] || 0) + d.count; });
|
| 237 |
+
const labels = []; const data = []; const colors = [];
|
| 238 |
+
if (tally.ot) { labels.push('Old Testament'); data.push(tally.ot); colors.push('#f59e0b'); }
|
| 239 |
+
if (tally.nt) { labels.push('New Testament'); data.push(tally.nt); colors.push('#6366f1'); }
|
| 240 |
+
if (tally.ap) { labels.push('Apocrypha'); data.push(tally.ap); colors.push('#9ca3af'); }
|
| 241 |
+
new Chart(testCanvas, {
|
| 242 |
+
type: 'doughnut',
|
| 243 |
+
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 3, borderColor: '#fff', hoverOffset: 8 }] },
|
| 244 |
+
options: {
|
| 245 |
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
| 246 |
+
plugins: { legend: { position: 'bottom', labels: { padding: 20, font: { ...chartFont, size: 12 }, usePointStyle: true, pointStyle: 'rectRounded' } } },
|
| 247 |
+
},
|
| 248 |
+
});
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const srcCanvas = document.getElementById('source-chart');
|
| 252 |
+
if (srcCanvas && sources.length > 0) {
|
| 253 |
+
const sorted = [...sources].sort((a,b) => b.quote_count - a.quote_count).slice(0, 15);
|
| 254 |
+
new Chart(srcCanvas, {
|
| 255 |
+
type: 'bar',
|
| 256 |
+
data: {
|
| 257 |
+
labels: sorted.map(s => s.name.length > 25 ? s.name.slice(0,22) + '...' : s.name),
|
| 258 |
+
datasets: [{ data: sorted.map(s => s.quote_count), backgroundColor: '#8b5cf6', borderRadius: 6 }],
|
| 259 |
+
},
|
| 260 |
+
options: {
|
| 261 |
+
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
| 262 |
+
plugins: { legend: { display: false } },
|
| 263 |
+
scales: {
|
| 264 |
+
x: { beginAtZero: true, ticks: { precision: 0, font: chartFont }, grid: { color: '#f0ede7' } },
|
| 265 |
+
y: { ticks: { font: { ...chartFont, size: 11 } }, grid: { display: false } },
|
| 266 |
+
},
|
| 267 |
+
},
|
| 268 |
+
});
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
fetch('/api/dashboard')
|
| 273 |
+
.then(r => r.json())
|
| 274 |
+
.then(data => { RAW = data; render(); });
|
| 275 |
+
</script>
|
| 276 |
+
</body>
|
| 277 |
+
</html>
|
templates/settings.html
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Scripture Detector — Settings</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
<style>
|
| 10 |
+
.container { max-width: 640px; }
|
| 11 |
+
|
| 12 |
+
.card { margin-bottom: 20px; }
|
| 13 |
+
|
| 14 |
+
.radio-group { display: flex; gap: 12px; margin-bottom: 18px; }
|
| 15 |
+
.radio-card {
|
| 16 |
+
flex: 1;
|
| 17 |
+
padding: 18px;
|
| 18 |
+
border: 2px solid var(--border);
|
| 19 |
+
border-radius: var(--radius);
|
| 20 |
+
cursor: pointer;
|
| 21 |
+
transition: var(--transition);
|
| 22 |
+
text-align: center;
|
| 23 |
+
background: var(--surface);
|
| 24 |
+
}
|
| 25 |
+
.radio-card:hover { border-color: #a5b4fc; background: var(--primary-subtle); }
|
| 26 |
+
.radio-card.selected { border-color: var(--primary); background: var(--primary-subtle); }
|
| 27 |
+
.radio-card input { display: none; }
|
| 28 |
+
.radio-card .rc-title { font-size: .9rem; font-weight: 700; margin-bottom: 3px; color: var(--text); }
|
| 29 |
+
.radio-card .rc-desc { font-size: .74rem; color: var(--muted); }
|
| 30 |
+
|
| 31 |
+
.conditional { display: none; }
|
| 32 |
+
.conditional.visible { display: block; }
|
| 33 |
+
|
| 34 |
+
.actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; align-items: center; }
|
| 35 |
+
|
| 36 |
+
.status-badge { display: inline-block; padding: 3px 10px; border-radius: 20px;
|
| 37 |
+
font-size: .76rem; font-weight: 600; }
|
| 38 |
+
.status-ok { background: rgba(22,163,74,.12); color: var(--green); }
|
| 39 |
+
.status-warn { background: rgba(245,158,11,.12); color: #92400e; }
|
| 40 |
+
|
| 41 |
+
.btn-success { background: linear-gradient(135deg, #16a34a, #15803d); color: #fff; }
|
| 42 |
+
</style>
|
| 43 |
+
</head>
|
| 44 |
+
<body>
|
| 45 |
+
|
| 46 |
+
<div class="header">
|
| 47 |
+
<a href="/" class="header-brand">
|
| 48 |
+
<img src="/static/logo.svg" alt="Logo" class="header-logo">
|
| 49 |
+
<div>
|
| 50 |
+
<div class="header-title">Scripture Detector</div>
|
| 51 |
+
<div class="header-subtitle">Settings</div>
|
| 52 |
+
</div>
|
| 53 |
+
</a>
|
| 54 |
+
<nav>
|
| 55 |
+
<a href="/">Sources</a>
|
| 56 |
+
<a href="/dashboard">Dashboard</a>
|
| 57 |
+
<a href="/about">About</a>
|
| 58 |
+
<a href="/settings" class="active">Settings</a>
|
| 59 |
+
</nav>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div class="container">
|
| 63 |
+
<div class="card">
|
| 64 |
+
<h2>API Configuration</h2>
|
| 65 |
+
|
| 66 |
+
<label style="font-size:.82rem;font-weight:600;margin-bottom:10px;display:block;color:var(--text-secondary)">API Provider</label>
|
| 67 |
+
<div class="radio-group">
|
| 68 |
+
<label class="radio-card" id="rc-gemini" onclick="selectProvider('gemini')">
|
| 69 |
+
<input type="radio" name="provider" value="gemini">
|
| 70 |
+
<div class="rc-title">Gemini API</div>
|
| 71 |
+
<div class="rc-desc">Google AI Studio API Key</div>
|
| 72 |
+
</label>
|
| 73 |
+
<label class="radio-card" id="rc-vertex" onclick="selectProvider('vertex')">
|
| 74 |
+
<input type="radio" name="provider" value="vertex">
|
| 75 |
+
<div class="rc-title">Vertex AI</div>
|
| 76 |
+
<div class="rc-desc">Google Cloud Project</div>
|
| 77 |
+
</label>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<div class="conditional" id="gemini-fields">
|
| 81 |
+
<div class="field">
|
| 82 |
+
<label for="api-key">API Key</label>
|
| 83 |
+
<input type="password" id="api-key" placeholder="AIza...">
|
| 84 |
+
<div class="hint">Get your key from <a href="https://aistudio.google.com/apikey" target="_blank">Google AI Studio</a></div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div class="conditional" id="vertex-fields">
|
| 89 |
+
<div class="field">
|
| 90 |
+
<label for="project-id">Project ID</label>
|
| 91 |
+
<input type="text" id="project-id" placeholder="my-gcp-project">
|
| 92 |
+
</div>
|
| 93 |
+
<div class="field">
|
| 94 |
+
<label for="location">Location</label>
|
| 95 |
+
<input type="text" id="location" placeholder="global" value="global">
|
| 96 |
+
<div class="hint">e.g. global, us-central1, europe-west1</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div class="card">
|
| 102 |
+
<h2>Model Selection</h2>
|
| 103 |
+
<div class="field">
|
| 104 |
+
<label for="model-select">Model</label>
|
| 105 |
+
<select id="model-select">
|
| 106 |
+
{% for m in models %}
|
| 107 |
+
<option value="{{ m.id }}">{{ m.name }}</option>
|
| 108 |
+
{% endfor %}
|
| 109 |
+
</select>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<div class="actions">
|
| 114 |
+
<span id="save-status"></span>
|
| 115 |
+
<button class="btn btn-primary" id="save-btn" onclick="saveSettings()">Save Settings</button>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
<script>
|
| 120 |
+
let currentProvider = 'gemini';
|
| 121 |
+
|
| 122 |
+
function showToast(msg, ok) {
|
| 123 |
+
const el = document.createElement('div');
|
| 124 |
+
el.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
| 125 |
+
el.textContent = msg;
|
| 126 |
+
document.body.appendChild(el);
|
| 127 |
+
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 400); }, 3500);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
function selectProvider(p) {
|
| 131 |
+
currentProvider = p;
|
| 132 |
+
document.getElementById('rc-gemini').classList.toggle('selected', p === 'gemini');
|
| 133 |
+
document.getElementById('rc-vertex').classList.toggle('selected', p === 'vertex');
|
| 134 |
+
document.getElementById('gemini-fields').classList.toggle('visible', p === 'gemini');
|
| 135 |
+
document.getElementById('vertex-fields').classList.toggle('visible', p === 'vertex');
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function loadSettings() {
|
| 139 |
+
fetch('/api/settings')
|
| 140 |
+
.then(r => r.json())
|
| 141 |
+
.then(s => {
|
| 142 |
+
const provider = s.api_provider || 'gemini';
|
| 143 |
+
selectProvider(provider);
|
| 144 |
+
if (s.gemini_api_key) document.getElementById('api-key').value = s.gemini_api_key;
|
| 145 |
+
if (s.vertex_project_id) document.getElementById('project-id').value = s.vertex_project_id;
|
| 146 |
+
if (s.vertex_location) document.getElementById('location').value = s.vertex_location;
|
| 147 |
+
if (s.model) document.getElementById('model-select').value = s.model;
|
| 148 |
+
});
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function saveSettings() {
|
| 152 |
+
const btn = document.getElementById('save-btn');
|
| 153 |
+
btn.disabled = true;
|
| 154 |
+
const payload = {
|
| 155 |
+
api_provider: currentProvider,
|
| 156 |
+
model: document.getElementById('model-select').value,
|
| 157 |
+
};
|
| 158 |
+
if (currentProvider === 'gemini') {
|
| 159 |
+
const key = document.getElementById('api-key').value.trim();
|
| 160 |
+
if (key) payload.gemini_api_key = key;
|
| 161 |
+
} else {
|
| 162 |
+
payload.vertex_project_id = document.getElementById('project-id').value.trim();
|
| 163 |
+
payload.vertex_location = document.getElementById('location').value.trim() || 'global';
|
| 164 |
+
}
|
| 165 |
+
fetch('/api/settings', {
|
| 166 |
+
method: 'POST',
|
| 167 |
+
headers: {'Content-Type': 'application/json'},
|
| 168 |
+
body: JSON.stringify(payload),
|
| 169 |
+
})
|
| 170 |
+
.then(r => r.json())
|
| 171 |
+
.then(() => {
|
| 172 |
+
showToast('Settings saved!', true);
|
| 173 |
+
btn.classList.remove('btn-primary');
|
| 174 |
+
btn.classList.add('btn-success');
|
| 175 |
+
btn.textContent = 'Saved!';
|
| 176 |
+
setTimeout(() => {
|
| 177 |
+
btn.classList.remove('btn-success');
|
| 178 |
+
btn.classList.add('btn-primary');
|
| 179 |
+
btn.textContent = 'Save Settings';
|
| 180 |
+
}, 2000);
|
| 181 |
+
})
|
| 182 |
+
.catch(e => showToast('Error: ' + e.message, false))
|
| 183 |
+
.finally(() => { btn.disabled = false; });
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
loadSettings();
|
| 187 |
+
</script>
|
| 188 |
+
</body>
|
| 189 |
+
</html>
|
templates/sources.html
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Scripture Detector</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
<style>
|
| 10 |
+
/* ── Toolbar ────────────────────────────────────────────────────────── */
|
| 11 |
+
.toolbar {
|
| 12 |
+
display: flex; gap: 12px; margin-bottom: 16px;
|
| 13 |
+
align-items: center; flex-wrap: wrap;
|
| 14 |
+
}
|
| 15 |
+
.toolbar .summary {
|
| 16 |
+
font-size: .84rem; color: var(--muted); margin-left: auto;
|
| 17 |
+
}
|
| 18 |
+
.toolbar .summary strong { color: var(--text); font-weight: 700; }
|
| 19 |
+
|
| 20 |
+
/* ── Search Panel ───────────────────────────────────────────────────── */
|
| 21 |
+
.search-panel {
|
| 22 |
+
background: var(--surface);
|
| 23 |
+
border: 1.5px solid var(--border);
|
| 24 |
+
border-radius: var(--radius-lg);
|
| 25 |
+
padding: 16px 20px;
|
| 26 |
+
margin-bottom: 24px;
|
| 27 |
+
box-shadow: var(--shadow-sm);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.search-top { display: flex; gap: 10px; align-items: center; }
|
| 31 |
+
|
| 32 |
+
.search-input-wrap { flex: 1; position: relative; }
|
| 33 |
+
.search-input-wrap svg {
|
| 34 |
+
position: absolute; left: 11px; top: 50%;
|
| 35 |
+
transform: translateY(-50%); color: var(--muted); pointer-events: none;
|
| 36 |
+
}
|
| 37 |
+
.search-input {
|
| 38 |
+
width: 100%; padding: 9px 14px 9px 38px;
|
| 39 |
+
border: 1.5px solid var(--border); border-radius: var(--radius-sm);
|
| 40 |
+
font-size: .9rem; font-family: inherit; color: var(--text);
|
| 41 |
+
transition: var(--transition);
|
| 42 |
+
}
|
| 43 |
+
.search-input:focus {
|
| 44 |
+
outline: none; border-color: var(--primary-mid);
|
| 45 |
+
box-shadow: 0 0 0 3px var(--primary-subtle);
|
| 46 |
+
}
|
| 47 |
+
.search-input.has-value { border-color: var(--primary-mid); }
|
| 48 |
+
|
| 49 |
+
.search-clear {
|
| 50 |
+
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
| 51 |
+
background: none; border: none; cursor: pointer; color: var(--muted);
|
| 52 |
+
font-size: 1rem; line-height: 1; padding: 2px 4px; border-radius: 3px;
|
| 53 |
+
display: none;
|
| 54 |
+
}
|
| 55 |
+
.search-clear:hover { color: var(--red); }
|
| 56 |
+
|
| 57 |
+
.adv-toggle {
|
| 58 |
+
display: flex; align-items: center; gap: 7px;
|
| 59 |
+
padding: 8px 16px; border: 1.5px solid var(--border);
|
| 60 |
+
border-radius: var(--radius-sm); background: var(--surface);
|
| 61 |
+
cursor: pointer; font-size: .84rem; font-weight: 600;
|
| 62 |
+
color: var(--muted); white-space: nowrap; font-family: inherit;
|
| 63 |
+
transition: var(--transition);
|
| 64 |
+
}
|
| 65 |
+
.adv-toggle:hover { border-color: var(--primary-mid); color: var(--primary); }
|
| 66 |
+
.adv-toggle.active {
|
| 67 |
+
border-color: var(--primary); color: var(--primary);
|
| 68 |
+
background: var(--primary-pale);
|
| 69 |
+
}
|
| 70 |
+
.adv-toggle .badge {
|
| 71 |
+
background: var(--primary); color: #fff;
|
| 72 |
+
border-radius: 10px; font-size: .68rem; font-weight: 800;
|
| 73 |
+
padding: 1px 6px; display: none;
|
| 74 |
+
}
|
| 75 |
+
.adv-toggle.has-filters .badge { display: inline; }
|
| 76 |
+
|
| 77 |
+
/* ── Advanced Panel ─────────────────────────────────────────────────── */
|
| 78 |
+
.adv-panel {
|
| 79 |
+
margin-top: 14px; padding-top: 14px;
|
| 80 |
+
border-top: 1px solid var(--border-light);
|
| 81 |
+
display: none;
|
| 82 |
+
}
|
| 83 |
+
.adv-panel.visible { display: block; }
|
| 84 |
+
|
| 85 |
+
.adv-header {
|
| 86 |
+
display: flex; align-items: center; gap: 14px;
|
| 87 |
+
margin-bottom: 12px; flex-wrap: wrap;
|
| 88 |
+
}
|
| 89 |
+
.adv-header label {
|
| 90 |
+
font-size: .78rem; font-weight: 700;
|
| 91 |
+
color: var(--muted); text-transform: uppercase; letter-spacing: .06em;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* Logic toggle pill */
|
| 95 |
+
.logic-toggle {
|
| 96 |
+
display: inline-flex; border: 1.5px solid var(--border);
|
| 97 |
+
border-radius: var(--radius-sm); overflow: hidden;
|
| 98 |
+
}
|
| 99 |
+
.logic-btn {
|
| 100 |
+
padding: 5px 14px; border: none; background: transparent;
|
| 101 |
+
cursor: pointer; font-size: .8rem; font-weight: 700;
|
| 102 |
+
color: var(--muted); font-family: inherit; transition: var(--transition);
|
| 103 |
+
}
|
| 104 |
+
.logic-btn.active { background: var(--primary); color: #fff; }
|
| 105 |
+
|
| 106 |
+
/* Filter rows */
|
| 107 |
+
.filter-rows { display: flex; flex-direction: column; gap: 8px; }
|
| 108 |
+
|
| 109 |
+
.filter-row {
|
| 110 |
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
| 111 |
+
padding: 10px 14px;
|
| 112 |
+
background: var(--bg); border-radius: var(--radius-sm);
|
| 113 |
+
border: 1px solid var(--border-light);
|
| 114 |
+
position: relative;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.filter-connector {
|
| 118 |
+
font-size: .7rem; font-weight: 800; color: var(--primary);
|
| 119 |
+
text-transform: uppercase; letter-spacing: .08em;
|
| 120 |
+
min-width: 28px; text-align: center;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.filter-select, .filter-input {
|
| 124 |
+
padding: 6px 10px; border: 1.5px solid var(--border);
|
| 125 |
+
border-radius: var(--radius-sm); font-size: .84rem;
|
| 126 |
+
font-family: inherit; color: var(--text); background: var(--surface);
|
| 127 |
+
transition: var(--transition);
|
| 128 |
+
}
|
| 129 |
+
.filter-select:focus, .filter-input:focus {
|
| 130 |
+
outline: none; border-color: var(--primary-mid);
|
| 131 |
+
box-shadow: 0 0 0 3px var(--primary-subtle);
|
| 132 |
+
}
|
| 133 |
+
.filter-select:disabled { opacity: .4; }
|
| 134 |
+
.filter-type { min-width: 130px; }
|
| 135 |
+
.filter-book { min-width: 150px; }
|
| 136 |
+
.filter-chapter { width: 80px; }
|
| 137 |
+
.filter-verse { width: 80px; }
|
| 138 |
+
.filter-text { flex: 1; min-width: 160px; }
|
| 139 |
+
|
| 140 |
+
.filter-remove {
|
| 141 |
+
background: none; border: none; cursor: pointer;
|
| 142 |
+
color: var(--muted); font-size: 1.1rem; line-height: 1;
|
| 143 |
+
padding: 2px 6px; border-radius: 4px; margin-left: auto;
|
| 144 |
+
transition: var(--transition);
|
| 145 |
+
}
|
| 146 |
+
.filter-remove:hover { color: var(--red); background: rgba(198,40,40,.08); }
|
| 147 |
+
|
| 148 |
+
.add-filter-btn {
|
| 149 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 150 |
+
margin-top: 8px; padding: 7px 14px;
|
| 151 |
+
border: 1.5px dashed var(--border); border-radius: var(--radius-sm);
|
| 152 |
+
background: transparent; cursor: pointer; font-size: .82rem;
|
| 153 |
+
font-weight: 600; color: var(--muted); font-family: inherit;
|
| 154 |
+
transition: var(--transition);
|
| 155 |
+
}
|
| 156 |
+
.add-filter-btn:hover {
|
| 157 |
+
border-color: var(--primary-mid); color: var(--primary);
|
| 158 |
+
background: var(--primary-subtle);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
/* Active filters summary chip strip */
|
| 162 |
+
.active-chips {
|
| 163 |
+
display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px;
|
| 164 |
+
}
|
| 165 |
+
.chip {
|
| 166 |
+
display: inline-flex; align-items: center; gap: 5px;
|
| 167 |
+
background: var(--primary-pale); color: var(--primary);
|
| 168 |
+
border: 1px solid #c9dcf5; border-radius: 20px;
|
| 169 |
+
font-size: .76rem; font-weight: 600; padding: 3px 10px;
|
| 170 |
+
}
|
| 171 |
+
.chip .chip-x {
|
| 172 |
+
background: none; border: none; cursor: pointer;
|
| 173 |
+
color: var(--primary-mid); font-size: .9rem; line-height: 1;
|
| 174 |
+
padding: 0; margin-left: 1px;
|
| 175 |
+
}
|
| 176 |
+
.chip .chip-x:hover { color: var(--red); }
|
| 177 |
+
|
| 178 |
+
/* ── Source Grid ────────────────────────────────────────────────────── */
|
| 179 |
+
.source-grid {
|
| 180 |
+
display: grid;
|
| 181 |
+
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
| 182 |
+
gap: 20px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.source-card {
|
| 186 |
+
background: var(--surface);
|
| 187 |
+
border-radius: var(--radius-lg);
|
| 188 |
+
box-shadow: var(--shadow-sm);
|
| 189 |
+
border: 1.5px solid var(--border-light);
|
| 190 |
+
padding: 24px;
|
| 191 |
+
transition: all .22s cubic-bezier(.4,0,.2,1);
|
| 192 |
+
display: flex; flex-direction: column; gap: 14px; cursor: pointer;
|
| 193 |
+
}
|
| 194 |
+
.source-card:hover {
|
| 195 |
+
box-shadow: var(--shadow-lg); transform: translateY(-3px);
|
| 196 |
+
border-color: var(--primary-light);
|
| 197 |
+
}
|
| 198 |
+
.source-card.search-match { border-color: #c9dcf5; }
|
| 199 |
+
|
| 200 |
+
.sc-header { display: flex; align-items: flex-start; gap: 12px; }
|
| 201 |
+
.sc-name { font-size: 1.05rem; font-weight: 700; flex: 1; line-height: 1.35; }
|
| 202 |
+
.sc-date {
|
| 203 |
+
font-size: .7rem; color: var(--muted); white-space: nowrap;
|
| 204 |
+
background: var(--bg-secondary); padding: 3px 8px; border-radius: 4px;
|
| 205 |
+
}
|
| 206 |
+
.sc-stats { display: flex; gap: 24px; }
|
| 207 |
+
.sc-stat { display: flex; flex-direction: column; }
|
| 208 |
+
.sc-stat-val { font-size: 1.4rem; font-weight: 800; }
|
| 209 |
+
.sc-stat-lbl {
|
| 210 |
+
font-size: .66rem; color: var(--muted); text-transform: uppercase;
|
| 211 |
+
letter-spacing: .06em; font-weight: 600;
|
| 212 |
+
}
|
| 213 |
+
.sc-stat-primary .sc-stat-val { color: var(--primary); }
|
| 214 |
+
.sc-stat-accent .sc-stat-val { color: var(--accent); }
|
| 215 |
+
|
| 216 |
+
.sc-books {
|
| 217 |
+
font-size: .78rem; color: var(--muted); line-height: 1.5;
|
| 218 |
+
padding: 8px 12px; background: var(--bg); border-radius: var(--radius-sm);
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.sc-footer {
|
| 222 |
+
display: flex; gap: 8px; align-items: center;
|
| 223 |
+
margin-top: auto; padding-top: 8px;
|
| 224 |
+
border-top: 1px solid var(--border-light);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/* ── Match Evidence ─────────────────────────────────────────────────── */
|
| 228 |
+
.match-evidence {
|
| 229 |
+
border-radius: var(--radius-sm);
|
| 230 |
+
overflow: hidden;
|
| 231 |
+
border: 1px solid #c9dcf5;
|
| 232 |
+
}
|
| 233 |
+
.match-evidence-header {
|
| 234 |
+
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
| 235 |
+
letter-spacing: .06em; color: var(--primary);
|
| 236 |
+
padding: 5px 10px; background: var(--primary-pale);
|
| 237 |
+
display: flex; align-items: center; gap: 5px;
|
| 238 |
+
}
|
| 239 |
+
.match-item {
|
| 240 |
+
padding: 8px 10px; font-size: .8rem; border-top: 1px solid #dce8f8;
|
| 241 |
+
background: var(--surface);
|
| 242 |
+
}
|
| 243 |
+
.match-item:first-child { border-top: none; }
|
| 244 |
+
|
| 245 |
+
.match-text-snippet {
|
| 246 |
+
color: var(--text-secondary); line-height: 1.6;
|
| 247 |
+
}
|
| 248 |
+
.match-text-snippet mark {
|
| 249 |
+
background: #fef08a; color: #713f12;
|
| 250 |
+
border-radius: 2px; padding: 1px 3px; font-weight: 800;
|
| 251 |
+
box-shadow: 0 0 0 1px #fde047;
|
| 252 |
+
}
|
| 253 |
+
.match-loc {
|
| 254 |
+
font-size: .68rem; color: var(--muted); margin-top: 3px;
|
| 255 |
+
font-style: italic;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.match-ref-row {
|
| 259 |
+
display: flex; align-items: flex-start; gap: 7px; flex-wrap: wrap;
|
| 260 |
+
}
|
| 261 |
+
.match-ref-badge {
|
| 262 |
+
font-family: 'SF Mono', ui-monospace, monospace;
|
| 263 |
+
font-size: .72rem; font-weight: 700;
|
| 264 |
+
background: var(--bg-secondary); color: var(--primary);
|
| 265 |
+
padding: 2px 7px; border-radius: 4px; white-space: nowrap;
|
| 266 |
+
border: 1px solid var(--border-light);
|
| 267 |
+
}
|
| 268 |
+
.match-ref-quote {
|
| 269 |
+
color: var(--muted); font-style: italic;
|
| 270 |
+
flex: 1; min-width: 0; overflow: hidden;
|
| 271 |
+
white-space: nowrap; text-overflow: ellipsis;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.no-results {
|
| 275 |
+
grid-column: 1/-1; text-align: center;
|
| 276 |
+
padding: 60px 20px; color: var(--muted);
|
| 277 |
+
}
|
| 278 |
+
.no-results h3 { font-size: 1.1rem; color: var(--text); margin-bottom: 8px; }
|
| 279 |
+
.no-results p { font-size: .9rem; }
|
| 280 |
+
|
| 281 |
+
.results-info {
|
| 282 |
+
font-size: .82rem; color: var(--muted); margin-bottom: 16px;
|
| 283 |
+
display: flex; align-items: center; gap: 8px;
|
| 284 |
+
}
|
| 285 |
+
.results-info strong { color: var(--text); }
|
| 286 |
+
.results-info .clear-link {
|
| 287 |
+
color: var(--primary-mid); cursor: pointer; text-decoration: underline;
|
| 288 |
+
font-size: .8rem; margin-left: 4px;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/* searching state */
|
| 292 |
+
.search-panel.searching .search-input { border-color: var(--primary-mid); }
|
| 293 |
+
</style>
|
| 294 |
+
</head>
|
| 295 |
+
<body>
|
| 296 |
+
|
| 297 |
+
<div class="header">
|
| 298 |
+
<a href="/" class="header-brand">
|
| 299 |
+
<img src="/static/logo.svg" alt="Logo" class="header-logo">
|
| 300 |
+
<div>
|
| 301 |
+
<div class="header-title">Scripture Detector</div>
|
| 302 |
+
<div class="header-subtitle">Scriptural Quote Detection & Analysis</div>
|
| 303 |
+
</div>
|
| 304 |
+
</a>
|
| 305 |
+
<nav>
|
| 306 |
+
<a href="/" class="active">Sources</a>
|
| 307 |
+
<a href="/dashboard">Dashboard</a>
|
| 308 |
+
<a href="/about">About</a>
|
| 309 |
+
<a href="/settings">Settings</a>
|
| 310 |
+
</nav>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<div class="container">
|
| 314 |
+
|
| 315 |
+
<!-- Toolbar -->
|
| 316 |
+
<div class="toolbar" id="toolbar">
|
| 317 |
+
<button class="btn btn-primary" onclick="openModal()">
|
| 318 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 319 |
+
Add Source
|
| 320 |
+
</button>
|
| 321 |
+
<button class="btn btn-ghost" onclick="importZip()" title="Import sources from a ZIP archive exported by Scripture Detector">
|
| 322 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
| 323 |
+
Import ZIP
|
| 324 |
+
</button>
|
| 325 |
+
<a class="btn btn-ghost" id="export-zip-btn" href="/api/export/zip" download
|
| 326 |
+
title="Export all sources as TEI XML inside a ZIP archive">
|
| 327 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
| 328 |
+
Export ZIP
|
| 329 |
+
</a>
|
| 330 |
+
<!-- hidden file input for ZIP import -->
|
| 331 |
+
<input type="file" id="zip-file-input" accept=".zip" style="display:none" onchange="handleZipUpload(this)">
|
| 332 |
+
<div class="summary" id="summary-line"></div>
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
<!-- Search panel -->
|
| 336 |
+
<div class="search-panel" id="search-panel">
|
| 337 |
+
<div class="search-top">
|
| 338 |
+
<!-- Simple text search -->
|
| 339 |
+
<div class="search-input-wrap">
|
| 340 |
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 341 |
+
<input id="text-search" class="search-input" type="text"
|
| 342 |
+
placeholder="Search source text content…" autocomplete="off"
|
| 343 |
+
oninput="onTextInput()" onkeydown="if(event.key==='Escape')clearText()">
|
| 344 |
+
<button class="search-clear" id="text-clear" onclick="clearText()" title="Clear">×</button>
|
| 345 |
+
</div>
|
| 346 |
+
<!-- Advanced toggle -->
|
| 347 |
+
<button class="adv-toggle" id="adv-toggle" onclick="toggleAdv()">
|
| 348 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="11" y1="18" x2="13" y2="18"/></svg>
|
| 349 |
+
Advanced
|
| 350 |
+
<span class="badge" id="adv-badge">0</span>
|
| 351 |
+
</button>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<!-- Advanced filter builder -->
|
| 355 |
+
<div class="adv-panel" id="adv-panel">
|
| 356 |
+
<div class="adv-header">
|
| 357 |
+
<label>Combine filters with:</label>
|
| 358 |
+
<div class="logic-toggle">
|
| 359 |
+
<button class="logic-btn active" id="logic-and" onclick="setLogic('AND')">AND</button>
|
| 360 |
+
<button class="logic-btn" id="logic-or" onclick="setLogic('OR')">OR</button>
|
| 361 |
+
</div>
|
| 362 |
+
<span style="font-size:.78rem;color:var(--muted)" id="logic-desc">
|
| 363 |
+
Source must match <strong>all</strong> filters
|
| 364 |
+
</span>
|
| 365 |
+
</div>
|
| 366 |
+
<div class="filter-rows" id="filter-rows"></div>
|
| 367 |
+
<button class="add-filter-btn" onclick="addFilterRow()">
|
| 368 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 369 |
+
Add Filter
|
| 370 |
+
</button>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
|
| 374 |
+
<!-- Active filter chips (shown when filters are active) -->
|
| 375 |
+
<div class="active-chips" id="active-chips"></div>
|
| 376 |
+
|
| 377 |
+
<!-- Results info bar (shown during search) -->
|
| 378 |
+
<div class="results-info" id="results-info" style="display:none"></div>
|
| 379 |
+
|
| 380 |
+
<!-- Source grid -->
|
| 381 |
+
<div id="source-grid-wrap">
|
| 382 |
+
<div style="text-align:center;padding:60px;color:var(--muted)">
|
| 383 |
+
<div class="spinner" style="margin:0 auto 12px"></div>Loading…
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<!-- Add source modal -->
|
| 390 |
+
<div class="modal-overlay" id="add-modal">
|
| 391 |
+
<div class="modal">
|
| 392 |
+
<h2>Add New Source</h2>
|
| 393 |
+
<label for="src-name">Source Name</label>
|
| 394 |
+
<input type="text" id="src-name" placeholder="e.g. Augustine, Confessions Book I">
|
| 395 |
+
<label for="src-text">Text</label>
|
| 396 |
+
<textarea id="src-text" placeholder="Paste the text to analyze here…"></textarea>
|
| 397 |
+
<div class="actions">
|
| 398 |
+
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
| 399 |
+
<button class="btn btn-primary" id="save-src-btn" onclick="saveSource()">Add Source</button>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
</div>
|
| 403 |
+
|
| 404 |
+
<script>
|
| 405 |
+
// ── State ────────────────────────────────────────────────────────────────────
|
| 406 |
+
let RAW = null; // full dashboard data
|
| 407 |
+
let bibleBooks = null; // [{code, name, testament}]
|
| 408 |
+
let advOpen = false;
|
| 409 |
+
let currentLogic = 'AND';
|
| 410 |
+
let filterIdSeq = 0;
|
| 411 |
+
let filters = []; // [{id, type, value, book, chapter}]
|
| 412 |
+
let searchTimer = null;
|
| 413 |
+
let isSearching = false;
|
| 414 |
+
|
| 415 |
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
| 416 |
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
| 417 |
+
function showToast(msg, ok) {
|
| 418 |
+
const el = document.createElement('div');
|
| 419 |
+
el.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
| 420 |
+
el.textContent = msg;
|
| 421 |
+
document.body.appendChild(el);
|
| 422 |
+
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 400); }, 3500);
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// ── Modal ────────────────────────────────────────────────────────────────────
|
| 426 |
+
const addModal = document.getElementById('add-modal');
|
| 427 |
+
function openModal() { addModal.classList.add('active'); document.getElementById('src-name').focus(); }
|
| 428 |
+
function closeModal() { addModal.classList.remove('active'); }
|
| 429 |
+
|
| 430 |
+
function saveSource() {
|
| 431 |
+
const name = document.getElementById('src-name').value.trim();
|
| 432 |
+
const text = document.getElementById('src-text').value.trim();
|
| 433 |
+
if (!name || !text) { showToast('Name and text are required', false); return; }
|
| 434 |
+
const btn = document.getElementById('save-src-btn');
|
| 435 |
+
btn.disabled = true; btn.textContent = 'Saving…';
|
| 436 |
+
fetch('/api/sources', {
|
| 437 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 438 |
+
body: JSON.stringify({name, text}),
|
| 439 |
+
})
|
| 440 |
+
.then(r => r.json())
|
| 441 |
+
.then(data => {
|
| 442 |
+
if (data.error) { showToast(data.error, false); return; }
|
| 443 |
+
closeModal();
|
| 444 |
+
document.getElementById('src-name').value = '';
|
| 445 |
+
document.getElementById('src-text').value = '';
|
| 446 |
+
showToast('Source added!', true);
|
| 447 |
+
loadAll();
|
| 448 |
+
})
|
| 449 |
+
.catch(e => showToast('Error: ' + e.message, false))
|
| 450 |
+
.finally(() => { btn.disabled = false; btn.textContent = 'Add Source'; });
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
function deleteSource(e, id, name) {
|
| 454 |
+
e.stopPropagation();
|
| 455 |
+
if (!confirm(`Delete "${name}" and all its quotes?`)) return;
|
| 456 |
+
fetch(`/api/sources/${id}`, {method: 'DELETE'})
|
| 457 |
+
.then(() => { showToast('Source deleted', true); loadAll(); })
|
| 458 |
+
.catch(e => showToast('Error: ' + e.message, false));
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
// ── Text search ──────────────────────────────────────────────────────────────
|
| 462 |
+
function onTextInput() {
|
| 463 |
+
const val = document.getElementById('text-search').value;
|
| 464 |
+
document.getElementById('text-clear').style.display = val ? 'block' : 'none';
|
| 465 |
+
document.getElementById('text-search').classList.toggle('has-value', !!val);
|
| 466 |
+
scheduleSearch();
|
| 467 |
+
}
|
| 468 |
+
function clearText() {
|
| 469 |
+
document.getElementById('text-search').value = '';
|
| 470 |
+
document.getElementById('text-clear').style.display = 'none';
|
| 471 |
+
document.getElementById('text-search').classList.remove('has-value');
|
| 472 |
+
scheduleSearch();
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
// ── Advanced toggle ──────────────────────────────────────────────────────────
|
| 476 |
+
function toggleAdv() {
|
| 477 |
+
advOpen = !advOpen;
|
| 478 |
+
document.getElementById('adv-panel').classList.toggle('visible', advOpen);
|
| 479 |
+
document.getElementById('adv-toggle').classList.toggle('active', advOpen);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
function setLogic(l) {
|
| 483 |
+
currentLogic = l;
|
| 484 |
+
document.getElementById('logic-and').classList.toggle('active', l === 'AND');
|
| 485 |
+
document.getElementById('logic-or').classList.toggle('active', l === 'OR');
|
| 486 |
+
document.getElementById('logic-desc').innerHTML =
|
| 487 |
+
l === 'AND'
|
| 488 |
+
? 'Source must match <strong>all</strong> filters'
|
| 489 |
+
: 'Source must match <strong>any</strong> filter';
|
| 490 |
+
renderFilterRows(); // update connector labels between rows
|
| 491 |
+
scheduleSearch();
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
// ── Filter rows ──────────────────────────────────────────────────────────────
|
| 495 |
+
function loadBibleBooks() {
|
| 496 |
+
if (bibleBooks) return Promise.resolve();
|
| 497 |
+
return fetch('/api/bible/books').then(r => r.json()).then(books => {
|
| 498 |
+
bibleBooks = books;
|
| 499 |
+
});
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
function addFilterRow(preset) {
|
| 503 |
+
loadBibleBooks().then(() => {
|
| 504 |
+
const id = ++filterIdSeq;
|
| 505 |
+
const type = preset?.type || 'book';
|
| 506 |
+
filters.push({ id, type, value: preset?.value || '', book: '', chapter: '' });
|
| 507 |
+
renderFilterRows();
|
| 508 |
+
// If preset, update UI after render
|
| 509 |
+
if (preset) applyPreset(id, preset);
|
| 510 |
+
scheduleSearch();
|
| 511 |
+
});
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
function applyPreset(id, preset) {
|
| 515 |
+
const row = document.querySelector(`.filter-row[data-id="${id}"]`);
|
| 516 |
+
if (!row) return;
|
| 517 |
+
if (preset.book) {
|
| 518 |
+
const bSel = row.querySelector('.filter-book');
|
| 519 |
+
if (bSel) { bSel.value = preset.book; onBookChange(id); }
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
function removeFilterRow(id) {
|
| 524 |
+
filters = filters.filter(f => f.id !== id);
|
| 525 |
+
renderFilterRows();
|
| 526 |
+
updateAdvBadge();
|
| 527 |
+
scheduleSearch();
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
function renderFilterRows() {
|
| 531 |
+
const container = document.getElementById('filter-rows');
|
| 532 |
+
container.innerHTML = '';
|
| 533 |
+
|
| 534 |
+
filters.forEach((f, idx) => {
|
| 535 |
+
const row = document.createElement('div');
|
| 536 |
+
row.className = 'filter-row';
|
| 537 |
+
row.dataset.id = f.id;
|
| 538 |
+
|
| 539 |
+
// connector
|
| 540 |
+
if (idx > 0) {
|
| 541 |
+
const conn = document.createElement('span');
|
| 542 |
+
conn.className = 'filter-connector';
|
| 543 |
+
conn.textContent = currentLogic;
|
| 544 |
+
row.appendChild(conn);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
// type selector
|
| 548 |
+
const typeSel = document.createElement('select');
|
| 549 |
+
typeSel.className = 'filter-select filter-type';
|
| 550 |
+
[
|
| 551 |
+
['book', 'Bible Book'],
|
| 552 |
+
['chapter', 'Chapter'],
|
| 553 |
+
['verse', 'Verse'],
|
| 554 |
+
['text', 'Text Content'],
|
| 555 |
+
].forEach(([val, label]) => {
|
| 556 |
+
const opt = document.createElement('option');
|
| 557 |
+
opt.value = val; opt.textContent = label;
|
| 558 |
+
if (val === f.type) opt.selected = true;
|
| 559 |
+
typeSel.appendChild(opt);
|
| 560 |
+
});
|
| 561 |
+
typeSel.addEventListener('change', () => {
|
| 562 |
+
const fi = filters.find(x => x.id === f.id);
|
| 563 |
+
if (fi) { fi.type = typeSel.value; fi.book = ''; fi.chapter = ''; fi.value = ''; }
|
| 564 |
+
renderFilterRows();
|
| 565 |
+
scheduleSearch();
|
| 566 |
+
});
|
| 567 |
+
row.appendChild(typeSel);
|
| 568 |
+
|
| 569 |
+
if (f.type === 'text') {
|
| 570 |
+
// text input
|
| 571 |
+
const inp = document.createElement('input');
|
| 572 |
+
inp.type = 'text'; inp.className = 'filter-select filter-text';
|
| 573 |
+
inp.placeholder = 'Search text…'; inp.value = f.value;
|
| 574 |
+
inp.addEventListener('input', () => {
|
| 575 |
+
const fi = filters.find(x => x.id === f.id);
|
| 576 |
+
if (fi) fi.value = inp.value;
|
| 577 |
+
scheduleSearch();
|
| 578 |
+
});
|
| 579 |
+
row.appendChild(inp);
|
| 580 |
+
} else {
|
| 581 |
+
// book dropdown
|
| 582 |
+
const bookSel = document.createElement('select');
|
| 583 |
+
bookSel.className = 'filter-select filter-book';
|
| 584 |
+
const defOpt = document.createElement('option');
|
| 585 |
+
defOpt.value = ''; defOpt.textContent = 'Select book…';
|
| 586 |
+
bookSel.appendChild(defOpt);
|
| 587 |
+
(bibleBooks || []).forEach(b => {
|
| 588 |
+
const opt = document.createElement('option');
|
| 589 |
+
opt.value = b.code;
|
| 590 |
+
opt.textContent = b.name;
|
| 591 |
+
if (b.code === f.book) opt.selected = true;
|
| 592 |
+
bookSel.appendChild(opt);
|
| 593 |
+
});
|
| 594 |
+
bookSel.addEventListener('change', () => {
|
| 595 |
+
const fi = filters.find(x => x.id === f.id);
|
| 596 |
+
if (fi) { fi.book = bookSel.value; fi.chapter = ''; fi.value = fi.book; }
|
| 597 |
+
onBookChange(f.id);
|
| 598 |
+
scheduleSearch();
|
| 599 |
+
});
|
| 600 |
+
row.appendChild(bookSel);
|
| 601 |
+
|
| 602 |
+
if (f.type === 'chapter' || f.type === 'verse') {
|
| 603 |
+
// chapter dropdown
|
| 604 |
+
const chSel = document.createElement('select');
|
| 605 |
+
chSel.className = 'filter-select filter-chapter';
|
| 606 |
+
chSel.disabled = !f.book;
|
| 607 |
+
const chDef = document.createElement('option');
|
| 608 |
+
chDef.value = ''; chDef.textContent = 'Ch.';
|
| 609 |
+
chSel.appendChild(chDef);
|
| 610 |
+
chSel.dataset.book = f.book;
|
| 611 |
+
chSel.addEventListener('change', () => {
|
| 612 |
+
const fi = filters.find(x => x.id === f.id);
|
| 613 |
+
if (fi) {
|
| 614 |
+
fi.chapter = chSel.value;
|
| 615 |
+
fi.value = fi.chapter ? `${fi.book}_${fi.chapter}` : fi.book;
|
| 616 |
+
}
|
| 617 |
+
if (f.type === 'verse') onChapterChange(f.id);
|
| 618 |
+
scheduleSearch();
|
| 619 |
+
});
|
| 620 |
+
row.appendChild(chSel);
|
| 621 |
+
row.dataset.chSel = true;
|
| 622 |
+
|
| 623 |
+
// load chapters if book already selected
|
| 624 |
+
if (f.book) {
|
| 625 |
+
fetch(`/api/bible/${f.book}/chapters`).then(r => r.json()).then(chs => {
|
| 626 |
+
chs.forEach(ch => {
|
| 627 |
+
const opt = document.createElement('option');
|
| 628 |
+
opt.value = ch; opt.textContent = ch;
|
| 629 |
+
if (String(ch) === String(f.chapter)) opt.selected = true;
|
| 630 |
+
chSel.appendChild(opt);
|
| 631 |
+
});
|
| 632 |
+
chSel.disabled = false;
|
| 633 |
+
});
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
if (f.type === 'verse') {
|
| 637 |
+
// verse number input
|
| 638 |
+
const vInp = document.createElement('input');
|
| 639 |
+
vInp.type = 'number'; vInp.min = '1';
|
| 640 |
+
vInp.className = 'filter-select filter-verse';
|
| 641 |
+
vInp.placeholder = 'Verse #';
|
| 642 |
+
vInp.disabled = !f.chapter;
|
| 643 |
+
if (f.verse) vInp.value = f.verse;
|
| 644 |
+
vInp.addEventListener('input', () => {
|
| 645 |
+
const fi = filters.find(x => x.id === f.id);
|
| 646 |
+
if (fi) {
|
| 647 |
+
fi.verse = vInp.value;
|
| 648 |
+
fi.value = fi.chapter && vInp.value
|
| 649 |
+
? `${fi.book}_${fi.chapter}:${vInp.value}`
|
| 650 |
+
: (fi.chapter ? `${fi.book}_${fi.chapter}` : fi.book);
|
| 651 |
+
}
|
| 652 |
+
scheduleSearch();
|
| 653 |
+
});
|
| 654 |
+
row.appendChild(vInp);
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
// remove button
|
| 660 |
+
const rem = document.createElement('button');
|
| 661 |
+
rem.className = 'filter-remove'; rem.title = 'Remove filter';
|
| 662 |
+
rem.innerHTML = '×';
|
| 663 |
+
rem.onclick = () => removeFilterRow(f.id);
|
| 664 |
+
row.appendChild(rem);
|
| 665 |
+
container.appendChild(row);
|
| 666 |
+
});
|
| 667 |
+
|
| 668 |
+
updateAdvBadge();
|
| 669 |
+
renderChips();
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
function onBookChange(fid) {
|
| 673 |
+
const fi = filters.find(x => x.id === fid);
|
| 674 |
+
if (!fi) return;
|
| 675 |
+
fi.chapter = ''; fi.verse = '';
|
| 676 |
+
fi.value = fi.book;
|
| 677 |
+
const row = document.querySelector(`.filter-row[data-id="${fid}"]`);
|
| 678 |
+
if (!row) return;
|
| 679 |
+
const chSel = row.querySelector('.filter-chapter');
|
| 680 |
+
const vInp = row.querySelector('.filter-verse');
|
| 681 |
+
if (chSel) {
|
| 682 |
+
chSel.innerHTML = '<option value="">Ch.</option>';
|
| 683 |
+
chSel.disabled = !fi.book;
|
| 684 |
+
if (fi.book) {
|
| 685 |
+
fetch(`/api/bible/${fi.book}/chapters`).then(r => r.json()).then(chs => {
|
| 686 |
+
chs.forEach(ch => {
|
| 687 |
+
const opt = document.createElement('option');
|
| 688 |
+
opt.value = ch; opt.textContent = ch;
|
| 689 |
+
chSel.appendChild(opt);
|
| 690 |
+
});
|
| 691 |
+
});
|
| 692 |
+
}
|
| 693 |
+
}
|
| 694 |
+
if (vInp) { vInp.value = ''; vInp.disabled = true; }
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
function onChapterChange(fid) {
|
| 698 |
+
const fi = filters.find(x => x.id === fid);
|
| 699 |
+
if (!fi) return;
|
| 700 |
+
const row = document.querySelector(`.filter-row[data-id="${fid}"]`);
|
| 701 |
+
if (!row) return;
|
| 702 |
+
const vInp = row.querySelector('.filter-verse');
|
| 703 |
+
if (vInp) { vInp.value = ''; vInp.disabled = !fi.chapter; }
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
function updateAdvBadge() {
|
| 707 |
+
const active = filters.filter(f => f.value).length;
|
| 708 |
+
const badge = document.getElementById('adv-badge');
|
| 709 |
+
badge.textContent = active;
|
| 710 |
+
document.getElementById('adv-toggle').classList.toggle('has-filters', active > 0);
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
// ── Chip strip (active filter summary) ──────────────────────────────────────
|
| 714 |
+
function renderChips() {
|
| 715 |
+
const container = document.getElementById('active-chips');
|
| 716 |
+
const textVal = document.getElementById('text-search').value.trim();
|
| 717 |
+
const activeFilters = filters.filter(f => f.value);
|
| 718 |
+
container.innerHTML = '';
|
| 719 |
+
|
| 720 |
+
if (textVal) {
|
| 721 |
+
const chip = makeChip(`Text: "${textVal}"`, clearText);
|
| 722 |
+
container.appendChild(chip);
|
| 723 |
+
}
|
| 724 |
+
activeFilters.forEach(f => {
|
| 725 |
+
let label = '';
|
| 726 |
+
if (f.type === 'book') label = `Book: ${bookName(f.book)}`;
|
| 727 |
+
else if (f.type === 'chapter') label = `Chapter: ${bookName(f.book)} ${f.chapter}`;
|
| 728 |
+
else if (f.type === 'verse') label = `Verse: ${f.value}`;
|
| 729 |
+
else if (f.type === 'text') label = `Text: "${f.value}"`;
|
| 730 |
+
if (label) {
|
| 731 |
+
const chip = makeChip(label, () => removeFilterRow(f.id));
|
| 732 |
+
container.appendChild(chip);
|
| 733 |
+
}
|
| 734 |
+
});
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
function makeChip(label, onRemove) {
|
| 738 |
+
const chip = document.createElement('span');
|
| 739 |
+
chip.className = 'chip';
|
| 740 |
+
chip.innerHTML = `${esc(label)} <button class="chip-x" title="Remove">×</button>`;
|
| 741 |
+
chip.querySelector('.chip-x').onclick = onRemove;
|
| 742 |
+
return chip;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
function bookName(code) {
|
| 746 |
+
if (!bibleBooks) return code;
|
| 747 |
+
const b = bibleBooks.find(x => x.code === code);
|
| 748 |
+
return b ? b.name : code;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
// ── Search scheduling ────────────────────────────────────────────────────────
|
| 752 |
+
function scheduleSearch() {
|
| 753 |
+
clearTimeout(searchTimer);
|
| 754 |
+
renderChips();
|
| 755 |
+
searchTimer = setTimeout(executeSearch, 320);
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
function executeSearch() {
|
| 759 |
+
const textVal = document.getElementById('text-search').value.trim();
|
| 760 |
+
const structFilters = filters.filter(f => f.value.trim());
|
| 761 |
+
|
| 762 |
+
// If nothing active, show all sources from RAW
|
| 763 |
+
if (!textVal && structFilters.length === 0) {
|
| 764 |
+
renderSources(null);
|
| 765 |
+
return;
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
const allFilters = [];
|
| 769 |
+
if (textVal) allFilters.push({ type: 'text', value: textVal });
|
| 770 |
+
structFilters.forEach(f => allFilters.push({ type: f.type, value: f.value }));
|
| 771 |
+
|
| 772 |
+
isSearching = true;
|
| 773 |
+
document.getElementById('search-panel').classList.add('searching');
|
| 774 |
+
|
| 775 |
+
fetch('/api/search', {
|
| 776 |
+
method: 'POST',
|
| 777 |
+
headers: { 'Content-Type': 'application/json' },
|
| 778 |
+
body: JSON.stringify({ filters: allFilters, logic: currentLogic }),
|
| 779 |
+
})
|
| 780 |
+
.then(r => r.json())
|
| 781 |
+
.then(data => {
|
| 782 |
+
isSearching = false;
|
| 783 |
+
document.getElementById('search-panel').classList.remove('searching');
|
| 784 |
+
renderSources(data);
|
| 785 |
+
})
|
| 786 |
+
.catch(e => {
|
| 787 |
+
isSearching = false;
|
| 788 |
+
document.getElementById('search-panel').classList.remove('searching');
|
| 789 |
+
showToast('Search error: ' + e.message, false);
|
| 790 |
+
});
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
// ── Rendering ──��─────────────────────────────────────────────────────────────
|
| 794 |
+
function renderSources(searchResult) {
|
| 795 |
+
const wrap = document.getElementById('source-grid-wrap');
|
| 796 |
+
const infoBar = document.getElementById('results-info');
|
| 797 |
+
|
| 798 |
+
// Determine what to show
|
| 799 |
+
let sources, isFiltered, totalCount;
|
| 800 |
+
if (searchResult === null) {
|
| 801 |
+
// Show all from dashboard
|
| 802 |
+
if (!RAW) return;
|
| 803 |
+
sources = RAW.sources.map(s => ({
|
| 804 |
+
...s,
|
| 805 |
+
type_distribution: s.type_distribution || {},
|
| 806 |
+
book_distribution: s.book_distribution || [],
|
| 807 |
+
match_evidence: null,
|
| 808 |
+
}));
|
| 809 |
+
isFiltered = false;
|
| 810 |
+
totalCount = RAW.source_count;
|
| 811 |
+
|
| 812 |
+
infoBar.style.display = 'none';
|
| 813 |
+
document.getElementById('summary-line').innerHTML =
|
| 814 |
+
`<strong>${RAW.source_count}</strong> source${RAW.source_count !== 1 ? 's' : ''}
|
| 815 |
+
·
|
| 816 |
+
<strong>${RAW.quote_count}</strong> quote${RAW.quote_count !== 1 ? 's' : ''}
|
| 817 |
+
·
|
| 818 |
+
<strong>${RAW.reference_count}</strong> reference${RAW.reference_count !== 1 ? 's' : ''}`;
|
| 819 |
+
} else {
|
| 820 |
+
sources = searchResult.results;
|
| 821 |
+
isFiltered = true;
|
| 822 |
+
totalCount = RAW ? RAW.source_count : '?';
|
| 823 |
+
|
| 824 |
+
const n = searchResult.total;
|
| 825 |
+
infoBar.style.display = 'flex';
|
| 826 |
+
infoBar.innerHTML = `
|
| 827 |
+
Showing <strong>${n}</strong> of <strong>${totalCount}</strong> source${totalCount !== 1 ? 's' : ''}
|
| 828 |
+
matching your ${currentLogic === 'AND' ? 'AND' : 'OR'} filters
|
| 829 |
+
<span class="clear-link" onclick="clearAllFilters()">Clear all filters</span>`;
|
| 830 |
+
document.getElementById('summary-line').innerHTML =
|
| 831 |
+
`<strong>${n}</strong> result${n !== 1 ? 's' : ''}`;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
// Empty state
|
| 835 |
+
if (sources.length === 0 && !isFiltered) {
|
| 836 |
+
wrap.innerHTML = `
|
| 837 |
+
<div class="empty-state">
|
| 838 |
+
<img src="/static/logo.svg" alt="" class="empty-icon">
|
| 839 |
+
<h2>No sources yet</h2>
|
| 840 |
+
<p>Add a text source to get started with scripture detection and analysis.</p>
|
| 841 |
+
<button class="btn btn-primary btn-lg" onclick="openModal()">
|
| 842 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 843 |
+
Add Your First Source
|
| 844 |
+
</button>
|
| 845 |
+
</div>`;
|
| 846 |
+
return;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
const grid = document.createElement('div');
|
| 850 |
+
grid.className = 'source-grid';
|
| 851 |
+
|
| 852 |
+
if (isFiltered && sources.length === 0) {
|
| 853 |
+
grid.innerHTML = `
|
| 854 |
+
<div class="no-results">
|
| 855 |
+
<h3>No matching sources</h3>
|
| 856 |
+
<p>Try adjusting your filters or switching from AND to OR logic.</p>
|
| 857 |
+
</div>`;
|
| 858 |
+
wrap.innerHTML = ''; wrap.appendChild(grid); return;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
sources.forEach(s => {
|
| 862 |
+
const td = s.type_distribution || {};
|
| 863 |
+
const bd = s.book_distribution || [];
|
| 864 |
+
const total = (td.full||0) + (td.partial||0) + (td.paraphrase||0) + (td.allusion||0);
|
| 865 |
+
const p = v => total ? ((v / total) * 100).toFixed(0) : 0;
|
| 866 |
+
const topBooks = bd.slice(0, 4).map(b => b.book_name || b.book_code);
|
| 867 |
+
const booksText = topBooks.length > 0
|
| 868 |
+
? topBooks.join(', ') + (bd.length > 4 ? ` +${bd.length - 4} more` : '')
|
| 869 |
+
: 'No books referenced yet';
|
| 870 |
+
|
| 871 |
+
const card = document.createElement('div');
|
| 872 |
+
card.className = 'source-card' + (isFiltered ? ' search-match' : '');
|
| 873 |
+
card.onclick = () => window.location.href = '/viewer/' + s.id;
|
| 874 |
+
|
| 875 |
+
// Match evidence HTML
|
| 876 |
+
let evidenceHtml = '';
|
| 877 |
+
if (s.match_evidence && s.match_evidence.length > 0) {
|
| 878 |
+
const items = s.match_evidence.map(ev => {
|
| 879 |
+
if (ev.kind === 'text') {
|
| 880 |
+
if (ev.offset < 0) {
|
| 881 |
+
// Name-only match
|
| 882 |
+
return `<div class="match-item">
|
| 883 |
+
<div class="match-text-snippet">
|
| 884 |
+
Matched source name: <mark>${esc(ev.query)}</mark>
|
| 885 |
+
</div>
|
| 886 |
+
</div>`;
|
| 887 |
+
}
|
| 888 |
+
const hl = highlightMatch(ev.snippet, ev.query);
|
| 889 |
+
const pos = ev.offset > 0 ? `~${Math.round(ev.offset / 100) * 100} chars in` : 'start of text';
|
| 890 |
+
return `<div class="match-item">
|
| 891 |
+
<div class="match-text-snippet">${hl}</div>
|
| 892 |
+
<div class="match-loc">Found at ${pos}</div>
|
| 893 |
+
</div>`;
|
| 894 |
+
} else {
|
| 895 |
+
const qt = `<span class="type-badge type-${esc(ev.quote_type)}">${esc(ev.quote_type)}</span>`;
|
| 896 |
+
return `<div class="match-item">
|
| 897 |
+
<div class="match-ref-row">
|
| 898 |
+
<span class="match-ref-badge">${esc(ev.reference)}</span>
|
| 899 |
+
${qt}
|
| 900 |
+
<span class="match-ref-quote">"${esc(ev.quote_text)}"</span>
|
| 901 |
+
</div>
|
| 902 |
+
</div>`;
|
| 903 |
+
}
|
| 904 |
+
}).join('');
|
| 905 |
+
evidenceHtml = `
|
| 906 |
+
<div class="match-evidence">
|
| 907 |
+
<div class="match-evidence-header">
|
| 908 |
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
| 909 |
+
Match Evidence
|
| 910 |
+
</div>
|
| 911 |
+
${items}
|
| 912 |
+
</div>`;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
card.innerHTML = `
|
| 916 |
+
<div class="sc-header">
|
| 917 |
+
<div class="sc-name">${esc(s.name)}</div>
|
| 918 |
+
<div class="sc-date">${new Date(s.created_at).toLocaleDateString()}</div>
|
| 919 |
+
</div>
|
| 920 |
+
<div class="sc-stats">
|
| 921 |
+
<div class="sc-stat sc-stat-primary">
|
| 922 |
+
<div class="sc-stat-val">${s.quote_count}</div>
|
| 923 |
+
<div class="sc-stat-lbl">Quotes</div>
|
| 924 |
+
</div>
|
| 925 |
+
<div class="sc-stat sc-stat-accent">
|
| 926 |
+
<div class="sc-stat-val">${bd.length}</div>
|
| 927 |
+
<div class="sc-stat-lbl">Books</div>
|
| 928 |
+
</div>
|
| 929 |
+
</div>
|
| 930 |
+
${total > 0 ? `
|
| 931 |
+
<div>
|
| 932 |
+
<div class="type-bar">
|
| 933 |
+
${td.full ? `<div style="width:${p(td.full)}%;background:var(--full)"></div>` : ''}
|
| 934 |
+
${td.partial ? `<div style="width:${p(td.partial)}%;background:var(--partial)"></div>` : ''}
|
| 935 |
+
${td.paraphrase? `<div style="width:${p(td.paraphrase)}%;background:var(--paraphrase)"></div>` : ''}
|
| 936 |
+
${td.allusion ? `<div style="width:${p(td.allusion)}%;background:var(--allusion)"></div>` : ''}
|
| 937 |
+
</div>
|
| 938 |
+
<div class="type-legend">
|
| 939 |
+
${td.full ? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--full)"></div>${td.full} Full</div>` : ''}
|
| 940 |
+
${td.partial ? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--partial)"></div>${td.partial} Partial</div>` : ''}
|
| 941 |
+
${td.paraphrase? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--paraphrase)"></div>${td.paraphrase} Paraphrase</div>` : ''}
|
| 942 |
+
${td.allusion ? `<div class="type-legend-item"><div class="type-legend-swatch" style="background:var(--allusion)"></div>${td.allusion} Allusion</div>` : ''}
|
| 943 |
+
</div>
|
| 944 |
+
</div>` : '<div style="font-size:.78rem;color:var(--muted);font-style:italic">Not yet processed</div>'}
|
| 945 |
+
${evidenceHtml || `<div class="sc-books">${esc(booksText)}</div>`}
|
| 946 |
+
<div class="sc-footer">
|
| 947 |
+
<button class="btn btn-primary btn-sm"
|
| 948 |
+
onclick="event.stopPropagation();window.location.href='/viewer/${s.id}'">
|
| 949 |
+
View
|
| 950 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
| 951 |
+
</button>
|
| 952 |
+
<div style="flex:1"></div>
|
| 953 |
+
<button class="btn btn-ghost btn-sm" style="color:var(--red)"
|
| 954 |
+
onclick="deleteSource(event,${s.id},'${esc(s.name).replace(/'/g,"\\'")}')">Delete</button>
|
| 955 |
+
</div>`;
|
| 956 |
+
grid.appendChild(card);
|
| 957 |
+
});
|
| 958 |
+
|
| 959 |
+
wrap.innerHTML = ''; wrap.appendChild(grid);
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
function highlightMatch(snippet, query) {
|
| 963 |
+
if (!query) return esc(snippet);
|
| 964 |
+
// esc() the snippet first, then highlight the query within the escaped HTML
|
| 965 |
+
const escaped = esc(snippet);
|
| 966 |
+
const qEsc = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
| 967 |
+
// The query arrives lowercased from the API; match case-insensitively
|
| 968 |
+
return escaped.replace(new RegExp(qEsc, 'gi'), m => `<mark>${m}</mark>`);
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
function clearAllFilters() {
|
| 972 |
+
clearText();
|
| 973 |
+
filters = [];
|
| 974 |
+
renderFilterRows();
|
| 975 |
+
if (advOpen) {
|
| 976 |
+
document.getElementById('adv-panel').classList.remove('visible');
|
| 977 |
+
document.getElementById('adv-toggle').classList.remove('active');
|
| 978 |
+
advOpen = false;
|
| 979 |
+
}
|
| 980 |
+
renderSources(null);
|
| 981 |
+
document.getElementById('active-chips').innerHTML = '';
|
| 982 |
+
document.getElementById('results-info').style.display = 'none';
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
// ── ZIP Import ───────────────────────────────────────────────────────────────
|
| 986 |
+
function importZip() {
|
| 987 |
+
document.getElementById('zip-file-input').click();
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
function handleZipUpload(input) {
|
| 991 |
+
const file = input.files[0];
|
| 992 |
+
if (!file) return;
|
| 993 |
+
input.value = ''; // reset so same file can be re-selected
|
| 994 |
+
|
| 995 |
+
const formData = new FormData();
|
| 996 |
+
formData.append('file', file);
|
| 997 |
+
|
| 998 |
+
// Show a toast while importing
|
| 999 |
+
const prog = document.createElement('div');
|
| 1000 |
+
prog.className = 'toast toast-ok';
|
| 1001 |
+
prog.style.opacity = '1';
|
| 1002 |
+
prog.textContent = `Importing ${file.name}…`;
|
| 1003 |
+
document.body.appendChild(prog);
|
| 1004 |
+
|
| 1005 |
+
fetch('/api/import/zip', { method: 'POST', body: formData })
|
| 1006 |
+
.then(r => r.json())
|
| 1007 |
+
.then(data => {
|
| 1008 |
+
prog.remove();
|
| 1009 |
+
if (data.error) { showToast(data.error, false); return; }
|
| 1010 |
+
const errMsg = data.errors && data.errors.length
|
| 1011 |
+
? ` (${data.errors.length} file${data.errors.length !== 1 ? 's' : ''} failed)`
|
| 1012 |
+
: '';
|
| 1013 |
+
showToast(`Imported ${data.imported} source${data.imported !== 1 ? 's' : ''}${errMsg}`, true);
|
| 1014 |
+
loadAll();
|
| 1015 |
+
})
|
| 1016 |
+
.catch(e => { prog.remove(); showToast('Import error: ' + e.message, false); });
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
// ── Initial load ────────────────────���────────────────────────────────────────
|
| 1020 |
+
function loadAll() {
|
| 1021 |
+
// /api/dashboard already includes per-source type_distribution & book_distribution
|
| 1022 |
+
fetch('/api/dashboard')
|
| 1023 |
+
.then(r => r.json())
|
| 1024 |
+
.then(data => { RAW = data; renderSources(null); });
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
addModal.addEventListener('click', e => { if (e.target === addModal) closeModal(); });
|
| 1028 |
+
loadAll();
|
| 1029 |
+
</script>
|
| 1030 |
+
</body>
|
| 1031 |
+
</html>
|
templates/viewer.html
ADDED
|
@@ -0,0 +1,865 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Scripture Detector — {{ source.name }}</title>
|
| 7 |
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
| 8 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
| 10 |
+
<style>
|
| 11 |
+
.header { position: sticky; top: 0; }
|
| 12 |
+
|
| 13 |
+
.action-bar {
|
| 14 |
+
display: flex;
|
| 15 |
+
gap: 10px;
|
| 16 |
+
padding: 12px 24px;
|
| 17 |
+
background: var(--surface);
|
| 18 |
+
border-bottom: 1px solid var(--border-light);
|
| 19 |
+
align-items: center;
|
| 20 |
+
flex-wrap: wrap;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.select-styled {
|
| 24 |
+
padding: 7px 12px;
|
| 25 |
+
border-radius: var(--radius-sm);
|
| 26 |
+
border: 1.5px solid var(--border);
|
| 27 |
+
background: var(--surface);
|
| 28 |
+
font-size: .82rem;
|
| 29 |
+
color: var(--text);
|
| 30 |
+
font-family: inherit;
|
| 31 |
+
transition: var(--transition);
|
| 32 |
+
}
|
| 33 |
+
.select-styled:focus {
|
| 34 |
+
outline: none;
|
| 35 |
+
border-color: var(--primary);
|
| 36 |
+
box-shadow: 0 0 0 3px var(--primary-subtle);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.tabs {
|
| 40 |
+
display: flex;
|
| 41 |
+
border-bottom: 2px solid var(--border-light);
|
| 42 |
+
padding: 0 24px;
|
| 43 |
+
background: var(--surface);
|
| 44 |
+
}
|
| 45 |
+
.tab {
|
| 46 |
+
padding: 12px 22px;
|
| 47 |
+
font-size: .84rem;
|
| 48 |
+
font-weight: 600;
|
| 49 |
+
color: var(--muted);
|
| 50 |
+
cursor: pointer;
|
| 51 |
+
border-bottom: 2px solid transparent;
|
| 52 |
+
margin-bottom: -2px;
|
| 53 |
+
transition: var(--transition);
|
| 54 |
+
user-select: none;
|
| 55 |
+
}
|
| 56 |
+
.tab:hover { color: var(--text); }
|
| 57 |
+
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
| 58 |
+
|
| 59 |
+
.tab-content { display: none; }
|
| 60 |
+
.tab-content.active { display: block; }
|
| 61 |
+
|
| 62 |
+
.main { display: grid; grid-template-columns: 1fr 400px; height: calc(100vh - 160px); }
|
| 63 |
+
@media (max-width: 900px) { .main { grid-template-columns: 1fr; } }
|
| 64 |
+
|
| 65 |
+
.text-panel {
|
| 66 |
+
padding: 32px 36px;
|
| 67 |
+
overflow-y: auto;
|
| 68 |
+
line-height: 1.9;
|
| 69 |
+
font-family: 'Palatino Linotype', 'Book Antiqua', Palatino, Georgia, serif;
|
| 70 |
+
font-size: 1.02rem;
|
| 71 |
+
white-space: pre-wrap;
|
| 72 |
+
word-wrap: break-word;
|
| 73 |
+
background: var(--surface);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.seg-full { background: var(--full-bg); border-bottom: 2px solid var(--full);
|
| 77 |
+
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
|
| 78 |
+
.seg-partial { background: var(--partial-bg); border-bottom: 2px solid var(--partial);
|
| 79 |
+
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
|
| 80 |
+
.seg-paraphrase { background: var(--paraphrase-bg); border-bottom: 2px solid var(--paraphrase);
|
| 81 |
+
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
|
| 82 |
+
.seg-allusion { background: var(--allusion-bg); border-bottom: 2px solid var(--allusion);
|
| 83 |
+
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
|
| 84 |
+
.seg-multi { background: rgba(99,102,241,.12); border-bottom: 2px solid var(--primary);
|
| 85 |
+
cursor: pointer; transition: all .15s; border-radius: 2px; padding: 0 1px; }
|
| 86 |
+
[class^="seg-"]:hover { filter: brightness(.92); }
|
| 87 |
+
.seg-active { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 3px; }
|
| 88 |
+
|
| 89 |
+
.ann-panel {
|
| 90 |
+
background: var(--bg);
|
| 91 |
+
border-left: 1px solid var(--border-light);
|
| 92 |
+
overflow-y: auto;
|
| 93 |
+
padding: 20px;
|
| 94 |
+
display: flex;
|
| 95 |
+
flex-direction: column;
|
| 96 |
+
gap: 10px;
|
| 97 |
+
}
|
| 98 |
+
.ann-panel h2 {
|
| 99 |
+
font-size: .78rem;
|
| 100 |
+
text-transform: uppercase;
|
| 101 |
+
letter-spacing: .08em;
|
| 102 |
+
color: var(--muted);
|
| 103 |
+
margin: 8px 0 4px;
|
| 104 |
+
font-weight: 700;
|
| 105 |
+
}
|
| 106 |
+
.ann-card {
|
| 107 |
+
background: var(--surface);
|
| 108 |
+
border-radius: var(--radius);
|
| 109 |
+
border-left: 4px solid var(--border);
|
| 110 |
+
padding: 14px 16px;
|
| 111 |
+
font-size: .83rem;
|
| 112 |
+
box-shadow: var(--shadow-sm);
|
| 113 |
+
cursor: pointer;
|
| 114 |
+
transition: all .15s;
|
| 115 |
+
position: relative;
|
| 116 |
+
border: 1px solid var(--border-light);
|
| 117 |
+
}
|
| 118 |
+
.ann-card:hover { box-shadow: var(--shadow-md); }
|
| 119 |
+
.ann-card.active { box-shadow: 0 0 0 2px var(--primary); }
|
| 120 |
+
.ann-full { border-left: 4px solid var(--full) !important; }
|
| 121 |
+
.ann-partial { border-left: 4px solid var(--partial) !important; }
|
| 122 |
+
.ann-paraphrase { border-left: 4px solid var(--paraphrase) !important; }
|
| 123 |
+
.ann-allusion { border-left: 4px solid var(--allusion) !important; }
|
| 124 |
+
|
| 125 |
+
.ann-refs { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
|
| 126 |
+
.ref-badge {
|
| 127 |
+
display: inline-block;
|
| 128 |
+
padding: 2px 8px;
|
| 129 |
+
border-radius: 5px;
|
| 130 |
+
font-size: .72rem;
|
| 131 |
+
font-weight: 600;
|
| 132 |
+
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
| 133 |
+
background: var(--bg-secondary);
|
| 134 |
+
color: var(--text-secondary);
|
| 135 |
+
border: 1px solid var(--border-light);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.ann-quote {
|
| 139 |
+
color: var(--muted);
|
| 140 |
+
font-style: italic;
|
| 141 |
+
margin-bottom: 8px;
|
| 142 |
+
max-height: 3.6em;
|
| 143 |
+
overflow: hidden;
|
| 144 |
+
line-height: 1.2em;
|
| 145 |
+
font-size: .8rem;
|
| 146 |
+
}
|
| 147 |
+
.ann-verse { border-top: 1px solid var(--border-light); padding-top: 8px; margin-top: 6px; font-size: .78rem; }
|
| 148 |
+
.ann-verse strong {
|
| 149 |
+
color: var(--text-secondary);
|
| 150 |
+
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
| 151 |
+
font-size: .72rem;
|
| 152 |
+
}
|
| 153 |
+
.ann-verse .vtext { color: var(--muted); }
|
| 154 |
+
.ann-actions { display: flex; gap: 6px; margin-top: 10px; }
|
| 155 |
+
|
| 156 |
+
/* Distribution tab */
|
| 157 |
+
.dist-content { max-width: 1000px; margin: 0 auto; padding: 28px; }
|
| 158 |
+
.dist-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
| 159 |
+
@media (max-width: 800px) { .dist-grid { grid-template-columns: 1fr; } }
|
| 160 |
+
.dist-card {
|
| 161 |
+
background: var(--surface);
|
| 162 |
+
border-radius: var(--radius-lg);
|
| 163 |
+
box-shadow: var(--shadow-sm);
|
| 164 |
+
border: 1px solid var(--border-light);
|
| 165 |
+
padding: 24px;
|
| 166 |
+
}
|
| 167 |
+
.dist-card h3 {
|
| 168 |
+
font-size: .8rem;
|
| 169 |
+
font-weight: 700;
|
| 170 |
+
margin-bottom: 16px;
|
| 171 |
+
text-transform: uppercase;
|
| 172 |
+
letter-spacing: .06em;
|
| 173 |
+
color: var(--muted);
|
| 174 |
+
}
|
| 175 |
+
.chart-wrap { position: relative; width: 100%; height: 300px; }
|
| 176 |
+
|
| 177 |
+
/* Selection toolbar */
|
| 178 |
+
.sel-toolbar {
|
| 179 |
+
position: fixed;
|
| 180 |
+
z-index: 200;
|
| 181 |
+
background: var(--surface);
|
| 182 |
+
border-radius: var(--radius);
|
| 183 |
+
box-shadow: var(--shadow-xl);
|
| 184 |
+
padding: 8px;
|
| 185 |
+
display: none;
|
| 186 |
+
gap: 6px;
|
| 187 |
+
border: 1px solid var(--border-light);
|
| 188 |
+
}
|
| 189 |
+
.sel-toolbar.visible { display: flex; }
|
| 190 |
+
|
| 191 |
+
/* Reference tags */
|
| 192 |
+
.ref-tags { display: flex; flex-wrap: wrap; gap: 6px; min-height: 28px; padding: 6px 0; }
|
| 193 |
+
.ref-tag {
|
| 194 |
+
display: inline-flex;
|
| 195 |
+
align-items: center;
|
| 196 |
+
gap: 4px;
|
| 197 |
+
padding: 4px 10px;
|
| 198 |
+
background: var(--primary-light);
|
| 199 |
+
color: #3730a3;
|
| 200 |
+
border-radius: 6px;
|
| 201 |
+
font-size: .78rem;
|
| 202 |
+
font-weight: 600;
|
| 203 |
+
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
| 204 |
+
}
|
| 205 |
+
.ref-tag button {
|
| 206 |
+
background: none;
|
| 207 |
+
border: none;
|
| 208 |
+
cursor: pointer;
|
| 209 |
+
color: #6366f1;
|
| 210 |
+
font-size: .9rem;
|
| 211 |
+
line-height: 1;
|
| 212 |
+
padding: 0 2px;
|
| 213 |
+
}
|
| 214 |
+
.ref-tag button:hover { color: var(--red); }
|
| 215 |
+
|
| 216 |
+
/* Reference picker */
|
| 217 |
+
.ref-picker {
|
| 218 |
+
border: 1.5px solid var(--border);
|
| 219 |
+
border-radius: var(--radius);
|
| 220 |
+
padding: 14px;
|
| 221 |
+
margin-top: 10px;
|
| 222 |
+
background: var(--bg);
|
| 223 |
+
}
|
| 224 |
+
.ref-picker-row { display: flex; gap: 8px; margin-bottom: 10px; }
|
| 225 |
+
.ref-picker-row select {
|
| 226 |
+
flex: 1;
|
| 227 |
+
padding: 8px 12px;
|
| 228 |
+
border-radius: var(--radius-sm);
|
| 229 |
+
border: 1.5px solid var(--border);
|
| 230 |
+
font-size: .84rem;
|
| 231 |
+
background: var(--surface);
|
| 232 |
+
font-family: inherit;
|
| 233 |
+
}
|
| 234 |
+
.ref-picker-row select:disabled { opacity: .4; }
|
| 235 |
+
.ref-verses {
|
| 236 |
+
max-height: 220px;
|
| 237 |
+
overflow-y: auto;
|
| 238 |
+
border: 1px solid var(--border-light);
|
| 239 |
+
border-radius: var(--radius-sm);
|
| 240 |
+
background: var(--surface);
|
| 241 |
+
}
|
| 242 |
+
.ref-verse-item {
|
| 243 |
+
display: flex;
|
| 244 |
+
gap: 8px;
|
| 245 |
+
padding: 8px 12px;
|
| 246 |
+
font-size: .82rem;
|
| 247 |
+
cursor: pointer;
|
| 248 |
+
transition: background .1s;
|
| 249 |
+
align-items: flex-start;
|
| 250 |
+
border-bottom: 1px solid var(--border-light);
|
| 251 |
+
}
|
| 252 |
+
.ref-verse-item:last-child { border-bottom: none; }
|
| 253 |
+
.ref-verse-item:hover { background: var(--primary-subtle); }
|
| 254 |
+
.ref-verse-item.added { background: rgba(22,163,74,.06); }
|
| 255 |
+
.ref-verse-num {
|
| 256 |
+
font-weight: 700;
|
| 257 |
+
color: var(--primary);
|
| 258 |
+
min-width: 24px;
|
| 259 |
+
font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace;
|
| 260 |
+
font-size: .78rem;
|
| 261 |
+
padding-top: 1px;
|
| 262 |
+
}
|
| 263 |
+
.ref-verse-text { color: var(--muted); flex: 1; line-height: 1.4; font-size: .8rem; }
|
| 264 |
+
.ref-verse-add {
|
| 265 |
+
font-size: .7rem;
|
| 266 |
+
font-weight: 700;
|
| 267 |
+
color: var(--green);
|
| 268 |
+
white-space: nowrap;
|
| 269 |
+
padding-top: 1px;
|
| 270 |
+
}
|
| 271 |
+
.ref-verse-item.added .ref-verse-add { color: var(--muted); }
|
| 272 |
+
.ref-no-verses { padding: 20px; text-align: center; color: var(--muted); font-size: .84rem; }
|
| 273 |
+
|
| 274 |
+
.legend-bar {
|
| 275 |
+
display: flex;
|
| 276 |
+
gap: 16px;
|
| 277 |
+
font-size: .74rem;
|
| 278 |
+
color: var(--muted);
|
| 279 |
+
align-items: center;
|
| 280 |
+
flex-wrap: wrap;
|
| 281 |
+
}
|
| 282 |
+
.legend-item { display: flex; align-items: center; gap: 5px; }
|
| 283 |
+
.legend-swatch { width: 22px; height: 8px; border-radius: 4px; }
|
| 284 |
+
</style>
|
| 285 |
+
</head>
|
| 286 |
+
<body>
|
| 287 |
+
|
| 288 |
+
<div class="header">
|
| 289 |
+
<a href="/" class="header-brand">
|
| 290 |
+
<img src="/static/logo.svg" alt="Logo" class="header-logo">
|
| 291 |
+
<div>
|
| 292 |
+
<div class="header-title">Scripture Detector</div>
|
| 293 |
+
<div class="header-subtitle">{{ source.name }}</div>
|
| 294 |
+
</div>
|
| 295 |
+
</a>
|
| 296 |
+
<nav>
|
| 297 |
+
<a href="/">Sources</a>
|
| 298 |
+
<a href="/dashboard">Dashboard</a>
|
| 299 |
+
<a href="/about">About</a>
|
| 300 |
+
<a href="/settings">Settings</a>
|
| 301 |
+
</nav>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<div class="action-bar">
|
| 305 |
+
<button class="btn btn-primary" id="process-btn" onclick="processSource()">
|
| 306 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
| 307 |
+
Process with AI
|
| 308 |
+
</button>
|
| 309 |
+
<a class="btn btn-ghost" id="export-tei-btn"
|
| 310 |
+
href="/api/sources/{{ source.id }}/export/tei" download
|
| 311 |
+
title="Download this source as TEI XML">
|
| 312 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
| 313 |
+
Export TEI
|
| 314 |
+
</a>
|
| 315 |
+
<select class="select-styled" id="model-select">
|
| 316 |
+
{% for m in models %}
|
| 317 |
+
<option value="{{ m.id }}" {{ 'selected' if m.id == current_model else '' }}>{{ m.name }}</option>
|
| 318 |
+
{% endfor %}
|
| 319 |
+
</select>
|
| 320 |
+
<button class="btn btn-ghost" id="add-quote-btn" onclick="openAddModal()">
|
| 321 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
| 322 |
+
Add Quote
|
| 323 |
+
</button>
|
| 324 |
+
<div style="margin-left:auto">
|
| 325 |
+
<div class="legend-bar">
|
| 326 |
+
<div class="legend-item"><div class="legend-swatch" style="background:var(--full-bg);border-bottom:2px solid var(--full)"></div>Full</div>
|
| 327 |
+
<div class="legend-item"><div class="legend-swatch" style="background:var(--partial-bg);border-bottom:2px solid var(--partial)"></div>Partial</div>
|
| 328 |
+
<div class="legend-item"><div class="legend-swatch" style="background:var(--paraphrase-bg);border-bottom:2px solid var(--paraphrase)"></div>Paraphrase</div>
|
| 329 |
+
<div class="legend-item"><div class="legend-swatch" style="background:var(--allusion-bg);border-bottom:2px solid var(--allusion)"></div>Allusion</div>
|
| 330 |
+
</div>
|
| 331 |
+
</div>
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
<div class="tabs">
|
| 335 |
+
<div class="tab active" data-tab="text">Text</div>
|
| 336 |
+
<div class="tab" data-tab="distribution">Distribution</div>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<div class="tab-content active" id="tab-text">
|
| 340 |
+
<div class="main">
|
| 341 |
+
<div class="text-panel" id="text-panel"><div class="loading"><div class="spinner"></div>Loading...</div></div>
|
| 342 |
+
<div class="ann-panel" id="ann-panel"></div>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<div class="tab-content" id="tab-distribution">
|
| 347 |
+
<div class="dist-content">
|
| 348 |
+
<div class="dist-grid">
|
| 349 |
+
<div class="dist-card">
|
| 350 |
+
<h3>Bible Books Referenced</h3>
|
| 351 |
+
<div class="chart-wrap"><canvas id="dist-book-chart"></canvas></div>
|
| 352 |
+
</div>
|
| 353 |
+
<div class="dist-card">
|
| 354 |
+
<h3>Quote Type Distribution</h3>
|
| 355 |
+
<div class="chart-wrap"><canvas id="dist-type-chart"></canvas></div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
</div>
|
| 360 |
+
|
| 361 |
+
<div class="sel-toolbar" id="sel-toolbar">
|
| 362 |
+
<button class="btn btn-primary btn-sm" onclick="analyzeSelection()">Analyze Selection</button>
|
| 363 |
+
<button class="btn btn-ghost btn-sm" onclick="addFromSelection()">Add as Quote</button>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<div class="modal-overlay" id="quote-modal">
|
| 367 |
+
<div class="modal">
|
| 368 |
+
<h2 id="modal-title">Add Quote</h2>
|
| 369 |
+
<input type="hidden" id="qm-id">
|
| 370 |
+
<label>Quote Text</label>
|
| 371 |
+
<textarea id="qm-text" rows="2" style="min-height:60px"></textarea>
|
| 372 |
+
<label>Quote Type</label>
|
| 373 |
+
<select id="qm-type">
|
| 374 |
+
<option value="full">Full</option>
|
| 375 |
+
<option value="partial">Partial</option>
|
| 376 |
+
<option value="paraphrase">Paraphrase</option>
|
| 377 |
+
<option value="allusion" selected>Allusion</option>
|
| 378 |
+
</select>
|
| 379 |
+
<label>References</label>
|
| 380 |
+
<div class="ref-tags" id="qm-ref-tags"></div>
|
| 381 |
+
<div class="ref-picker">
|
| 382 |
+
<div class="ref-picker-row">
|
| 383 |
+
<select id="ref-book" onchange="onBookChange()">
|
| 384 |
+
<option value="">Select Book...</option>
|
| 385 |
+
</select>
|
| 386 |
+
<select id="ref-chapter" disabled onchange="onChapterChange()">
|
| 387 |
+
<option value="">Ch.</option>
|
| 388 |
+
</select>
|
| 389 |
+
</div>
|
| 390 |
+
<div id="ref-verses-wrap" style="display:none">
|
| 391 |
+
<div class="ref-verses" id="ref-verses"></div>
|
| 392 |
+
</div>
|
| 393 |
+
</div>
|
| 394 |
+
<input type="hidden" id="qm-start">
|
| 395 |
+
<input type="hidden" id="qm-end">
|
| 396 |
+
<div class="actions">
|
| 397 |
+
<button class="btn btn-ghost" onclick="closeQuoteModal()">Cancel</button>
|
| 398 |
+
<button class="btn btn-primary" id="qm-save" onclick="saveQuote()">Save</button>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
<script>
|
| 404 |
+
const SOURCE_ID = {{ source.id }};
|
| 405 |
+
const textPanel = document.getElementById('text-panel');
|
| 406 |
+
const annPanel = document.getElementById('ann-panel');
|
| 407 |
+
const selToolbar = document.getElementById('sel-toolbar');
|
| 408 |
+
const quoteModal = document.getElementById('quote-modal');
|
| 409 |
+
let DATA = null;
|
| 410 |
+
let selRange = null;
|
| 411 |
+
let distChartsRendered = false;
|
| 412 |
+
let modalRefs = [];
|
| 413 |
+
let bibleBooks = null;
|
| 414 |
+
|
| 415 |
+
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
| 416 |
+
function showToast(msg, ok) {
|
| 417 |
+
const el = document.createElement('div');
|
| 418 |
+
el.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
| 419 |
+
el.textContent = msg;
|
| 420 |
+
document.body.appendChild(el);
|
| 421 |
+
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 400); }, 3500);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
document.querySelectorAll('.tab').forEach(tab => {
|
| 425 |
+
tab.addEventListener('click', () => {
|
| 426 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 427 |
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
| 428 |
+
tab.classList.add('active');
|
| 429 |
+
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
| 430 |
+
if (tab.dataset.tab === 'distribution' && !distChartsRendered) loadDistribution();
|
| 431 |
+
});
|
| 432 |
+
});
|
| 433 |
+
|
| 434 |
+
document.getElementById('model-select').addEventListener('change', e => {
|
| 435 |
+
fetch('/api/settings', {
|
| 436 |
+
method: 'POST',
|
| 437 |
+
headers: {'Content-Type': 'application/json'},
|
| 438 |
+
body: JSON.stringify({model: e.target.value}),
|
| 439 |
+
});
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
function loadSource() {
|
| 443 |
+
textPanel.innerHTML = '<div class="loading"><div class="spinner"></div>Loading...</div>';
|
| 444 |
+
annPanel.innerHTML = '';
|
| 445 |
+
fetch(`/api/sources/${SOURCE_ID}`)
|
| 446 |
+
.then(r => r.json())
|
| 447 |
+
.then(data => { DATA = data; renderText(); });
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
function renderText() {
|
| 451 |
+
if (!DATA) return;
|
| 452 |
+
const {segments, annotations} = DATA;
|
| 453 |
+
|
| 454 |
+
let html = '';
|
| 455 |
+
segments.forEach((seg, i) => {
|
| 456 |
+
if (seg.annotation_ids.length === 0) {
|
| 457 |
+
html += `<span data-start="${seg.start}" data-end="${seg.end}">${esc(seg.text)}</span>`;
|
| 458 |
+
} else {
|
| 459 |
+
const types = new Set(seg.annotation_ids.map(id => annotations[id]?.quote_type));
|
| 460 |
+
let cls = 'seg-multi';
|
| 461 |
+
if (types.size === 1) cls = `seg-${[...types][0]}`;
|
| 462 |
+
const ids = seg.annotation_ids.join(',');
|
| 463 |
+
html += `<span class="${cls}" data-ann="${ids}" data-start="${seg.start}" data-end="${seg.end}">${esc(seg.text)}</span>`;
|
| 464 |
+
}
|
| 465 |
+
});
|
| 466 |
+
textPanel.innerHTML = html;
|
| 467 |
+
|
| 468 |
+
let annHtml = '<h2>Annotations (' + annotations.length + ')</h2>';
|
| 469 |
+
if (annotations.length === 0) {
|
| 470 |
+
annHtml += '<p style="color:var(--muted);font-size:.84rem;padding:16px 0">No quotes detected yet. Click "Process with AI" to analyze this text.</p>';
|
| 471 |
+
}
|
| 472 |
+
annotations.forEach((a, i) => {
|
| 473 |
+
const qt = a.quote_type || 'allusion';
|
| 474 |
+
const snippet = (a.quote_text || '').slice(0, 140);
|
| 475 |
+
let versesHtml = '';
|
| 476 |
+
(a.verses || []).forEach(v => {
|
| 477 |
+
const vt = v.text ? esc(v.text) : '<span style="color:var(--red);font-style:italic">not found</span>';
|
| 478 |
+
versesHtml += `<div class="ann-verse"><strong>${esc(v.ref)}</strong> <span class="vtext">${vt}</span></div>`;
|
| 479 |
+
});
|
| 480 |
+
annHtml += `
|
| 481 |
+
<div class="ann-card ann-${qt}" data-idx="${i}" data-id="${a.id}">
|
| 482 |
+
<div class="ann-refs">
|
| 483 |
+
<span class="type-badge type-${qt}">${qt}</span>
|
| 484 |
+
${(a.refs||[]).map(r => `<span class="ref-badge">${esc(r)}</span>`).join('')}
|
| 485 |
+
</div>
|
| 486 |
+
<div class="ann-quote">"${esc(snippet)}${snippet.length < (a.quote_text||'').length ? '...' : ''}"</div>
|
| 487 |
+
${versesHtml}
|
| 488 |
+
<div class="ann-actions">
|
| 489 |
+
<button class="btn btn-ghost btn-sm" onclick="event.stopPropagation();editQuote(${i})">Edit</button>
|
| 490 |
+
<button class="btn btn-ghost btn-sm" style="color:var(--red)" onclick="event.stopPropagation();deleteQuote(${a.id})">Delete</button>
|
| 491 |
+
</div>
|
| 492 |
+
</div>`;
|
| 493 |
+
});
|
| 494 |
+
annPanel.innerHTML = annHtml;
|
| 495 |
+
bindTextEvents();
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
function bindTextEvents() {
|
| 499 |
+
textPanel.querySelectorAll('[data-ann]').forEach(span => {
|
| 500 |
+
span.addEventListener('click', () => {
|
| 501 |
+
clearActive();
|
| 502 |
+
span.classList.add('seg-active');
|
| 503 |
+
span.dataset.ann.split(',').forEach(id => {
|
| 504 |
+
const card = annPanel.querySelector(`.ann-card[data-idx="${id}"]`);
|
| 505 |
+
if (card) { card.classList.add('active'); card.scrollIntoView({behavior:'smooth',block:'center'}); }
|
| 506 |
+
});
|
| 507 |
+
});
|
| 508 |
+
});
|
| 509 |
+
annPanel.querySelectorAll('.ann-card').forEach(card => {
|
| 510 |
+
card.addEventListener('click', () => {
|
| 511 |
+
clearActive();
|
| 512 |
+
card.classList.add('active');
|
| 513 |
+
const idx = card.dataset.idx;
|
| 514 |
+
textPanel.querySelectorAll('[data-ann]').forEach(span => {
|
| 515 |
+
if (span.dataset.ann.split(',').includes(idx)) {
|
| 516 |
+
span.classList.add('seg-active');
|
| 517 |
+
span.scrollIntoView({behavior:'smooth',block:'center'});
|
| 518 |
+
}
|
| 519 |
+
});
|
| 520 |
+
});
|
| 521 |
+
});
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
function clearActive() {
|
| 525 |
+
document.querySelectorAll('.seg-active').forEach(e => e.classList.remove('seg-active'));
|
| 526 |
+
document.querySelectorAll('.ann-card.active').forEach(e => e.classList.remove('active'));
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
function getCharPos(node, offset) {
|
| 530 |
+
let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
| 531 |
+
while (el && !el.dataset.start) el = el.parentElement;
|
| 532 |
+
if (!el) return null;
|
| 533 |
+
return parseInt(el.dataset.start) + Math.min(offset, (el.textContent || '').length);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
textPanel.addEventListener('mouseup', () => {
|
| 537 |
+
setTimeout(() => {
|
| 538 |
+
const sel = window.getSelection();
|
| 539 |
+
if (!sel.rangeCount || sel.isCollapsed || sel.toString().trim().length < 3) {
|
| 540 |
+
selToolbar.classList.remove('visible'); selRange = null; return;
|
| 541 |
+
}
|
| 542 |
+
const range = sel.getRangeAt(0);
|
| 543 |
+
const s = getCharPos(range.startContainer, range.startOffset);
|
| 544 |
+
const e = getCharPos(range.endContainer, range.endOffset);
|
| 545 |
+
if (s === null || e === null || e <= s) {
|
| 546 |
+
selToolbar.classList.remove('visible'); selRange = null; return;
|
| 547 |
+
}
|
| 548 |
+
selRange = {start: Math.min(s,e), end: Math.max(s,e), text: sel.toString()};
|
| 549 |
+
const rect = range.getBoundingClientRect();
|
| 550 |
+
selToolbar.style.top = (rect.top - 44 + window.scrollY) + 'px';
|
| 551 |
+
selToolbar.style.left = (rect.left + rect.width/2 - 100) + 'px';
|
| 552 |
+
selToolbar.classList.add('visible');
|
| 553 |
+
}, 10);
|
| 554 |
+
});
|
| 555 |
+
|
| 556 |
+
document.addEventListener('mousedown', e => {
|
| 557 |
+
if (!selToolbar.contains(e.target)) selToolbar.classList.remove('visible');
|
| 558 |
+
});
|
| 559 |
+
|
| 560 |
+
const PROCESS_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>';
|
| 561 |
+
|
| 562 |
+
function processSource() {
|
| 563 |
+
if (DATA && DATA.annotations && DATA.annotations.length > 0) {
|
| 564 |
+
const n = DATA.annotations.length;
|
| 565 |
+
if (!confirm(
|
| 566 |
+
`This source already has ${n} annotation${n !== 1 ? 's' : ''}.\n\n` +
|
| 567 |
+
`Processing with AI will delete all existing annotations and replace them with new results.\n\n` +
|
| 568 |
+
`This cannot be undone. Continue?`
|
| 569 |
+
)) return;
|
| 570 |
+
}
|
| 571 |
+
const btn = document.getElementById('process-btn');
|
| 572 |
+
btn.disabled = true;
|
| 573 |
+
btn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;margin:0"></div> Processing...';
|
| 574 |
+
fetch(`/api/sources/${SOURCE_ID}/process`, {method: 'POST'})
|
| 575 |
+
.then(r => r.json())
|
| 576 |
+
.then(data => {
|
| 577 |
+
if (data.error) { showToast(data.error, false); return; }
|
| 578 |
+
showToast(`Found ${data.count} quotes`, true);
|
| 579 |
+
distChartsRendered = false;
|
| 580 |
+
loadSource();
|
| 581 |
+
})
|
| 582 |
+
.catch(e => showToast('Error: ' + e.message, false))
|
| 583 |
+
.finally(() => { btn.disabled = false; btn.innerHTML = PROCESS_ICON + ' Process with AI'; });
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
function analyzeSelection() {
|
| 587 |
+
if (!selRange) return;
|
| 588 |
+
if (DATA && DATA.annotations && DATA.annotations.length > 0) {
|
| 589 |
+
const n = DATA.annotations.length;
|
| 590 |
+
if (!confirm(
|
| 591 |
+
`This source already has ${n} annotation${n !== 1 ? 's' : ''}.\n\n` +
|
| 592 |
+
`Analyzing this selection may overwrite existing annotations that overlap the selected passage.\n\n` +
|
| 593 |
+
`Continue?`
|
| 594 |
+
)) return;
|
| 595 |
+
}
|
| 596 |
+
selToolbar.classList.remove('visible');
|
| 597 |
+
const btn = document.getElementById('process-btn');
|
| 598 |
+
btn.disabled = true;
|
| 599 |
+
btn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px;margin:0"></div> Analyzing selection...';
|
| 600 |
+
fetch(`/api/sources/${SOURCE_ID}/process-selection`, {
|
| 601 |
+
method: 'POST',
|
| 602 |
+
headers: {'Content-Type': 'application/json'},
|
| 603 |
+
body: JSON.stringify({start: selRange.start, end: selRange.end}),
|
| 604 |
+
})
|
| 605 |
+
.then(r => r.json())
|
| 606 |
+
.then(data => {
|
| 607 |
+
if (data.error) { showToast(data.error, false); return; }
|
| 608 |
+
showToast(`Found ${data.count} quotes in selection`, true);
|
| 609 |
+
distChartsRendered = false;
|
| 610 |
+
loadSource();
|
| 611 |
+
})
|
| 612 |
+
.catch(e => showToast('Error: ' + e.message, false))
|
| 613 |
+
.finally(() => { btn.disabled = false; btn.innerHTML = PROCESS_ICON + ' Process with AI'; selRange = null; });
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
function loadBibleBooks() {
|
| 617 |
+
if (bibleBooks) return Promise.resolve();
|
| 618 |
+
return fetch('/api/bible/books').then(r => r.json()).then(books => {
|
| 619 |
+
bibleBooks = books;
|
| 620 |
+
const sel = document.getElementById('ref-book');
|
| 621 |
+
sel.innerHTML = '<option value="">Select Book...</option>';
|
| 622 |
+
books.forEach(b => {
|
| 623 |
+
const opt = document.createElement('option');
|
| 624 |
+
opt.value = b.code;
|
| 625 |
+
opt.textContent = `${b.name} (${b.code})`;
|
| 626 |
+
sel.appendChild(opt);
|
| 627 |
+
});
|
| 628 |
+
});
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
function onBookChange() {
|
| 632 |
+
const book = document.getElementById('ref-book').value;
|
| 633 |
+
const chSel = document.getElementById('ref-chapter');
|
| 634 |
+
const versesWrap = document.getElementById('ref-verses-wrap');
|
| 635 |
+
chSel.innerHTML = '<option value="">Ch.</option>';
|
| 636 |
+
chSel.disabled = true;
|
| 637 |
+
versesWrap.style.display = 'none';
|
| 638 |
+
if (!book) return;
|
| 639 |
+
fetch(`/api/bible/${book}/chapters`).then(r => r.json()).then(chapters => {
|
| 640 |
+
chapters.forEach(ch => {
|
| 641 |
+
const opt = document.createElement('option');
|
| 642 |
+
opt.value = ch;
|
| 643 |
+
opt.textContent = ch;
|
| 644 |
+
chSel.appendChild(opt);
|
| 645 |
+
});
|
| 646 |
+
chSel.disabled = false;
|
| 647 |
+
});
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
function onChapterChange() {
|
| 651 |
+
const book = document.getElementById('ref-book').value;
|
| 652 |
+
const chapter = document.getElementById('ref-chapter').value;
|
| 653 |
+
const versesWrap = document.getElementById('ref-verses-wrap');
|
| 654 |
+
const versesList = document.getElementById('ref-verses');
|
| 655 |
+
versesWrap.style.display = 'none';
|
| 656 |
+
if (!book || !chapter) return;
|
| 657 |
+
fetch(`/api/bible/${book}/${chapter}/verses`).then(r => r.json()).then(verses => {
|
| 658 |
+
if (verses.length === 0) {
|
| 659 |
+
versesList.innerHTML = '<div class="ref-no-verses">No verses found</div>';
|
| 660 |
+
} else {
|
| 661 |
+
versesList.innerHTML = verses.map(v => {
|
| 662 |
+
const ref = `${book}_${chapter}:${v.verse}`;
|
| 663 |
+
const isAdded = modalRefs.includes(ref);
|
| 664 |
+
return `<div class="ref-verse-item${isAdded ? ' added' : ''}" data-ref="${esc(ref)}" onclick="toggleVerse(this,'${esc(ref)}')">
|
| 665 |
+
<div class="ref-verse-num">${v.verse}</div>
|
| 666 |
+
<div class="ref-verse-text">${esc(v.text)}</div>
|
| 667 |
+
<div class="ref-verse-add">${isAdded ? 'Added' : '+ Add'}</div>
|
| 668 |
+
</div>`;
|
| 669 |
+
}).join('');
|
| 670 |
+
}
|
| 671 |
+
versesWrap.style.display = 'block';
|
| 672 |
+
});
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
function toggleVerse(el, ref) {
|
| 676 |
+
const idx = modalRefs.indexOf(ref);
|
| 677 |
+
if (idx >= 0) {
|
| 678 |
+
modalRefs.splice(idx, 1);
|
| 679 |
+
el.classList.remove('added');
|
| 680 |
+
el.querySelector('.ref-verse-add').textContent = '+ Add';
|
| 681 |
+
} else {
|
| 682 |
+
modalRefs.push(ref);
|
| 683 |
+
el.classList.add('added');
|
| 684 |
+
el.querySelector('.ref-verse-add').textContent = 'Added';
|
| 685 |
+
}
|
| 686 |
+
renderRefTags();
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
function removeRef(ref) {
|
| 690 |
+
modalRefs = modalRefs.filter(r => r !== ref);
|
| 691 |
+
renderRefTags();
|
| 692 |
+
document.querySelectorAll(`.ref-verse-item[data-ref="${ref}"]`).forEach(el => {
|
| 693 |
+
el.classList.remove('added');
|
| 694 |
+
el.querySelector('.ref-verse-add').textContent = '+ Add';
|
| 695 |
+
});
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
function renderRefTags() {
|
| 699 |
+
const container = document.getElementById('qm-ref-tags');
|
| 700 |
+
if (modalRefs.length === 0) {
|
| 701 |
+
container.innerHTML = '<span style="color:var(--muted);font-size:.78rem">No references added yet</span>';
|
| 702 |
+
return;
|
| 703 |
+
}
|
| 704 |
+
container.innerHTML = modalRefs.map(ref =>
|
| 705 |
+
`<span class="ref-tag">${esc(ref)} <button onclick="removeRef('${esc(ref)}')">×</button></span>`
|
| 706 |
+
).join('');
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
function resetModal() {
|
| 710 |
+
document.getElementById('qm-id').value = '';
|
| 711 |
+
document.getElementById('qm-text').value = '';
|
| 712 |
+
document.getElementById('qm-type').value = 'allusion';
|
| 713 |
+
document.getElementById('qm-start').value = '';
|
| 714 |
+
document.getElementById('qm-end').value = '';
|
| 715 |
+
document.getElementById('ref-book').value = '';
|
| 716 |
+
document.getElementById('ref-chapter').innerHTML = '<option value="">Ch.</option>';
|
| 717 |
+
document.getElementById('ref-chapter').disabled = true;
|
| 718 |
+
document.getElementById('ref-verses-wrap').style.display = 'none';
|
| 719 |
+
modalRefs = [];
|
| 720 |
+
renderRefTags();
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
function openAddModal() {
|
| 724 |
+
loadBibleBooks().then(() => {
|
| 725 |
+
document.getElementById('modal-title').textContent = 'Add Quote';
|
| 726 |
+
resetModal();
|
| 727 |
+
quoteModal.classList.add('active');
|
| 728 |
+
});
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
function addFromSelection() {
|
| 732 |
+
if (!selRange) return;
|
| 733 |
+
selToolbar.classList.remove('visible');
|
| 734 |
+
loadBibleBooks().then(() => {
|
| 735 |
+
document.getElementById('modal-title').textContent = 'Add Quote';
|
| 736 |
+
resetModal();
|
| 737 |
+
document.getElementById('qm-text').value = selRange.text;
|
| 738 |
+
document.getElementById('qm-start').value = selRange.start;
|
| 739 |
+
document.getElementById('qm-end').value = selRange.end;
|
| 740 |
+
quoteModal.classList.add('active');
|
| 741 |
+
window.getSelection().removeAllRanges();
|
| 742 |
+
});
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
function editQuote(idx) {
|
| 746 |
+
const a = DATA.annotations[idx];
|
| 747 |
+
if (!a) return;
|
| 748 |
+
loadBibleBooks().then(() => {
|
| 749 |
+
document.getElementById('modal-title').textContent = 'Edit Quote';
|
| 750 |
+
resetModal();
|
| 751 |
+
document.getElementById('qm-id').value = a.id;
|
| 752 |
+
document.getElementById('qm-text').value = a.quote_text;
|
| 753 |
+
document.getElementById('qm-type').value = a.quote_type;
|
| 754 |
+
document.getElementById('qm-start').value = a.span_start ?? '';
|
| 755 |
+
document.getElementById('qm-end').value = a.span_end ?? '';
|
| 756 |
+
modalRefs = [...(a.refs || [])];
|
| 757 |
+
renderRefTags();
|
| 758 |
+
quoteModal.classList.add('active');
|
| 759 |
+
});
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
function closeQuoteModal() { quoteModal.classList.remove('active'); }
|
| 763 |
+
|
| 764 |
+
function saveQuote() {
|
| 765 |
+
const id = document.getElementById('qm-id').value;
|
| 766 |
+
const text = document.getElementById('qm-text').value.trim();
|
| 767 |
+
const type = document.getElementById('qm-type').value;
|
| 768 |
+
const startVal = document.getElementById('qm-start').value;
|
| 769 |
+
const endVal = document.getElementById('qm-end').value;
|
| 770 |
+
const span_start = startVal !== '' ? parseInt(startVal) : null;
|
| 771 |
+
const span_end = endVal !== '' ? parseInt(endVal) : null;
|
| 772 |
+
|
| 773 |
+
if (!text) { showToast('Quote text is required', false); return; }
|
| 774 |
+
|
| 775 |
+
const saveBtn = document.getElementById('qm-save');
|
| 776 |
+
saveBtn.disabled = true;
|
| 777 |
+
|
| 778 |
+
const payload = {quote_text: text, quote_type: type, references: modalRefs, span_start, span_end};
|
| 779 |
+
const url = id ? `/api/quotes/${id}` : '/api/quotes';
|
| 780 |
+
const method = id ? 'PUT' : 'POST';
|
| 781 |
+
if (!id) payload.source_id = SOURCE_ID;
|
| 782 |
+
|
| 783 |
+
fetch(url, {
|
| 784 |
+
method,
|
| 785 |
+
headers: {'Content-Type': 'application/json'},
|
| 786 |
+
body: JSON.stringify(payload),
|
| 787 |
+
})
|
| 788 |
+
.then(r => r.json())
|
| 789 |
+
.then(() => {
|
| 790 |
+
closeQuoteModal();
|
| 791 |
+
showToast(id ? 'Quote updated' : 'Quote added', true);
|
| 792 |
+
distChartsRendered = false;
|
| 793 |
+
loadSource();
|
| 794 |
+
})
|
| 795 |
+
.catch(e => showToast('Error: ' + e.message, false))
|
| 796 |
+
.finally(() => { saveBtn.disabled = false; });
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
function deleteQuote(id) {
|
| 800 |
+
if (!confirm('Delete this quote?')) return;
|
| 801 |
+
fetch(`/api/quotes/${id}`, {method: 'DELETE'})
|
| 802 |
+
.then(() => { showToast('Quote deleted', true); distChartsRendered = false; loadSource(); })
|
| 803 |
+
.catch(e => showToast('Error: ' + e.message, false));
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
let bookChart = null, typeChart = null;
|
| 807 |
+
|
| 808 |
+
function loadDistribution() {
|
| 809 |
+
fetch(`/api/sources/${SOURCE_ID}/distribution`)
|
| 810 |
+
.then(r => r.json())
|
| 811 |
+
.then(data => { renderDistCharts(data); distChartsRendered = true; });
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
function renderDistCharts(data) {
|
| 815 |
+
if (bookChart) { bookChart.destroy(); bookChart = null; }
|
| 816 |
+
if (typeChart) { typeChart.destroy(); typeChart = null; }
|
| 817 |
+
|
| 818 |
+
const chartFont = { family: "'Inter', system-ui, sans-serif" };
|
| 819 |
+
const bookCanvas = document.getElementById('dist-book-chart');
|
| 820 |
+
const typeCanvas = document.getElementById('dist-type-chart');
|
| 821 |
+
|
| 822 |
+
const bd = data.book_distribution || [];
|
| 823 |
+
if (bd.length > 0) {
|
| 824 |
+
const colors = bd.map(d => d.testament === 'nt' ? '#6366f1' : d.testament === 'ot' ? '#f59e0b' : '#9ca3af');
|
| 825 |
+
bookChart = new Chart(bookCanvas, {
|
| 826 |
+
type: 'bar',
|
| 827 |
+
data: {
|
| 828 |
+
labels: bd.map(d => d.book_name || d.book_code),
|
| 829 |
+
datasets: [{ data: bd.map(d => d.count), backgroundColor: colors, borderRadius: 6 }],
|
| 830 |
+
},
|
| 831 |
+
options: {
|
| 832 |
+
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
|
| 833 |
+
plugins: { legend: { display: false } },
|
| 834 |
+
scales: {
|
| 835 |
+
x: { beginAtZero: true, ticks: { precision: 0, font: chartFont }, grid: { color: '#f0ede7' } },
|
| 836 |
+
y: { ticks: { font: { ...chartFont, size: 11 } }, grid: { display: false } },
|
| 837 |
+
},
|
| 838 |
+
},
|
| 839 |
+
});
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
const td = data.type_distribution || {};
|
| 843 |
+
const typeLabels = ['full', 'partial', 'paraphrase', 'allusion'];
|
| 844 |
+
const typeColors = ['#16a34a', '#ca8a04', '#0891b2', '#9333ea'];
|
| 845 |
+
const typeData = typeLabels.map(t => td[t] || 0);
|
| 846 |
+
if (typeData.some(v => v > 0)) {
|
| 847 |
+
typeChart = new Chart(typeCanvas, {
|
| 848 |
+
type: 'doughnut',
|
| 849 |
+
data: {
|
| 850 |
+
labels: typeLabels.map(t => t.charAt(0).toUpperCase() + t.slice(1)),
|
| 851 |
+
datasets: [{ data: typeData, backgroundColor: typeColors, borderWidth: 3, borderColor: '#fff', hoverOffset: 8 }],
|
| 852 |
+
},
|
| 853 |
+
options: {
|
| 854 |
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
| 855 |
+
plugins: { legend: { position: 'bottom', labels: { padding: 20, font: { ...chartFont, size: 12 }, usePointStyle: true, pointStyle: 'rectRounded' } } },
|
| 856 |
+
},
|
| 857 |
+
});
|
| 858 |
+
}
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
quoteModal.addEventListener('click', e => { if (e.target === quoteModal) closeQuoteModal(); });
|
| 862 |
+
loadSource();
|
| 863 |
+
</script>
|
| 864 |
+
</body>
|
| 865 |
+
</html>
|