Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84d08ee675 | |||
| 3b802f2e34 | |||
| 95391f82d5 |
@@ -20,16 +20,4 @@ ADMIN_PASSWORD=1993llelMO65026illustrations
|
||||
# Seed import
|
||||
IMPORT_SEED_ON_START=false
|
||||
# SEED_CSV=/data/imports/illustrations_seed.csv
|
||||
OPENAI_API_KEY=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
|
||||
|
||||
# Authentik OIDC
|
||||
OIDC_RP_CLIENT_ID=yQnn1VzxZh7hglqCWuBX4ekwpuvDgxss0IhDuULx
|
||||
OIDC_RP_CLIENT_SECRET=LmWIlfXJMGMPbc4NKOkMFZ5gxKRpvHykkeMe7OQ1ugykihVa9yPlDQl4hPbfvKiYoj5Nm2ONU7RkQGSWovmS2dLUjGhYUxv4JgSzK0s2ECdHmUn1QH6iVaNncWn3zZMb
|
||||
|
||||
# Endpoints (see next section)
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT=https://auth.lakehandyman.biz/application/o/authorize/
|
||||
OIDC_OP_TOKEN_ENDPOINT=https://auth.lakehandyman.biz/application/o/token/
|
||||
OIDC_OP_USER_ENDPOINT=https://auth.lakehandyman.biz/application/o/userinfo/
|
||||
OIDC_OP_JWKS_ENDPOINT=https://auth.lakehandyman.biz/application/o/illustration-database/jwks/
|
||||
|
||||
OIDC_RP_SCOPES=openid email profile
|
||||
OPENAI_API_KEY=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
|
||||
@@ -1,68 +0,0 @@
|
||||
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||
|
||||
|
||||
class AuthentikOIDCBackend(OIDCAuthenticationBackend):
|
||||
"""
|
||||
Custom backend to integrate Authentik OIDC with existing Django users
|
||||
without breaking local authentication.
|
||||
"""
|
||||
|
||||
def filter_users_by_claims(self, claims):
|
||||
email = (claims.get("email") or "").strip().lower()
|
||||
username = (
|
||||
claims.get("preferred_username")
|
||||
or claims.get("username")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
# 1) Prefer matching by email
|
||||
if email:
|
||||
qs = self.UserModel.objects.filter(email__iexact=email)
|
||||
if qs.exists():
|
||||
return qs
|
||||
|
||||
# 2) Fallback to username match (critical for existing local users)
|
||||
if username:
|
||||
qs = self.UserModel.objects.filter(username__iexact=username)
|
||||
if qs.exists():
|
||||
return qs
|
||||
|
||||
# 3) Otherwise, no match -> create user
|
||||
return self.UserModel.objects.none()
|
||||
|
||||
def create_user(self, claims):
|
||||
user = super().create_user(claims)
|
||||
|
||||
email = (claims.get("email") or "").strip().lower()
|
||||
username = (
|
||||
claims.get("preferred_username")
|
||||
or claims.get("username")
|
||||
or email
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if email:
|
||||
user.email = email
|
||||
|
||||
# Only set username if Django hasn't already set one
|
||||
if username and not user.username:
|
||||
user.username = username
|
||||
|
||||
user.first_name = claims.get("given_name", "") or user.first_name
|
||||
user.last_name = claims.get("family_name", "") or user.last_name
|
||||
|
||||
# SSO-only account unless you explicitly add a password later
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def update_user(self, user, claims):
|
||||
email = (claims.get("email") or "").strip().lower()
|
||||
if email and user.email.lower() != email:
|
||||
user.email = email
|
||||
|
||||
user.first_name = claims.get("given_name", "") or user.first_name
|
||||
user.last_name = claims.get("family_name", "") or user.last_name
|
||||
|
||||
user.save()
|
||||
return user
|
||||
+27
-193
@@ -53,22 +53,15 @@ EXPECTED_HEADERS = [
|
||||
"Date Edited",
|
||||
]
|
||||
|
||||
|
||||
def is_admin(user):
|
||||
return user.is_superuser or user.is_staff
|
||||
|
||||
|
||||
def login_view(request):
|
||||
# If Django session already exists, go to app
|
||||
if request.user.is_authenticated:
|
||||
return redirect("search")
|
||||
|
||||
# Only auto-start OIDC if this is a fresh browser visit
|
||||
# and NOT a redirect coming from Django itself
|
||||
if (
|
||||
request.method == "GET"
|
||||
and "next" not in request.GET
|
||||
):
|
||||
return redirect("oidc_authentication_init")
|
||||
|
||||
# Fallback: show login page (rare, but prevents loops)
|
||||
ctx = {}
|
||||
|
||||
if request.method == "POST":
|
||||
u = request.POST.get("username")
|
||||
p = request.POST.get("password")
|
||||
@@ -77,13 +70,9 @@ def login_view(request):
|
||||
login(request, user)
|
||||
return redirect("search")
|
||||
ctx["error"] = "Invalid credentials"
|
||||
|
||||
return render(request, "login.html", ctx)
|
||||
|
||||
|
||||
def is_admin(user):
|
||||
return user.is_superuser or user.is_staff
|
||||
|
||||
def entry_context(entry, result_ids):
|
||||
"""
|
||||
Build the navigation + chips context for the entry pages.
|
||||
@@ -132,7 +121,7 @@ def search_page(request):
|
||||
- AND across tokens, OR across the selected fields
|
||||
|
||||
Special power term:
|
||||
- 'invalidscripture' -> entries whose Scripture would be INVALID per the JS validator
|
||||
- 'invalidscripture' -> entries whose Scripture would show red (invalid)
|
||||
"""
|
||||
default_fields = {
|
||||
"subject": True,
|
||||
@@ -158,134 +147,25 @@ def search_page(request):
|
||||
|
||||
q = (request.GET.get("q") or "").strip()
|
||||
if q:
|
||||
# ===== SPECIAL POWER TERM (JS-compatible scripture validation) =====
|
||||
# ===== SPECIAL POWER TERM =====
|
||||
if q.lower() == "invalidscripture":
|
||||
import re
|
||||
|
||||
# --- JS validator port (same logic as ScriptureValidator.isValidSingleRef) ---
|
||||
FULL_TO_CODE = {
|
||||
# OT
|
||||
"genesis":"Ge","exodus":"Ex","leviticus":"Le","numbers":"Nu","deuteronomy":"De",
|
||||
"joshua":"Jos","judges":"Jg","ruth":"Ru",
|
||||
"1 samuel":"1Sa","2 samuel":"2Sa","1 kings":"1Ki","2 kings":"2Ki",
|
||||
"1 chronicles":"1Ch","2 chronicles":"2Ch",
|
||||
"ezra":"Ezr","nehemiah":"Ne","esther":"Es","job":"Job","psalms":"Ps","psalm":"Ps",
|
||||
"proverbs":"Pr","ecclesiastes":"Ec","song of solomon":"Ca","song of songs":"Ca",
|
||||
"isaiah":"Isa","jeremiah":"Jer","lamentations":"La","ezekiel":"Eze","daniel":"Da",
|
||||
"hosea":"Ho","joel":"Joe","amos":"Am","obadiah":"Ob","jonah":"Jon","micah":"Mic",
|
||||
"nahum":"Na","habakkuk":"Hab","zephaniah":"Zep","haggai":"Hag","zechariah":"Zec","malachi":"Mal",
|
||||
# NT
|
||||
"matthew":"Mt","mark":"Mr","luke":"Lu","john":"Joh","acts":"Ac","romans":"Ro",
|
||||
"1 corinthians":"1Co","2 corinthians":"2Co",
|
||||
"galatians":"Ga","ephesians":"Eph","philippians":"Php","colossians":"Col",
|
||||
"1 thessalonians":"1Th","2 thessalonians":"2Th",
|
||||
"1 timothy":"1Ti","2 timothy":"2Ti",
|
||||
"titus":"Tit","philemon":"Phm","hebrews":"Heb","james":"Jas",
|
||||
"1 peter":"1Pe","2 peter":"2Pe",
|
||||
"1 john":"1Jo","2 john":"2Jo","3 john":"3Jo",
|
||||
"jude":"Jude","revelation":"Re",
|
||||
}
|
||||
ALIAS_TO_CODE = {
|
||||
# OT
|
||||
"gen":"Ge","exod":"Ex","lev":"Le","num":"Nu","deut":"De",
|
||||
"josh":"Jos","judg":"Jg","ps":"Ps","prov":"Pr","eccl":"Ec","song":"Ca","cant":"Ca",
|
||||
"isa":"Isa","jer":"Jer","lam":"La","ezek":"Eze","dan":"Da","hos":"Ho","joel":"Joe",
|
||||
"amos":"Am","obad":"Ob","jon":"Jon","mic":"Mic","nah":"Na","hab":"Hab","zeph":"Zep",
|
||||
"hag":"Hag","zech":"Zec","mal":"Mal",
|
||||
# NT
|
||||
"matt":"Mt","mark":"Mr","luke":"Lu","john":"Joh","acts":"Ac","rom":"Ro",
|
||||
"gal":"Ga","eph":"Eph","phil":"Php","col":"Col","heb":"Heb","jas":"Jas",
|
||||
"jude":"Jude","rev":"Re",
|
||||
}
|
||||
CODE_TO_NUM = {
|
||||
# OT
|
||||
"Ge":1,"Ex":2,"Le":3,"Nu":4,"De":5,"Jos":6,"Jg":7,"Ru":8,"1Sa":9,"2Sa":10,
|
||||
"1Ki":11,"2Ki":12,"1Ch":13,"2Ch":14,"Ezr":15,"Ne":16,"Es":17,"Job":18,
|
||||
"Ps":19,"Pr":20,"Ec":21,"Ca":22,"Isa":23,"Jer":24,"La":25,"Eze":26,"Da":27,"Ho":28,
|
||||
"Joe":29,"Am":30,"Ob":31,"Jon":32,"Mic":33,"Na":34,"Hab":35,"Zep":36,"Hag":37,"Zec":38,"Mal":39,
|
||||
# NT
|
||||
"Mt":40,"Mr":41,"Lu":42,"Joh":43,"Ac":44,"Ro":45,"1Co":46,"2Co":47,"Ga":48,"Eph":49,
|
||||
"Php":50,"Col":51,"1Th":52,"2Th":53,"1Ti":54,"2Ti":55,"Tit":56,"Phm":57,
|
||||
"Heb":58,"Jas":59,"1Pe":60,"2Pe":61,"1Jo":62,"2Jo":63,"3Jo":64,"Jude":65,"Re":66,
|
||||
}
|
||||
SERIES = [
|
||||
{"prefixes":["sam","samu","samuel"], "codes":{1:"1Sa",2:"2Sa"}},
|
||||
{"prefixes":["ki","king","kings","kgs"], "codes":{1:"1Ki",2:"2Ki"}},
|
||||
{"prefixes":["chron","chr","ch","chronicles"], "codes":{1:"1Ch",2:"2Ch"}},
|
||||
{"prefixes":["cor","corin","corinth","corinthians","co","c"], "codes":{1:"1Co",2:"2Co"}},
|
||||
{"prefixes":["thes","thess","thessalon","thessalonians","th"], "codes":{1:"1Th",2:"2Th"}},
|
||||
{"prefixes":["tim","ti","timothy","t"], "codes":{1:"1Ti",2:"2Ti"}},
|
||||
{"prefixes":["pet","pe","peter","pt","p"], "codes":{1:"1Pe",2:"2Pe"}},
|
||||
{"prefixes":["jo","jn","joh","john","jno","jhn"], "codes":{1:"1Jo",2:"2Jo",3:"3Jo"}},
|
||||
]
|
||||
WOL_ABBR = set(CODE_TO_NUM.keys())
|
||||
versesRe = re.compile(r"""
|
||||
^
|
||||
(?:
|
||||
(\d{1,3}) # chapter only
|
||||
|
|
||||
(\d{1,3})\s*:\s*
|
||||
(
|
||||
\d{1,3} # v1
|
||||
(?:\s*-\s*(?:\d{1,3}|\d{1,3}:\d{1,3}))? # -v2 OR -ch:vs
|
||||
(?:\s*,\s*\d{1,3}(?:\s*-\s*(?:\d{1,3}|\d{1,3}:\d{1,3}))?)* # ,vN[-…]
|
||||
)
|
||||
)
|
||||
$
|
||||
""", re.VERBOSE)
|
||||
|
||||
def _norm_spaces(s): return re.sub(r"\s+", " ", (s or "").strip())
|
||||
def _strip_dots(s): return re.sub(r"\.+$", "", s or "")
|
||||
def _lower(s): return (s or "").lower()
|
||||
|
||||
def _lookup_book_code(book_raw: str):
|
||||
b = _norm_spaces(_strip_dots(book_raw))
|
||||
# Full names
|
||||
c = FULL_TO_CODE.get(_lower(b))
|
||||
if c: return c
|
||||
# Aliases
|
||||
c = ALIAS_TO_CODE.get(_lower(b))
|
||||
if c: return c
|
||||
# WOL abbr (allow a space after the number, and arbitrary spaces)
|
||||
tightened = re.sub(r"^([1-3])\s+([A-Za-z].*)$", r"\1\2", b)
|
||||
if tightened in WOL_ABBR: return tightened
|
||||
no_space = re.sub(r"\s+", "", b)
|
||||
if no_space in WOL_ABBR: return no_space
|
||||
# Numbered prose (e.g., "2 Sam", "1 Chron", "3 Jo")
|
||||
m = re.match(r"^([1-3])\s*([A-Za-z]+)$", _lower(b))
|
||||
if m:
|
||||
n = int(m.group(1)); base = m.group(2)
|
||||
for fam in SERIES:
|
||||
if any(base.startswith(p) for p in fam["prefixes"]):
|
||||
code = fam["codes"].get(n)
|
||||
if code: return code
|
||||
return None
|
||||
|
||||
def _split_book_and_rest(s: str):
|
||||
m = re.match(r"^(.+?)\s+(\d{1,3}(?:\s*:\s*.*)?)$", s)
|
||||
return {"book": m.group(1), "rest": m.group(2)} if m else None
|
||||
|
||||
def _is_valid_single_ref(ref: str) -> bool:
|
||||
s = (ref or "").strip()
|
||||
if not s: return False
|
||||
parts = _split_book_and_rest(s)
|
||||
if not parts: return False
|
||||
if not _lookup_book_code(parts["book"]): return False
|
||||
rest = (parts.get("rest") or "").trim() if hasattr(str, "trim") else (parts.get("rest") or "").strip()
|
||||
if not rest: return False
|
||||
return bool(versesRe.match(rest))
|
||||
|
||||
def _field_is_valid(text: str) -> bool:
|
||||
pieces = [p.strip() for p in (text or "").split(";") if p.strip()]
|
||||
if not pieces: # empty field considered neutral/invalid? UI treats empty as neither; we exclude empties anyway
|
||||
return False
|
||||
return all(_is_valid_single_ref(p) for p in pieces)
|
||||
# --- end JS port ---
|
||||
# A simple server-side validity check that mirrors the front-end idea:
|
||||
# each piece must look like "<book> <chapter[:verses...]>"
|
||||
book_ch_re = re.compile(r"^.+?\s+\d{1,3}(?::\s*.+)?$")
|
||||
|
||||
invalid_ids = []
|
||||
qs = Entry.objects.exclude(scripture_raw="").only("id", "scripture_raw", "date_added")
|
||||
for e in qs.iterator(chunk_size=1000):
|
||||
if not _field_is_valid(e.scripture_raw or ""):
|
||||
qs_all = Entry.objects.exclude(scripture_raw="").only("id", "scripture_raw", "date_added")
|
||||
for e in qs_all.iterator(chunk_size=1000):
|
||||
original = (e.scripture_raw or "").strip()
|
||||
norm, warns = normalize_scripture_field(original)
|
||||
|
||||
# Split into pieces as the UI does
|
||||
pieces = [p.strip() for p in original.split(";") if p.strip()]
|
||||
# Invalid if:
|
||||
# - normalizer produced warnings (e.g., verses but no book), OR
|
||||
# - any piece fails "<book> <chapter[:verses...]>" quick check
|
||||
any_bad_shape = any(not book_ch_re.match(p) for p in pieces)
|
||||
if warns or any_bad_shape:
|
||||
invalid_ids.append(e.id)
|
||||
|
||||
ids = list(
|
||||
@@ -303,7 +183,8 @@ def search_page(request):
|
||||
request.session["last_search"] = {"q": q, "fields": ["scripture_raw"]}
|
||||
request.session.modified = True
|
||||
|
||||
if ids:
|
||||
count = len(ids)
|
||||
if count:
|
||||
entry = Entry.objects.get(pk=ids[0])
|
||||
ctx = entry_context(entry, ids)
|
||||
ctx.update({"from_search": True})
|
||||
@@ -326,7 +207,6 @@ def search_page(request):
|
||||
)
|
||||
# ===== END SPECIAL TERM =====
|
||||
|
||||
# --- existing search flow ---
|
||||
tokens = terms(q)
|
||||
fields = [f for f, sel in selected.items() if sel] or ["subject"]
|
||||
|
||||
@@ -362,10 +242,11 @@ def search_page(request):
|
||||
pass
|
||||
|
||||
request.session["result_ids"] = ids
|
||||
count = len(ids)
|
||||
request.session["last_search"] = {"q": q, "fields": fields}
|
||||
request.session.modified = True
|
||||
|
||||
if ids:
|
||||
if count:
|
||||
entry = Entry.objects.get(pk=ids[0])
|
||||
ctx = entry_context(entry, ids)
|
||||
ctx.update({"from_search": True})
|
||||
@@ -1256,51 +1137,4 @@ def set_theme(request):
|
||||
|
||||
request.session["theme"] = theme
|
||||
messages.success(request, f"Theme set to {theme.title()}.")
|
||||
return redirect("settings_home")
|
||||
|
||||
# web/core/views.py
|
||||
import json
|
||||
import os
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.http import JsonResponse, HttpResponseBadRequest
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
@user_passes_test(lambda u: u.is_superuser)
|
||||
def api_update_pub_codes(request):
|
||||
"""
|
||||
Accepts a 'json' field (string) that should parse to {"pub_codes": [..]}.
|
||||
Normalizes, de-duplicates, and writes to web/static/data/wol-pub-codes.v1.json.
|
||||
"""
|
||||
payload = request.POST.get("json") or (request.body.decode("utf-8") if request.body else "")
|
||||
if not payload:
|
||||
return HttpResponseBadRequest("Missing 'json'.")
|
||||
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except Exception as e:
|
||||
return HttpResponseBadRequest(f"Invalid JSON: {e}")
|
||||
|
||||
if not isinstance(data, dict) or "pub_codes" not in data or not isinstance(data["pub_codes"], list):
|
||||
return HttpResponseBadRequest('JSON must be an object with a "pub_codes" array.')
|
||||
|
||||
# Normalize to unique, lowercase, trimmed strings
|
||||
seen = set()
|
||||
codes = []
|
||||
for c in data["pub_codes"]:
|
||||
s = str(c or "").strip().lower()
|
||||
if s and s not in seen:
|
||||
seen.add(s)
|
||||
codes.append(s)
|
||||
|
||||
# Write back to static data file
|
||||
target_path = os.path.join(settings.BASE_DIR, "web", "static", "data", "wol-pub-codes.v1.json")
|
||||
try:
|
||||
with open(target_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"pub_codes": codes}, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
return HttpResponseBadRequest(f"Could not write file: {e}")
|
||||
|
||||
return JsonResponse({"ok": True, "count": len(codes)})
|
||||
return redirect("settings_home")
|
||||
@@ -12,7 +12,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes",
|
||||
"django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
|
||||
"core","mozilla_django_oidc",
|
||||
"core",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
@@ -52,12 +52,6 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
"core.auth_oidc.AuthentikOIDCBackend", # OIDC via Authentik
|
||||
"django.contrib.auth.backends.ModelBackend", # keep existing username/password login
|
||||
)
|
||||
|
||||
|
||||
LANGUAGE_CODE="en-us"
|
||||
TIME_ZONE="America/Chicago"
|
||||
USE_I18N=True
|
||||
@@ -72,22 +66,6 @@ LOGIN_REDIRECT_URL="/search/"
|
||||
LOGOUT_REDIRECT_URL="/login/"
|
||||
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
|
||||
|
||||
# --- Authentik OIDC ---
|
||||
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID", "")
|
||||
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET", "")
|
||||
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("OIDC_OP_AUTHORIZATION_ENDPOINT", "")
|
||||
OIDC_OP_TOKEN_ENDPOINT = os.getenv("OIDC_OP_TOKEN_ENDPOINT", "")
|
||||
OIDC_OP_USER_ENDPOINT = os.getenv("OIDC_OP_USER_ENDPOINT", "")
|
||||
OIDC_OP_JWKS_ENDPOINT = os.getenv("OIDC_OP_JWKS_ENDPOINT", "")
|
||||
|
||||
OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile")
|
||||
OIDC_CREATE_USER = True
|
||||
USE_X_FORWARDED_HOST = True
|
||||
OIDC_RP_SIGN_ALGO = "RS256"
|
||||
|
||||
|
||||
|
||||
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||
# Ensure MEDIA_ROOT exists (you likely already have this)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# illustrations/urls.py
|
||||
from django.contrib import admin
|
||||
from django.urls import path, re_path, include
|
||||
from django.urls import path, re_path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.views.generic import TemplateView # NEW: for manifest / service worker / offline
|
||||
|
||||
@@ -42,7 +42,6 @@ urlpatterns = [
|
||||
|
||||
# Auth
|
||||
path("login/", core_views.login_view, name="login"),
|
||||
path("oidc/", include("mozilla_django_oidc.urls")),
|
||||
path("logout/", auth_views.LogoutView.as_view(next_page="/login/"), name="logout"),
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
@@ -55,7 +54,6 @@ urlpatterns = [
|
||||
path("api/get-recent-views/", feat_views.api_get_recent_views, name="api_get_recent_views"),
|
||||
path("settings/", core_views.settings_home, name="settings_home"),
|
||||
path("settings/theme/", core_views.set_theme, name="set_theme"), # ✅ FIXED
|
||||
path("api/update-pub-codes/", core_views.api_update_pub_codes, name="api_update_pub_codes"),
|
||||
|
||||
# --- NEW (PWA) ---
|
||||
re_path(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
Django==5.0.6
|
||||
psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0.post0
|
||||
openai>=1.0,<2.0
|
||||
mozilla-django-oidc==5.0.2
|
||||
openai>=1.0,<2.0
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"pub_codes": [
|
||||
"wp", "ws", "yb", "mwb", "w", "g",
|
||||
"ap", "apf", "be", "bh", "br", "bt", "btg", "cf", "cl", "ct", "dp",
|
||||
"fg", "fy", "gt", "hb", "im", "ip", "it", "jv", "ka", "kj", "kl",
|
||||
"lf", "lff", "ll", "ly", "my", "od", "pe", "po", "pt", "rr", "rs",
|
||||
"sg", "sh", "si", "td", "tp", "tr", "ts", "un", "jy",
|
||||
"uw", "su", "re", "lvs", "lp", "yy", "yp2", "yp", "sl", "pm", "kc"
|
||||
]
|
||||
}
|
||||
@@ -1,47 +1,22 @@
|
||||
/* source-validator.v1.js
|
||||
Centralizes logic for deciding if a "Source" string should link to WOL.
|
||||
Loads publication codes from /static/data/wol-pub-codes.v1.json.
|
||||
|
||||
Exposes:
|
||||
- SourceValidator.isWOLSource(text) -> boolean
|
||||
- SourceValidator.buildWOLSearchURL(text) -> string
|
||||
*/
|
||||
window.SourceValidator = (function () {
|
||||
// ---- Load publication codes (sync so callers can use API immediately) ----
|
||||
function loadPubCodesSync() {
|
||||
try {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "/static/data/wol-pub-codes.v1.json", false); // synchronous
|
||||
xhr.send(null);
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
var data = JSON.parse(xhr.responseText || "{}");
|
||||
if (data && Array.isArray(data.pub_codes)) {
|
||||
// de-duplicate and normalize to lowercase strings
|
||||
var uniq = Object.create(null), out = [];
|
||||
for (var i = 0; i < data.pub_codes.length; i++) {
|
||||
var c = String(data.pub_codes[i] || "").trim().toLowerCase();
|
||||
if (!c) continue;
|
||||
if (!uniq[c]) { uniq[c] = 1; out.push(c); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// fall through to fallback
|
||||
}
|
||||
// Fallback (very small set) — only used if JSON cannot be loaded
|
||||
return ["w", "wp", "ws", "g", "rs"];
|
||||
}
|
||||
|
||||
// Publications / codes loaded from JSON
|
||||
var PUB_CODES = loadPubCodesSync();
|
||||
|
||||
// Choose the longest matching code at the start (so "ws" beats "w").
|
||||
var PUB_CODES_SORTED = PUB_CODES.slice().sort(function (a, b) { return b.length - a.length; });
|
||||
// Publications / codes that produce valid WOL links.
|
||||
// Added: uw, su, re, lvs, rs (rs was already present).
|
||||
const PUB_CODES = [
|
||||
"wp","ws","yb","mwb","w","g","ap","apf","be","bh","br","bt","btg","cf","cl","ct","dp",
|
||||
"fg","fy","gt","hb","im","ip","it","jv","ka","kj","kl","lf","lff","ll","ly","my","od",
|
||||
"pe","po","pt","rr","rs","sg","sh","si","td","tp","tr","ts","un","jy",
|
||||
"uw","su","re","lvs","lp","yy","yp2","yp","jv","sl","pm"// new
|
||||
];
|
||||
|
||||
// Year validation rules (applies only if a year can be parsed from the source).
|
||||
// Watchtower (w/wp/ws) back to 1950; Awake (g) back to 1970.
|
||||
var YEAR_RULES = [
|
||||
const YEAR_RULES = [
|
||||
{ codes: ["w","wp","ws"], minYear: 1950 },
|
||||
{ codes: ["g"], minYear: 1970 }
|
||||
];
|
||||
@@ -49,10 +24,12 @@ window.SourceValidator = (function () {
|
||||
// Normalize helper
|
||||
function normalize(s) { return (s || "").trim().toLowerCase(); }
|
||||
|
||||
// Choose the longest matching code at the start (so "ws" beats "w").
|
||||
const PUB_CODES_SORTED = [...PUB_CODES].sort((a,b)=>b.length-a.length);
|
||||
|
||||
function leadingCode(textLower) {
|
||||
for (var i = 0; i < PUB_CODES_SORTED.length; i++) {
|
||||
var code = PUB_CODES_SORTED[i];
|
||||
if (textLower.indexOf(code) === 0) return code;
|
||||
for (const code of PUB_CODES_SORTED) {
|
||||
if (textLower.startsWith(code)) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -60,51 +37,60 @@ window.SourceValidator = (function () {
|
||||
// Try to extract a year that appears right after the leading code (allow spaces),
|
||||
// accepting either 4-digit (e.g., 1955, 2001) or 2-digit (e.g., 55, 95, 12) forms.
|
||||
function extractYearAfterCode(textLower, code) {
|
||||
var s = textLower.slice(code.length).trim();
|
||||
let s = textLower.slice(code.length).trim();
|
||||
|
||||
// 1) Look for a 4-digit year first (1800–2099)
|
||||
var m = s.match(/\b(1[89]\d{2}|20\d{2})\b/);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
// 1) Look for a 4-digit year first
|
||||
let m = s.match(/\b(1[89]\d{2}|20\d{2})\b/); // 1800-2099 (broad, but OK)
|
||||
if (m) {
|
||||
return parseInt(m[1], 10);
|
||||
}
|
||||
|
||||
// 2) If not found, accept a 2-digit year at the *start* of the remainder
|
||||
// (e.g., "w55 1/1", "w 95", "g70 1/22")
|
||||
// 2) If not found, accept a 2-digit year at the *start* of the remainder,
|
||||
// or right after an optional space: e.g., "w55 1/1", "w 95", "g70 1/22"
|
||||
m = s.match(/^\s*(\d{2})\b/);
|
||||
if (m) {
|
||||
var yy = parseInt(m[1], 10);
|
||||
if (code === "g") return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
||||
if (code === "w" || code === "wp" || code === "ws")
|
||||
return yy >= 50 ? (1900 + yy) : (2000 + yy);
|
||||
const yy = parseInt(m[1], 10);
|
||||
// Infer century based on publication + threshold logic
|
||||
// - For Watchtower: 50–99 -> 1950–1999; 00–49 -> 2000–2049
|
||||
// - For Awake: 70–99 -> 1970–1999; 00–69 -> 2000–2069
|
||||
if (code === "g") {
|
||||
return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
||||
}
|
||||
if (code === "w" || code === "wp" || code === "ws") {
|
||||
return yy >= 50 ? (1900 + yy) : (2000 + yy);
|
||||
}
|
||||
// For other pubs, if they ever include 2-digit years, assume 1900+yy≥70 else 2000+yy
|
||||
return yy >= 70 ? (1900 + yy) : (2000 + yy);
|
||||
}
|
||||
|
||||
// No recognizable year → don't enforce year limits
|
||||
return null;
|
||||
}
|
||||
|
||||
function passesYearRuleIfPresent(textLower, code) {
|
||||
var rule = null;
|
||||
for (var i=0;i<YEAR_RULES.length;i++){
|
||||
if (YEAR_RULES[i].codes.indexOf(code) !== -1) { rule = YEAR_RULES[i]; break; }
|
||||
}
|
||||
const rule = YEAR_RULES.find(r => r.codes.includes(code));
|
||||
if (!rule) return true; // no year rule for this pub
|
||||
|
||||
var y = extractYearAfterCode(textLower, code);
|
||||
const y = extractYearAfterCode(textLower, code);
|
||||
if (y == null) return true; // no year present → allow
|
||||
return y >= rule.minYear;
|
||||
}
|
||||
|
||||
function isWOLSource(text) {
|
||||
var t = normalize(text);
|
||||
const t = normalize(text);
|
||||
if (!t) return false;
|
||||
var code = leadingCode(t);
|
||||
|
||||
const code = leadingCode(t);
|
||||
if (!code) return false;
|
||||
|
||||
// If starts with a known pub code, it’s WOL-capable — but enforce year rules where applicable.
|
||||
return passesYearRuleIfPresent(t, code);
|
||||
}
|
||||
|
||||
function buildWOLSearchURL(text) {
|
||||
var q = encodeURIComponent(text || "");
|
||||
return "https://wol.jw.org/en/wol/l/r1/lp-e?q=" + q;
|
||||
const q = encodeURIComponent(text || "");
|
||||
return `https://wol.jw.org/en/wol/l/r1/lp-e?q=${q}`;
|
||||
}
|
||||
|
||||
return { isWOLSource: isWOLSource, buildWOLSearchURL: buildWOLSearchURL };
|
||||
return { isWOLSource, buildWOLSearchURL };
|
||||
})();
|
||||
@@ -11,7 +11,6 @@
|
||||
<span class="rt-count">Editing: #{{ entry.id }}</span>
|
||||
</div>
|
||||
<div class="rt-right">
|
||||
<a class="btn btn-danger" href="{% url 'entry_delete' entry.id %}">Delete</a>
|
||||
<a class="btn" href="{% url 'entry_view' entry.id %}">Cancel</a>
|
||||
<button form="entry-edit-form" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<!-- Edit/Delete for staff -->
|
||||
{% if user.is_authenticated and user.is_staff %}
|
||||
<a class="btn btn-outline" href="{% url 'entry_edit' entry.id %}">Edit</a>
|
||||
<a class="btn btn-danger" href="{% url 'entry_delete' entry.id %}">Delete</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+27
-72
@@ -3,95 +3,50 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Illustrations · Sign in</title>
|
||||
<title>Sign in · Illustrations</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="{% static 'app.css' %}">
|
||||
</head>
|
||||
|
||||
<body class="login-page">
|
||||
|
||||
<div class="login-hero">
|
||||
<div class="login-card">
|
||||
<h1 class="login-title">Sign in</h1>
|
||||
|
||||
<!-- App title instead of generic "Sign in" -->
|
||||
<h1 class="login-title">Illustrations Database Login</h1>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="login-alert">
|
||||
{% for e in form.non_field_errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="login-sso">
|
||||
<div class="login-box">
|
||||
<a href="{% url 'oidc_authentication_init' %}"
|
||||
class="btn btn-primary btn-lg login-sso-button">
|
||||
Log in with SSO
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'login' %}" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
|
||||
<!-- Legacy toggle -->
|
||||
<div class="login-divider" style="margin-top: 1.25rem;">
|
||||
<span>
|
||||
<a href="#"
|
||||
onclick="event.preventDefault(); document.getElementById('alt-login').toggleAttribute('hidden');">
|
||||
Use a different sign-in method
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<label for="id_username" class="login-label">Username</label>
|
||||
<input id="id_username" name="username" type="text" autocomplete="username"
|
||||
value="{{ form.username.value|default:'' }}" required autofocus class="login-input">
|
||||
|
||||
<!-- Legacy login (hidden by default) -->
|
||||
<div id="alt-login" hidden>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="login-alert">
|
||||
{% for e in form.non_field_errors %}
|
||||
{{ e }}{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
{% if form.username.errors %}
|
||||
<div class="login-field-error">
|
||||
{% for e in form.username.errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'login' %}" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% endif %}
|
||||
<label for="id_password" class="login-label">Password</label>
|
||||
<input id="id_password" name="password" type="password" autocomplete="current-password"
|
||||
required class="login-input">
|
||||
|
||||
<label for="id_username" class="login-label">Username</label>
|
||||
<input id="id_username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
value="{{ form.username.value|default:'' }}"
|
||||
class="login-input">
|
||||
|
||||
{% if form.username.errors %}
|
||||
<div class="login-field-error">
|
||||
{% for e in form.username.errors %}
|
||||
{{ e }}{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<label for="id_password" class="login-label">Password</label>
|
||||
<input id="id_password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="login-input">
|
||||
|
||||
{% if form.password.errors %}
|
||||
<div class="login-field-error">
|
||||
{% for e in form.password.errors %}
|
||||
{{ e }}{% if not forloop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn-primary btn-lg login-submit" type="submit">
|
||||
Log in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
{% if form.password.errors %}
|
||||
<div class="login-field-error">
|
||||
{% for e in form.password.errors %}{{ e }}{% if not forloop.last %}<br>{% endif %}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn-primary btn-lg login-submit" type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h3 id="release-title">{{ pending_announcement.title|default:"What’s new" }}</h3>
|
||||
<button class="modal-close" type="button" aria-label="Close" data-dismiss>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="prose">{{ pending_announcement.message|linebreaks }}</div>
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
<button class="btn btn-primary">Search</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop filters -->
|
||||
<div class="filter-row desktop-filters">
|
||||
<div class="filter-row">
|
||||
{% for f in field_options %}
|
||||
<label class="check-pill">
|
||||
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
|
||||
@@ -20,19 +19,6 @@
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile dropdown filters -->
|
||||
<div class="filter-row mobile-filters">
|
||||
<button type="button" id="filterDropdownBtn" class="btn btn-secondary" aria-expanded="false">Filters ▾</button>
|
||||
<div id="filterDropdownPanel" class="dropdown-panel">
|
||||
{% for f in field_options %}
|
||||
<label class="check-pill" style="display:block; margin:6px 0;">
|
||||
<input type="checkbox" name="{{ f.name }}" {% if f.checked %}checked{% endif %}>
|
||||
<span>{{ f.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if ran_search and result_count == 0 %}
|
||||
@@ -211,17 +197,6 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile filter dropdown toggle
|
||||
(function(){
|
||||
const btn = document.getElementById('filterDropdownBtn');
|
||||
const panel = document.getElementById('filterDropdownPanel');
|
||||
if (!btn || !panel) return;
|
||||
btn.addEventListener('click', ()=>{
|
||||
const open = panel.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
})();
|
||||
|
||||
// ===============================
|
||||
// No-results: show a random funny illustration
|
||||
// ===============================
|
||||
@@ -367,21 +342,6 @@
|
||||
padding:10px 16px;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
/* Mobile filter dropdown styling */
|
||||
.mobile-filters { display:none; margin-top:10px; }
|
||||
@media (max-width: 700px){
|
||||
.desktop-filters { display:none; }
|
||||
.mobile-filters { display:block; }
|
||||
#filterDropdownPanel {
|
||||
border:1px solid var(--border);
|
||||
background:#fff;
|
||||
border-radius:10px;
|
||||
margin-top:6px;
|
||||
padding:10px;
|
||||
}
|
||||
#filterDropdownBtn { width:100%; text-align:left; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Save q + selected fields exactly as submitted -->
|
||||
|
||||
@@ -122,16 +122,18 @@
|
||||
<h2 class="cc-panel-title">Release Announcement</h2>
|
||||
</div>
|
||||
<div class="cc-panel-body">
|
||||
<form id="announcement-form" method="post" action="{% url 'announcement_tools' %}" class="cc-form">
|
||||
<form method="post" action="{% url 'announcement_tools' %}" class="cc-form">
|
||||
{% csrf_token %}
|
||||
<label class="cc-label">Title</label>
|
||||
<input type="text" name="title" class="tool-input" id="annc-title" />
|
||||
<label class="cc-label">Message</label>
|
||||
<textarea name="message" rows="5" class="tool-input" id="annc-message" placeholder="What’s new…"></textarea>
|
||||
<!-- Always active -->
|
||||
<input type="hidden" name="is_active" value="on" />
|
||||
{% if announcement_form %}
|
||||
{{ announcement_form.as_p }}
|
||||
{% else %}
|
||||
<label class="cc-label">Title</label>
|
||||
<input type="text" name="title" class="tool-input">
|
||||
<label class="cc-label">Message</label>
|
||||
<textarea name="message" rows="5" class="tool-input" placeholder="What’s new…"></textarea>
|
||||
<label class="cc-check"><input type="checkbox" name="is_active" checked> Active</label>
|
||||
{% endif %}
|
||||
<div class="cc-actions">
|
||||
<button type="button" id="annc-preview-btn" class="btn">Preview</button>
|
||||
<button class="btn btn-primary">Publish</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -162,43 +164,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Publication Codes (JSON) – superusers only -->
|
||||
<section class="card cc-panel">
|
||||
<div class="cc-panel-head">
|
||||
<div class="cc-kicker">Comms</div>
|
||||
<h2 class="cc-panel-title">Publication Codes (JSON)</h2>
|
||||
</div>
|
||||
<div class="cc-panel-body">
|
||||
<div class="cc-row">
|
||||
<textarea id="pubCodesEditor" class="tool-input" rows="14"
|
||||
style="width:100%; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;"></textarea>
|
||||
</div>
|
||||
<div class="cc-actions">
|
||||
<button type="button" id="pubCodesReloadBtn" class="btn">Reload</button>
|
||||
<button type="button" id="pubCodesSaveBtn" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
<div id="pubCodesStatus" class="tiny" style="margin-top:6px; color:#64748b;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Announcement Preview Modal -->
|
||||
<div id="annc-preview-modal" style="position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:10000;background:rgba(15,23,42,.35);backdrop-filter:blur(2px);">
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="annc-prev-title" class="card" style="max-width:680px;width:min(92vw,680px);padding:18px 18px 12px;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,.25);background:#fff;position:relative;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
||||
<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#64748b;font-weight:700;">Announcement Preview</div>
|
||||
<button type="button" id="annc-prev-close" class="btn btn-secondary">Close</button>
|
||||
</div>
|
||||
<div style="border:1px solid #e5e7eb;border-radius:12px;padding:14px;background:linear-gradient(180deg,#ffffff, #fbfdff);">
|
||||
<div id="annc-prev-title" style="font-weight:800;font-size:18px;color:#0f172a;margin-bottom:6px;"></div>
|
||||
<div id="annc-prev-message" style="font-size:15px;line-height:1.45;color:#111827;white-space:pre-wrap;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast for Clear History -->
|
||||
<div id="toast-clear-history"
|
||||
style="position:fixed; right:16px; bottom:16px; padding:10px 14px; border-radius:10px;
|
||||
@@ -224,9 +193,6 @@
|
||||
.cc-label{display:block;font-weight:600;margin:8px 0 6px;color:#0f172a}
|
||||
.cc-actions{display:flex;align-items:center;gap:10px;margin-top:10px}
|
||||
.cc-form .tool-input{border:1px solid var(--border,#d1d5db);border-radius:10px;padding:8px 10px;font-size:14px}
|
||||
/* make announcement inputs full-width */
|
||||
.cc-form .tool-input{width:100%;}
|
||||
textarea.tool-input{min-height:140px;resize:vertical;width:100%;}
|
||||
/* Switch */
|
||||
.switch{position:relative;display:inline-block;width:46px;height:26px}
|
||||
.switch input{display:none}
|
||||
@@ -317,108 +283,6 @@ textarea.tool-input{min-height:140px;resize:vertical;width:100%;}
|
||||
.catch(()=>alert("Could not save the setting. Please try again."));
|
||||
});
|
||||
})();
|
||||
|
||||
// === Announcement Preview ===
|
||||
(function(){
|
||||
const form = document.getElementById('announcement-form');
|
||||
const btn = document.getElementById('annc-preview-btn');
|
||||
const modal = document.getElementById('annc-preview-modal');
|
||||
const close = document.getElementById('annc-prev-close');
|
||||
const titleI = document.getElementById('annc-title');
|
||||
const msgI = document.getElementById('annc-message');
|
||||
const titleO = document.getElementById('annc-prev-title');
|
||||
const msgO = document.getElementById('annc-prev-message');
|
||||
if (!form || !btn || !modal) return;
|
||||
|
||||
function openModal(){
|
||||
modal.style.display = 'flex';
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
}
|
||||
function closeModal(){
|
||||
modal.style.display = 'none';
|
||||
document.documentElement.style.overflow = '';
|
||||
}
|
||||
btn.addEventListener('click', function(){
|
||||
const t = (titleI?.value || '').trim();
|
||||
const m = (msgI?.value || '').trim();
|
||||
if (!m){
|
||||
alert('Please enter a Message to preview.');
|
||||
return;
|
||||
}
|
||||
titleO.textContent = t || 'Release Notes';
|
||||
msgO.textContent = m;
|
||||
openModal();
|
||||
});
|
||||
(close||modal).addEventListener('click', (ev)=>{
|
||||
if (ev.target === modal || ev.target === close) closeModal();
|
||||
});
|
||||
window.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && modal.style.display === 'flex') closeModal(); });
|
||||
})();
|
||||
|
||||
// === Publication Codes Editor (superuser) ===
|
||||
(function(){
|
||||
const ta = document.getElementById('pubCodesEditor');
|
||||
const btnR = document.getElementById('pubCodesReloadBtn');
|
||||
const btnS = document.getElementById('pubCodesSaveBtn');
|
||||
const stat = document.getElementById('pubCodesStatus');
|
||||
if (!ta || !btnR || !btnS) return; // not superuser or card missing
|
||||
|
||||
function setStatus(msg, ok=true){
|
||||
if (!stat) return;
|
||||
stat.textContent = msg;
|
||||
stat.style.color = ok ? '#64748b' : '#b91c1c';
|
||||
}
|
||||
|
||||
async function reloadJSON(){
|
||||
try{
|
||||
setStatus('Loading…');
|
||||
const r = await fetch('/static/data/wol-pub-codes.v1.json', { cache:'no-store' });
|
||||
if (!r.ok) throw new Error('HTTP '+r.status);
|
||||
const data = await r.json();
|
||||
ta.value = JSON.stringify(data, null, 2);
|
||||
setStatus('Loaded.');
|
||||
}catch(err){
|
||||
setStatus('Failed to load JSON: ' + (err?.message||err), false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveJSON(){
|
||||
let parsed;
|
||||
try{
|
||||
parsed = JSON.parse(ta.value);
|
||||
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.pub_codes))
|
||||
throw new Error('JSON must be an object with a "pub_codes" array.');
|
||||
}catch(err){
|
||||
setStatus('Invalid JSON: ' + (err?.message||err), false);
|
||||
return;
|
||||
}
|
||||
|
||||
try{
|
||||
setStatus('Saving…');
|
||||
const fd = new FormData();
|
||||
fd.append('json', JSON.stringify(parsed));
|
||||
const r = await fetch("{% url 'api_update_pub_codes' %}", {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': (document.cookie.match(/(^|;)\s*csrftoken\s*=\s*([^;]+)/)||[]).pop() || '' }
|
||||
});
|
||||
if (!r.ok) {
|
||||
const txt = await r.text().catch(()=>String(r.status));
|
||||
throw new Error(txt.slice(0,200));
|
||||
}
|
||||
const out = await r.json().catch(()=>({ok:false}));
|
||||
if (!out.ok) throw new Error('Server rejected the update.');
|
||||
setStatus('Saved. ' + out.count + ' codes.');
|
||||
}catch(err){
|
||||
setStatus('Save failed: ' + (err?.message||err), false);
|
||||
}
|
||||
}
|
||||
|
||||
btnR.addEventListener('click', reloadJSON);
|
||||
btnS.addEventListener('click', saveJSON);
|
||||
reloadJSON(); // initial load
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v4.1.24
|
||||
v4.1.22
|
||||
Reference in New Issue
Block a user