William Mattingly commited on
Commit
a9a9428
·
1 Parent(s): ac1cbf4

Add scripture detector app

Browse files

Initial implementation with screenshots tracked via Git LFS.

Made-with: Cursor

.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
+ ![Sources page](static/screenshots/sources.png)
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
+ ![Sources page](static/screenshots/sources.png)
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
+ ![Viewer page](static/screenshots/viewer.png)
32
+
33
+ ### Analytics Dashboard
34
+ Explore scripture distribution across all sources — broken down by Bible book, testament, and quote type.
35
+
36
+ ![Dashboard](static/screenshots/dashboard.png)
37
+
38
+ ### Settings
39
+ Configure your Gemini API key or Google Vertex AI credentials, and select which Gemini model to use.
40
+
41
+ ![Settings page](static/screenshots/settings.png)
42
+
43
+ ### About Page
44
+ Full documentation of the application, its features, and how it works.
45
+
46
+ ![About page](static/screenshots/about.png)
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

  • SHA256: 463471c453c2892c044d1fed3e4076e1d54b184f54fbd19d967f978c6696085d
  • Pointer size: 131 Bytes
  • Size of remote file: 340 kB
static/screenshots/dashboard.png ADDED

Git LFS Details

  • SHA256: b9da920feecd815f734939d504fb26bb923e455f48baf137e769de81f40a0082
  • Pointer size: 131 Bytes
  • Size of remote file: 190 kB
static/screenshots/settings.png ADDED

Git LFS Details

  • SHA256: 5390a7c2c881c221d34a1349d90e427e6dd881d5aaa03af25c75f09ba36e980b
  • Pointer size: 131 Bytes
  • Size of remote file: 136 kB
static/screenshots/sources.png ADDED

Git LFS Details

  • SHA256: f067505b665fdfe36f025ee61e657a4de420d6f917c699fdd3b2ca92be8aa97a
  • Pointer size: 131 Bytes
  • Size of remote file: 145 kB
static/screenshots/viewer.png ADDED

Git LFS Details

  • SHA256: a82ed516df55c43c30a4d3ec8850544e55472b9f0a0562f7a837778caedd22a2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.07 MB
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 &amp; 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 &amp; 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 ? ' &#9650;' : ' &#9660;') : '';
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 &amp; 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 = '&times;';
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">&times;</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
+ &nbsp;&middot;&nbsp;
816
+ <strong>${RAW.quote_count}</strong> quote${RAW.quote_count !== 1 ? 's' : ''}
817
+ &nbsp;&middot;&nbsp;
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)}')">&times;</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>