diff --git a/.DS_Store b/.DS_Store index d4c18cd..5f507ec 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/docker-compose.yml b/docker-compose.yml index 9fb575a..1bdff1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.9" services: db: image: postgres:16-alpine diff --git a/imports/.DS_Store b/imports/.DS_Store index ec73a88..2e5269f 100644 Binary files a/imports/.DS_Store and b/imports/.DS_Store differ diff --git a/imports/README.txt b/imports/README.txt deleted file mode 100644 index 0041a59..0000000 --- a/imports/README.txt +++ /dev/null @@ -1 +0,0 @@ -Place your seed file as illustrations_seed.csv here if you want auto-import on first boot. \ No newline at end of file diff --git a/web/.DS_Store b/web/.DS_Store deleted file mode 100644 index aa2b472..0000000 Binary files a/web/.DS_Store and /dev/null differ diff --git a/web/Dockerfile b/web/Dockerfile index d4cada0..c0326c7 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -7,4 +7,4 @@ COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ RUN chmod +x /app/entrypoint.sh -CMD ["bash","-lc","/app/entrypoint.sh"] \ No newline at end of file +CMD ["bash","-lc","/app/entrypoint.sh"] diff --git a/web/core/utils.py b/web/core/utils.py index 415f792..f9fc98a 100644 --- a/web/core/utils.py +++ b/web/core/utils.py @@ -1,13 +1,13 @@ import csv, io, re from dateutil import parser as dateparser from datetime import date -from .models import Entry, ScriptureRef SCR_REF_RE = re.compile(r"""^\s*([1-3]?\s*[A-Za-z\.]+)\s+(\d+)(?::(\d+))?(?:\s*[-–—]\s*(\d+)(?::(\d+))?)?\s*$""", re.VERBOSE) BOOK_ALIASES={'matt':'Matthew','mt':'Matthew','jn':'John','john':'John','lk':'Luke','luke':'Luke','ps':'Psalms'} def normalize_book(s): - b = re.sub(r"[\.\s]","", s).lower() + import re as _re + b = _re.sub(r"[.\s]","", s).lower() return BOOK_ALIASES.get(b, s.strip()) def parse_scripture(s): @@ -35,10 +35,11 @@ def import_csv_bytes(b: bytes, dry_run=True): if missing: raise ValueError(f"Missing required headers: {missing}") report={"rows":0,"inserted":0,"updated":0,"skipped":0,"errors":[],"scripture_parsed":0,"scripture_failed":0} rows=list(reader); report["rows"]=len(rows) + from core.models import Entry, ScriptureRef for row in rows: try: code=(row.get("code") or "").strip() - talk=row.get("talk number"); + talk=row.get("talk number") try: talk=int(talk) if str(talk).strip() else None except: talk=None data=dict( @@ -75,17 +76,12 @@ def import_csv_bytes(b: bytes, dry_run=True): report["skipped"]+=1; report["errors"].append(str(e)) return report -def wildcard_to_like(q:str)->str: - return q.replace("%","\%").replace("_","\_").replace("*","%").replace("?","_") - -def terms(q:str): return [t for t in q.split() if t.strip()] - -def month_buckets_last_12(today: date): - months=[]; y=today.year; m=today.month - for i in range(12): - mm=m-i; yy=y - while mm<=0: mm+=12; yy-=1 - start=date(yy,mm,1) - end=date(yy+1,1,1) if mm==12 else date(yy,mm+1,1) - months.append((f"{yy}-{mm:02d}", start, end)) - return list(reversed(months)) +# Tokenization with quoted phrases; wildcards tolerated but removed for icontains +_QUOTED_OR_WORD = re.compile(r'"([^"]+)"|(\S+)') +def terms(q: str): + out = [] + for m in _QUOTED_OR_WORD.finditer(q or ""): + token = (m.group(1) or m.group(2) or "").replace("*","").replace("?","").strip() + if token: + out.append(token) + return out diff --git a/web/core/views.py b/web/core/views.py index 6c6f803..6a20968 100644 --- a/web/core/views.py +++ b/web/core/views.py @@ -6,10 +6,9 @@ from django.contrib import messages from django.db.models import Q from datetime import date, timedelta import csv - from .models import Entry from .forms import ImportForm, EntryForm -from .utils import import_csv_bytes, wildcard_to_like, terms, month_buckets_last_12 +from .utils import import_csv_bytes, terms FIELD_ORDER=[ ("subject","Subject"), @@ -34,33 +33,52 @@ def login_view(request): ctx["error"]="Invalid credentials" return render(request,"login.html",ctx) +def entry_context(entry, result_ids): + count = len(result_ids or []) + if entry and result_ids and entry.id in result_ids: + position = result_ids.index(entry.id) + 1 + else: + position = 1 + subject_list = [t.strip() for t in (entry.subject or "").split(",") if t.strip()] + scripture_list = [t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] + return {"entry":entry,"locked":True,"position":position,"count":count, + "subject_list":subject_list,"scripture_list":scripture_list} + @login_required def search_page(request): default_fields={"subject":True,"illustration":True,"application":True, "scripture_raw":False,"source":False,"talk_title":False,"talk_number":False,"entry_code":False} - if request.method=="GET": - selected={k:(request.GET.get(k,"on" if v else "")!="") for k,v in default_fields.items()} - field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER] - q=request.GET.get("q","").strip() - if q: - term_list=terms(q) - qs=Entry.objects.all() - fields=[f for f,sel in selected.items() if sel] - for t in term_list: - clause=Q() - for f in fields: - clause |= Q(**{f+"__icontains": t.replace("*","").replace("?","")}) - qs=qs.filter(clause) - ids=list(qs.order_by("-date_added","-id").values_list("id", flat=True)) - request.session["result_ids"]=ids - if ids: - entry=Entry.objects.get(pk=ids[0]) - subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] - scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] - return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":1,"count":len(ids),"from_search":True}) + # robust checkbox parsing: present only if checked + form_submitted = ("q" in request.GET) or any(k in request.GET for k in default_fields) + if form_submitted: + selected={k:(k in request.GET) for k in default_fields} + else: + selected=default_fields.copy() + field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER] + + q=request.GET.get("q","").strip() + if q: + tokens = terms(q) # supports quoted phrases + fields = [f for f, sel in selected.items() if sel] + qs = Entry.objects.all() + # AND across tokens, OR across selected fields + for t in tokens: + clause = Q() + for f in fields: + clause |= Q(**{f + "__icontains": t}) + qs = qs.filter(clause) + ids = list(qs.order_by("-date_added","-id").values_list("id", flat=True)) + request.session["result_ids"]=ids + count=len(ids) + if count: + entry=Entry.objects.get(pk=ids[0]) + ctx=entry_context(entry, ids); ctx.update({"from_search":True}) + return render(request,"entry_view.html",ctx) total=Entry.objects.count() - return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total}) - return render(request,"search.html",{"selected":default_fields,"field_options":[{"name":k,"label":lbl,"checked":default_fields.get(k,False)} for k,lbl in FIELD_ORDER]}) + return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total,"ran_search":True,"result_count":0}) + + total=Entry.objects.count() + return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total,"ran_search":False}) @login_required def nav_next(request): @@ -68,9 +86,7 @@ def nav_next(request): if not ids: return redirect("search") idx=int(request.GET.get("i","0")); idx=min(idx+1, len(ids)-1) entry=get_object_or_404(Entry, pk=ids[idx]) - subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] - scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] - return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":idx+1,"count":len(ids)}) + return render(request,"entry_view.html",entry_context(entry, ids)) @login_required def nav_prev(request): @@ -78,21 +94,17 @@ def nav_prev(request): if not ids: return redirect("search") idx=int(request.GET.get("i","0")); idx=max(idx-1, 0) entry=get_object_or_404(Entry, pk=ids[idx]) - subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] - scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] - return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":idx+1,"count":len(ids)}) + return render(request,"entry_view.html",entry_context(entry, ids)) @login_required def entry_view(request, entry_id): + ids=request.session.get("result_ids",[]) entry=get_object_or_404(Entry, pk=entry_id) - ids=request.session.get("result_ids",[]); count=len(ids) - position=ids.index(entry.id)+1 if entry.id in ids else 1 - subject_list=[t.strip() for t in (entry.subject or "").split(",") if t.strip()] - scripture_list=[t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] - return render(request,"entry_view.html",{"entry":entry,"subject_list":subject_list,"scripture_list":scripture_list,"locked":True,"position":position,"count":count}) + return render(request,"entry_view.html",entry_context(entry, ids)) @login_required def entry_edit(request, entry_id): + ids=request.session.get("result_ids",[]) entry=get_object_or_404(Entry, pk=entry_id) if request.method=="POST": form=EntryForm(request.POST) @@ -101,8 +113,8 @@ def entry_edit(request, entry_id): entry.save(); messages.success(request,"Entry saved."); return redirect("entry_view", entry_id=entry.id) else: form=EntryForm(initial={"subject":entry.subject,"illustration":entry.illustration,"application":entry.application,"scripture_raw":entry.scripture_raw,"source":entry.source,"talk_number":entry.talk_number,"talk_title":entry.talk_title,"entry_code":entry.entry_code,"date_added":entry.date_added,"date_edited":entry.date_edited}) - ids=request.session.get("result_ids",[]); count=len(ids); position=ids.index(entry.id)+1 if entry.id in ids else 1 - return render(request,"entry_edit.html",{"entry":entry,"form":form,"position":position,"count":count}) + ctx={"entry":entry,"form":form}; ctx.update(entry_context(entry, ids)) + return render(request,"entry_edit.html",ctx) @login_required def entry_delete(request, entry_id): @@ -146,10 +158,21 @@ def stats_page(request): today=date.today() last30=Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count() last365=Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count() - buckets=month_buckets_last_12(today) - series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in buckets] - peak=max((v for _,v in series), default=1) - heights=[(label, value, 8 + int((value/peak)*100) if peak else 8) for label,value in series] + # last 12 months + months=[]; y=today.year; m=today.month + for i in range(12): + mm=m-i; yy=y + while mm<=0: mm+=12; yy-=1 + from datetime import date as _d + start=_d(yy,mm,1) + end=_d(yy+1,1,1) if mm==12 else _d(yy,mm+1,1) + label=f"{yy}-{mm:02d}" + months.append((label,start,end)) + months=list(reversed(months)) + series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in months] + peak = max((v for _, v in series), default=1) + heights=[(label, value, 8 + int((value/peak)*100) if peak else 8) for label, value in series] + # top subjects from collections import Counter counts=Counter() for subj in Entry.objects.exclude(subject="").values_list("subject", flat=True): diff --git a/web/entrypoint.sh b/web/entrypoint.sh old mode 100644 new mode 100755 diff --git a/web/illustrations/settings.py b/web/illustrations/settings.py index 499ffd0..bb27151 100644 --- a/web/illustrations/settings.py +++ b/web/illustrations/settings.py @@ -4,7 +4,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.getenv("DJANGO_SECRET_KEY","dev-insecure") DEBUG = os.getenv("DJANGO_DEBUG","False") == "True" -ALLOWED_HOSTS = [h.strip() for h in os.getenv("DJANGO_ALLOWED_HOSTS","*").split(",") if h.strip()] +ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS","*").split(",") CSRF_TRUSTED_ORIGINS = [x.strip() for x in os.getenv("CSRF_TRUSTED_ORIGINS","").split(",") if x.strip()] SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/web/templates/base.html b/web/templates/base.html index c2f541d..abaf042 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -1,45 +1,12 @@
- - -