feat(erindringer): familien fortæller minder og dagbog til en AI-interviewer
Nyt modul erindringer.py: emner (projekter) med baggrund og startspørgsmål, personer (indloggede + gæstelinks), Claude stiller opfølgende spørgsmål, ElevenLabs læser op (cachet) og transskriberer, ffmpeg laver mp3, admin med klip, favoritter og zip-eksport. Forsidekort og admin-modul. Røgtest i tests/. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
@@ -14,6 +14,28 @@ FastAPI-app der:
|
||||
- Matomo-statistik
|
||||
- App-beskrivelser & grupper (skriver tilbage til Authentik)
|
||||
|
||||
## Erindringer (interview + dagbog)
|
||||
|
||||
`/erindringer`: familien fortæller minder til en AI-interviewer. Claude (Anthropic
|
||||
Messages API, `ANTHROPIC_API_KEY`, model `ERINDRINGER_MODEL`) stiller ét spørgsmål ad gangen
|
||||
og følger op på det sidste svar; spørgsmålet læses op af ElevenLabs (`ELEVEN_API_KEY`,
|
||||
stemme `ERINDRINGER_VOICE`, cachet pr. tekst), svaret optages i browseren (MediaRecorder,
|
||||
webm/mp4), konverteres med ffmpeg til mp3 og transskriberes (ElevenLabs STT). Alt gemmes
|
||||
pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
|
||||
|
||||
- Indloggede i familiegrupperne har altid emnet "Dagbog"; admin inviterer til andre emner.
|
||||
- Gæster uden login får et hemmeligt link `/erindringer/g/<nøgle>/`, lavet i `/admin/erindringer`
|
||||
(kan lukkes og fornys). Nøglen sendes med i API-kald som `noegle`.
|
||||
- `/admin/erindringer`: emner (titel, beskrivelse, baggrund til intervieweren, startspørgsmål),
|
||||
personer, klip med afspiller og transskription, favoritter, zip-eksport til podcast-produktion.
|
||||
- Edge: gæstelink, `/erindringer/api/`, `/erindringer/tts/` og `/erindringer/klip/` skal uden om
|
||||
bot-udfordringen og have `client_max_body_size 50m` (lange lydsvar).
|
||||
- Uden nøgler virker siden stadig: intervieweren bruger emnets startspørgsmål, og svar gemmes
|
||||
uden transskription.
|
||||
|
||||
Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige
|
||||
API'er, koster lidt credits).
|
||||
|
||||
## Kør
|
||||
|
||||
```bash
|
||||
|
||||
@@ -111,6 +111,8 @@ ADMIN_MODULES = [
|
||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||
{"key": "podcast", "title": "Podcast", "icon": "🎙️",
|
||||
"desc": "Slægtens Stemmer: udgiv kladder og se/træk lytternes personlige feed-adresser tilbage."},
|
||||
{"key": "erindringer", "title": "Erindringer", "icon": "🎤",
|
||||
"desc": "Familien fortæller minder og dagbog til en AI-interviewer: projekter, gæstelinks, klip og eksport."},
|
||||
{"key": "claude-login", "title": "Claude-login", "icon": "🤖",
|
||||
"desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."},
|
||||
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
|
||||
|
||||
@@ -11,3 +11,9 @@ MATOMO_TOKEN=__matomo-token__
|
||||
MATOMO_SITE=2
|
||||
IP_LABELS_REMOTE=user@edge-host:/path/to/ip_labels.json
|
||||
PORTAL_DATA=/opt/portal/data
|
||||
# Erindringer (interview + dagbog): Claude stiller spørgsmål, ElevenLabs læser op og transskriberer.
|
||||
ANTHROPIC_API_KEY=__sk-ant-…__
|
||||
ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ellers tom
|
||||
ERINDRINGER_MODEL=claude-sonnet-5
|
||||
ELEVEN_API_KEY=__xi-…__
|
||||
ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS
|
||||
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
"""Erindringer: familien fortæller sine minder (eller fører dagbog) til en AI-interviewer.
|
||||
|
||||
Interviewer = Claude (Anthropic Messages API via httpx; ingen SDK), der stiller ét spørgsmål ad
|
||||
gangen og følger op på det, personen lige har sagt. Spørgsmålet læses op af ElevenLabs (cachet pr.
|
||||
tekst), svaret optages i browseren, transskriberes af ElevenLabs STT og gemmes som mp3 + tekst.
|
||||
Klippene bruges som råstof til podcasten (Slægtens Stemmer) og som personlig dagbog.
|
||||
|
||||
Data (ikke i git, persondata):
|
||||
DATA/erindringer/projekter.json slug -> titel, beskrivelse, kontekst (til Claude), start (spørgsmål)
|
||||
DATA/erindringer/personer.json pid -> navn, bruger | noegle, projekter, oprettet, sidst
|
||||
DATA/erindringer/klip/<pid>/<id>.mp3 svaret (mono 64 kbit)
|
||||
DATA/erindringer/klip/<pid>/<id>.json id, tid, projekt, samtale, spoergsmaal, tekst, varighed, favorit
|
||||
DATA/erindringer/tts/<sha>.mp3 oplæste spørgsmål
|
||||
|
||||
Personer: indloggede brugere har pid "u:<username>" og kan altid føre dagbog; gæster (familie uden
|
||||
login) får pid "g:<slug>" og en hemmelig nøgle i adressen, som podcasten gør det. Admin bestemmer,
|
||||
hvilke projekter en person inviteres til; projektet "dagbog" er altid åbent for indloggede.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
import config
|
||||
|
||||
ROOT = config.DATA / "erindringer"
|
||||
PROJEKTER = ROOT / "projekter.json"
|
||||
PERSONER = ROOT / "personer.json"
|
||||
KLIP = ROOT / "klip"
|
||||
TTS = ROOT / "tts"
|
||||
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$")
|
||||
KEY = re.compile(r"^[A-Za-z0-9_-]{32,64}$")
|
||||
KLIPID = re.compile(r"^[0-9]{8}-[0-9]{6}-[a-f0-9]{6}$")
|
||||
PID = re.compile(r"^[ug]:[A-Za-z0-9_.@-]{1,64}$")
|
||||
|
||||
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
ANTHROPIC_WORKSPACE = os.environ.get("ANTHROPIC_WORKSPACE_ID", "")
|
||||
MODEL = os.environ.get("ERINDRINGER_MODEL", "claude-sonnet-5")
|
||||
ELEVEN_KEY = os.environ.get("ELEVEN_API_KEY", "")
|
||||
VOICE = os.environ.get("ERINDRINGER_VOICE", "tJDFCHyviItsYF1qqToS") # Leif, podcastens fortæller
|
||||
MAX_SVAR_SEK = 15 * 60
|
||||
MAX_UPLOAD = 40 * 1024 * 1024
|
||||
|
||||
DAGBOG = {
|
||||
"titel": "Dagbog",
|
||||
"beskrivelse": "Hvad der fylder lige nu. Et par minutter, når du har lyst; interviewerens spørgsmål hjælper dig i gang.",
|
||||
"kontekst": "Dette er personens private dagbog. Spørg til hverdagen, til det der fylder, til små og store ting, "
|
||||
"og følg op på det, personen selv bringer på bane. Vær nysgerrig uden at være nærgående.",
|
||||
"start": ["Hvad har fyldt mest for dig i dag?", "Hvad har du tænkt på i denne uge?",
|
||||
"Er der sket noget siden sidst, du gerne vil huske?"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------- fil-hjælpere ----------------
|
||||
|
||||
def _læs(p: Path, standard):
|
||||
try:
|
||||
return json.loads(p.read_text())
|
||||
except (OSError, ValueError):
|
||||
return standard
|
||||
|
||||
|
||||
def _skriv(p: Path, data) -> None:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_name("." + p.name + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1))
|
||||
os.replace(tmp, p)
|
||||
|
||||
|
||||
def _nu() -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
||||
|
||||
|
||||
# ---------------- projekter ----------------
|
||||
|
||||
def projekter() -> dict:
|
||||
p = _læs(PROJEKTER, {})
|
||||
p.setdefault("dagbog", dict(DAGBOG))
|
||||
return p
|
||||
|
||||
|
||||
def projekt(slug: str) -> dict | None:
|
||||
if not SLUG.match(slug or ""):
|
||||
return None
|
||||
p = projekter().get(slug)
|
||||
return {**p, "slug": slug} if p else None
|
||||
|
||||
|
||||
def gem_projekt(slug: str, titel: str, beskrivelse: str, kontekst: str, start: list[str]) -> bool:
|
||||
if not SLUG.match(slug or "") or not titel.strip():
|
||||
return False
|
||||
p = projekter()
|
||||
gammel = p.get(slug, {})
|
||||
p[slug] = {"titel": titel.strip(), "beskrivelse": beskrivelse.strip(), "kontekst": kontekst.strip(),
|
||||
"start": [s.strip() for s in start if s.strip()], "oprettet": gammel.get("oprettet") or _nu()}
|
||||
_skriv(PROJEKTER, p)
|
||||
return True
|
||||
|
||||
|
||||
def slet_projekt(slug: str) -> bool:
|
||||
p = projekter()
|
||||
if slug == "dagbog" or slug not in p:
|
||||
return False
|
||||
del p[slug]
|
||||
_skriv(PROJEKTER, p)
|
||||
ps = personer()
|
||||
for v in ps.values():
|
||||
if slug in v.get("projekter", []):
|
||||
v["projekter"].remove(slug)
|
||||
_skriv(PERSONER, ps)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- personer ----------------
|
||||
|
||||
def personer() -> dict:
|
||||
return _læs(PERSONER, {})
|
||||
|
||||
|
||||
def person(pid: str) -> dict | None:
|
||||
if not PID.match(pid or ""):
|
||||
return None
|
||||
v = personer().get(pid)
|
||||
return {**v, "pid": pid} if v else None
|
||||
|
||||
|
||||
def person_for_bruger(username: str, navn: str) -> dict:
|
||||
"""Indlogget bruger: oprettes første gang, dagbog er altid med."""
|
||||
pid = f"u:{username}"
|
||||
ps = personer()
|
||||
v = ps.get(pid)
|
||||
if v is None:
|
||||
v = {"navn": navn or username, "bruger": username, "projekter": ["dagbog"], "oprettet": _nu()}
|
||||
ps[pid] = v
|
||||
else:
|
||||
if navn and v.get("navn") != navn:
|
||||
v["navn"] = navn
|
||||
if "dagbog" not in v.setdefault("projekter", []):
|
||||
v["projekter"].insert(0, "dagbog")
|
||||
v["sidst"] = _nu()
|
||||
_skriv(PERSONER, ps)
|
||||
return {**v, "pid": pid}
|
||||
|
||||
|
||||
def person_for_noegle(key: str) -> dict | None:
|
||||
if not KEY.match(key or ""):
|
||||
return None
|
||||
for pid, v in personer().items():
|
||||
if v.get("noegle") == key and not v.get("lukket"):
|
||||
v["sidst"] = _nu()
|
||||
ps = personer(); ps[pid]["sidst"] = v["sidst"]; _skriv(PERSONER, ps)
|
||||
return {**v, "pid": pid}
|
||||
return None
|
||||
|
||||
|
||||
def ny_gaest(navn: str, projekt_slugs: list[str]) -> dict:
|
||||
base = re.sub(r"[^a-z0-9]+", "-", navn.lower().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")).strip("-")[:30] or "gaest"
|
||||
ps = personer()
|
||||
slug, n = base, 2
|
||||
while f"g:{slug}" in ps:
|
||||
slug, n = f"{base}-{n}", n + 1
|
||||
pid = f"g:{slug}"
|
||||
ps[pid] = {"navn": navn.strip(), "noegle": secrets.token_urlsafe(32), "projekter": list(projekt_slugs),
|
||||
"oprettet": _nu()}
|
||||
_skriv(PERSONER, ps)
|
||||
return {**ps[pid], "pid": pid}
|
||||
|
||||
|
||||
def saet_projekter(pid: str, projekt_slugs: list[str]) -> bool:
|
||||
ps = personer()
|
||||
if pid not in ps:
|
||||
return False
|
||||
ps[pid]["projekter"] = [s for s in projekt_slugs if s in projekter()]
|
||||
if pid.startswith("u:") and "dagbog" not in ps[pid]["projekter"]:
|
||||
ps[pid]["projekter"].insert(0, "dagbog")
|
||||
_skriv(PERSONER, ps)
|
||||
return True
|
||||
|
||||
|
||||
def ny_noegle(pid: str) -> str | None:
|
||||
ps = personer()
|
||||
if pid not in ps or not pid.startswith("g:"):
|
||||
return None
|
||||
ps[pid]["noegle"] = secrets.token_urlsafe(32)
|
||||
ps[pid].pop("lukket", None)
|
||||
_skriv(PERSONER, ps)
|
||||
return ps[pid]["noegle"]
|
||||
|
||||
|
||||
def luk_gaest(pid: str, lukket: bool) -> bool:
|
||||
ps = personer()
|
||||
if pid not in ps or not pid.startswith("g:"):
|
||||
return False
|
||||
if lukket:
|
||||
ps[pid]["lukket"] = _nu()
|
||||
else:
|
||||
ps[pid].pop("lukket", None)
|
||||
_skriv(PERSONER, ps)
|
||||
return True
|
||||
|
||||
|
||||
def alle_personer() -> list[dict]:
|
||||
ud = []
|
||||
for pid, v in personer().items():
|
||||
ud.append({**v, "pid": pid, "antal": len(klip_for(pid))})
|
||||
return sorted(ud, key=lambda v: (v.get("sidst") or v.get("oprettet") or ""), reverse=True)
|
||||
|
||||
|
||||
def maa(p: dict, slug: str) -> bool:
|
||||
return slug in (p.get("projekter") or []) and projekt(slug) is not None
|
||||
|
||||
|
||||
# ---------------- klip ----------------
|
||||
|
||||
def _klipmappe(pid: str) -> Path:
|
||||
return KLIP / pid.replace(":", "_")
|
||||
|
||||
|
||||
def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = None) -> list[dict]:
|
||||
"""Klip for en person, ældste først."""
|
||||
d = _klipmappe(pid)
|
||||
if not PID.match(pid or "") or not d.exists():
|
||||
return []
|
||||
ud = []
|
||||
for meta in d.glob("*.json"):
|
||||
if not KLIPID.match(meta.stem):
|
||||
continue
|
||||
k = _læs(meta, None)
|
||||
if not k or not (d / f"{meta.stem}.mp3").exists():
|
||||
continue
|
||||
if projekt_slug and k.get("projekt") != projekt_slug:
|
||||
continue
|
||||
if samtale and k.get("samtale") != samtale:
|
||||
continue
|
||||
ud.append(k)
|
||||
return sorted(ud, key=lambda k: k["id"])
|
||||
|
||||
|
||||
def klip(pid: str, kid: str) -> dict | None:
|
||||
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||
return None
|
||||
k = _læs(_klipmappe(pid) / f"{kid}.json", None)
|
||||
return k if k and k.get("id") == kid else None
|
||||
|
||||
|
||||
def klip_fil(pid: str, kid: str) -> Path | None:
|
||||
if not PID.match(pid or "") or not KLIPID.match(kid or ""):
|
||||
return None
|
||||
p = _klipmappe(pid) / f"{kid}.mp3"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def _varighed(p: Path) -> int:
|
||||
try:
|
||||
ud = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(p)],
|
||||
capture_output=True, text=True, timeout=30).stdout.strip()
|
||||
return int(float(ud))
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return 0
|
||||
|
||||
|
||||
def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str,
|
||||
tekst: str | None) -> dict:
|
||||
"""Gem et svar: lyd konverteres til mp3 og transskriberes; ren tekst gemmes som det er.
|
||||
Returnerer klip-metadata. Kaster ValueError med besked til brugeren."""
|
||||
if not PID.match(pid or "") or not SLUG.match(projekt_slug or ""):
|
||||
raise ValueError("Ugyldig person eller projekt.")
|
||||
d = _klipmappe(pid)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
kid = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(3)
|
||||
mp3 = d / f"{kid}.mp3"
|
||||
varighed = 0
|
||||
if lyd:
|
||||
if len(lyd) > MAX_UPLOAD:
|
||||
raise ValueError("Optagelsen er for stor. Prøv at dele den op i to svar.")
|
||||
suffix = "." + (re.sub(r"[^a-z0-9]", "", (filnavn or "").rsplit(".", 1)[-1].lower()) or "webm")
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(lyd)
|
||||
raa = Path(tmp.name)
|
||||
try:
|
||||
r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(raa), "-vn", "-ac", "1", "-ar", "44100",
|
||||
"-c:a", "libmp3lame", "-b:a", "64k", str(mp3)], capture_output=True, text=True, timeout=300)
|
||||
if r.returncode or not mp3.exists():
|
||||
raise ValueError("Optagelsen kunne ikke læses. Prøv igen, eller skriv svaret i stedet.")
|
||||
varighed = _varighed(mp3)
|
||||
if varighed > MAX_SVAR_SEK:
|
||||
mp3.unlink(missing_ok=True)
|
||||
raise ValueError("Svaret er længere end et kvarter. Del det op, så bliver det også nemmere at bruge.")
|
||||
if tekst is None or not tekst.strip():
|
||||
tekst = transskriber(mp3)
|
||||
finally:
|
||||
raa.unlink(missing_ok=True)
|
||||
else:
|
||||
if not (tekst or "").strip():
|
||||
raise ValueError("Der kom hverken lyd eller tekst med.")
|
||||
# Tekstsvar: en stille mp3 på 1 s, så alle klip har samme form (afspiller + tekst).
|
||||
subprocess.run(["ffmpeg", "-y", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "1",
|
||||
"-c:a", "libmp3lame", "-b:a", "32k", str(mp3)], capture_output=True, timeout=60)
|
||||
meta = {"id": kid, "tid": _nu(), "projekt": projekt_slug, "samtale": samtale, "spoergsmaal": spoergsmaal.strip()[:600],
|
||||
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd}
|
||||
_skriv(d / f"{kid}.json", meta)
|
||||
return meta
|
||||
|
||||
|
||||
def ret_tekst(pid: str, kid: str, tekst: str) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return False
|
||||
k["tekst"] = tekst.strip()[:20000]
|
||||
k["redigeret"] = _nu()
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return True
|
||||
|
||||
|
||||
def saet_favorit(pid: str, kid: str, favorit: bool) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return False
|
||||
k["favorit"] = favorit
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return True
|
||||
|
||||
|
||||
def slet_klip(pid: str, kid: str) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return False
|
||||
d = _klipmappe(pid)
|
||||
(d / f"{kid}.json").unlink(missing_ok=True)
|
||||
(d / f"{kid}.mp3").unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------- ElevenLabs: tale ----------------
|
||||
|
||||
def transskriber(mp3: Path) -> str:
|
||||
if not ELEVEN_KEY:
|
||||
return ""
|
||||
try:
|
||||
with open(mp3, "rb") as f:
|
||||
r = httpx.post("https://api.elevenlabs.io/v1/speech-to-text", headers={"xi-api-key": ELEVEN_KEY},
|
||||
data={"model_id": "scribe_v2", "language_code": "da"},
|
||||
files={"file": (mp3.name, f, "audio/mpeg")}, timeout=600)
|
||||
if r.status_code == 200:
|
||||
return (r.json().get("text") or "").strip()
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def tts_sti(tekst: str) -> Path | None:
|
||||
"""Oplæst spørgsmål, cachet på tekstens hash; None hvis det ikke kunne laves."""
|
||||
tekst = (tekst or "").strip()
|
||||
if not tekst or not ELEVEN_KEY:
|
||||
return None
|
||||
sha = hashlib.sha256((VOICE + "|" + tekst).encode()).hexdigest()[:32]
|
||||
p = TTS / f"{sha}.mp3"
|
||||
if p.exists():
|
||||
return p
|
||||
TTS.mkdir(parents=True, exist_ok=True)
|
||||
body = {"text": tekst, "model_id": "eleven_v4", "language_code": "da",
|
||||
"voice_settings": {"stability": 0.5, "similarity_boost": 0.8, "style": 0.2, "use_speaker_boost": True}}
|
||||
try:
|
||||
r = httpx.post(f"https://api.elevenlabs.io/v1/text-to-speech/{VOICE}?output_format=mp3_44100_64",
|
||||
headers={"xi-api-key": ELEVEN_KEY}, json=body, timeout=120)
|
||||
if r.status_code == 200 and r.content[:3] in (b"ID3", b"\xff\xfb", b"\xff\xf3"):
|
||||
tmp = p.with_name("." + p.name + ".tmp")
|
||||
tmp.write_bytes(r.content)
|
||||
os.replace(tmp, p)
|
||||
return p
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def tts_fil(sha: str) -> Path | None:
|
||||
if not re.match(r"^[a-f0-9]{32}$", sha or ""):
|
||||
return None
|
||||
p = TTS / f"{sha}.mp3"
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
# ---------------- Claude: interviewer ----------------
|
||||
|
||||
SYSTEM = """Du er en varm, nysgerrig interviewer for en dansk familie. Du hjælper et familiemedlem med at
|
||||
fortælle sine minder (eller føre dagbog) ved at stille ÉT kort spørgsmål ad gangen på dansk.
|
||||
|
||||
Regler:
|
||||
- Spørg konkret og hverdagsnært: steder, personer, lyde, lugte, hvad man lavede, hvad man følte. Et spørgsmål er på højst to sætninger.
|
||||
- Følg op på det, personen lige har fortalt (navne, steder, ting de kun nævnte kort), før du skifter emne. Brug personens egne ord.
|
||||
- Led ikke vidnet: foreslå ikke svar, og ret ikke personen. Hvis noget i det sagte strider mod baggrunden, så spørg nysgerrigt i stedet for at påpege det.
|
||||
- Hold en rolig, respektfuld tone, ingen smiger, ingen udråbstegn, ingen "fantastisk". Skriv som man taler. Brug aldrig tankestreger.
|
||||
- Hvis personen virker træt, skriver kort eller siger de vil stoppe, så stil et let, afrundende spørgsmål og sæt "afslut" til true.
|
||||
- Gentag ikke et spørgsmål, der allerede er stillet i samtalen. Vend tilbage til startspørgsmålene, hvis samtalen går i stå.
|
||||
- Svar KUN med JSON: {"spoergsmaal": "...", "afslut": false}"""
|
||||
|
||||
|
||||
def _historik(pid: str, projekt_slug: str, samtale: str) -> tuple[list[dict], list[dict]]:
|
||||
"""(denne samtale, tidligere svar i projektet) som korte dicts til prompten."""
|
||||
alle = klip_for(pid, projekt_slug)
|
||||
denne = [k for k in alle if k.get("samtale") == samtale]
|
||||
tidligere = [k for k in alle if k.get("samtale") != samtale][-30:]
|
||||
return denne, tidligere
|
||||
|
||||
|
||||
def _kort(k: dict, n: int = 700) -> dict:
|
||||
return {"dato": (k.get("tid") or "")[:10], "spoergsmaal": k.get("spoergsmaal", ""),
|
||||
"svar": (k.get("tekst") or "")[:n]}
|
||||
|
||||
|
||||
async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str) -> tuple[str, bool]:
|
||||
"""Næste spørgsmål til personen i projektet. Falder tilbage til startspørgsmålene uden nøgle/ved fejl."""
|
||||
denne, tidligere = _historik(p["pid"], pr["slug"], samtale)
|
||||
stillede = {k.get("spoergsmaal") for k in denne + tidligere}
|
||||
start = [s for s in (pr.get("start") or DAGBOG["start"]) if s not in stillede] or list(pr.get("start") or DAGBOG["start"])
|
||||
if not denne:
|
||||
return start[0], False # samtalen åbner altid med et af projektets egne spørgsmål
|
||||
if not ANTHROPIC_KEY:
|
||||
return start[len(denne) % len(start)], False
|
||||
payload = {
|
||||
"person": {"navn": p.get("navn")},
|
||||
"projekt": {"titel": pr.get("titel"), "beskrivelse": pr.get("beskrivelse"), "baggrund": pr.get("kontekst", "")[:6000],
|
||||
"startspoergsmaal": pr.get("start") or []},
|
||||
"tidligere_samtaler": [_kort(k, 300) for k in tidligere],
|
||||
"denne_samtale": [_kort(k) for k in denne],
|
||||
"antal_svar_i_dag": len(denne),
|
||||
}
|
||||
headers = {"x-api-key": ANTHROPIC_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}
|
||||
if ANTHROPIC_WORKSPACE:
|
||||
headers["anthropic-workspace-id"] = ANTHROPIC_WORKSPACE
|
||||
body = {"model": MODEL, "max_tokens": 300, "system": SYSTEM,
|
||||
"messages": [{"role": "user", "content": "Her er samtalen indtil nu som JSON. Stil næste spørgsmål.\n\n"
|
||||
+ json.dumps(payload, ensure_ascii=False)}]}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
r = await c.post("https://api.anthropic.com/v1/messages", headers=headers, json=body)
|
||||
if r.status_code == 200:
|
||||
tekst = "".join(b.get("text", "") for b in r.json().get("content", []) if b.get("type") == "text")
|
||||
m = re.search(r"\{.*\}", tekst, re.S)
|
||||
if m:
|
||||
svar = json.loads(m.group(0))
|
||||
q = (svar.get("spoergsmaal") or "").strip().replace("—", ",").replace(" - ", ", ")
|
||||
if q:
|
||||
return q[:400], bool(svar.get("afslut"))
|
||||
except (httpx.HTTPError, ValueError):
|
||||
pass
|
||||
return start[len(denne) % len(start)], False
|
||||
|
||||
|
||||
def ny_samtale() -> str:
|
||||
return time.strftime("%Y%m%d") + "-" + secrets.token_hex(3)
|
||||
|
||||
|
||||
def eksport(pid: str) -> bytes:
|
||||
"""Zip med alle klip (mp3 + json) og en samlet tekstfil, til podcast-produktionen."""
|
||||
import io
|
||||
import zipfile
|
||||
k = klip_for(pid)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
linjer = []
|
||||
for x in k:
|
||||
f = klip_fil(pid, x["id"])
|
||||
if f:
|
||||
z.write(f, f"{x['id']}.mp3")
|
||||
z.writestr(f"{x['id']}.json", json.dumps(x, ensure_ascii=False, indent=1))
|
||||
linjer.append(f"## {x['id']} · {x.get('tid', '')[:16]} · {x.get('projekt')}{' · favorit' if x.get('favorit') else ''}\n"
|
||||
f"Spørgsmål: {x.get('spoergsmaal', '')}\n\n{x.get('tekst', '')}\n")
|
||||
z.writestr("alle_svar.md", "\n".join(linjer))
|
||||
return buf.getvalue()
|
||||
@@ -26,6 +26,7 @@ import mcp_gateway
|
||||
import secretdrop
|
||||
import claudelogin
|
||||
import podcast
|
||||
import erindringer
|
||||
|
||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||
@@ -149,7 +150,8 @@ async def dashboard_content(request: Request):
|
||||
"fav_apps": [a for a in by_name if a["slug"] in fav_set],
|
||||
"favorites": list(fav_set), "view": prefs["view"],
|
||||
"mc_servers": mc_servers, "disco_url": config.DISCO_PANEL_URL,
|
||||
"podcasts": _podcast_kort(user)})
|
||||
"podcasts": _podcast_kort(user),
|
||||
"erindringer": bool(is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or []))})
|
||||
|
||||
|
||||
def _podcast_kort(user) -> list[dict]:
|
||||
@@ -615,6 +617,253 @@ async def admin_podcast_udgiv(request: Request, name: str, slug: str):
|
||||
return RedirectResponse(f"/admin/podcast?saved={besked}", status_code=303)
|
||||
|
||||
|
||||
# ---------------- erindringer (interview + dagbog) ----------------
|
||||
# Indloggede (familie) bruger sessionen; gæster uden login bruger en hemmelig nøgle i
|
||||
# adressen og sender den med i API-kald ("noegle"). Begge ender som en "person" i modulet.
|
||||
|
||||
def _erindringer_person(request: Request, noegle: str = "") -> dict:
|
||||
user = current_user(request)
|
||||
if user and (is_admin(user) or config.PODCAST_GROUPS & set(user.get("groups") or [])):
|
||||
return erindringer.person_for_bruger(user["username"], user.get("name") or user["username"])
|
||||
if noegle:
|
||||
if podcast.blokeret(_klient_ip(request)):
|
||||
raise HTTPException(404)
|
||||
p = erindringer.person_for_noegle(noegle)
|
||||
if p:
|
||||
return p
|
||||
podcast.forkert(_klient_ip(request))
|
||||
raise HTTPException(404)
|
||||
if not user and request.method == "GET":
|
||||
nxt = request.url.path
|
||||
raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")})
|
||||
raise HTTPException(403, "Kun for familien")
|
||||
|
||||
|
||||
def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str):
|
||||
user = current_user(request)
|
||||
pr = []
|
||||
for slug in p.get("projekter") or []:
|
||||
x = erindringer.projekt(slug)
|
||||
if x:
|
||||
pr.append({**x, "antal": len(erindringer.klip_for(p["pid"], slug))})
|
||||
titler = {x["slug"]: x["titel"] for x in pr}
|
||||
seneste = list(reversed(erindringer.klip_for(p["pid"])))[:20]
|
||||
for k in seneste:
|
||||
k["projekt_titel"] = titler.get(k.get("projekt"), k.get("projekt"))
|
||||
return templates.TemplateResponse(request, "erindringer.html", {
|
||||
"request": request, "user": user, "is_admin": is_admin(user), "navn": p.get("navn") or "",
|
||||
"noegle": noegle, "rod": rod, "api": "/erindringer/api", "projekter": pr, "klip_seneste": seneste,
|
||||
"saved": saved, "valgt": valgt if valgt in titler else ""})
|
||||
|
||||
|
||||
@app.get("/erindringer", response_class=HTMLResponse)
|
||||
async def erindringer_side(request: Request, saved: str = "", valgt: str = ""):
|
||||
p = _erindringer_person(request)
|
||||
return _erindringer_side(request, p, "/erindringer", "", saved, valgt)
|
||||
|
||||
|
||||
@app.get("/erindringer/g/{key}/", response_class=HTMLResponse)
|
||||
async def erindringer_gaest(request: Request, key: str, saved: str = "", valgt: str = ""):
|
||||
p = _erindringer_person(request, key)
|
||||
r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, saved, valgt)
|
||||
r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
r.headers["Referrer-Policy"] = "no-referrer"
|
||||
return r
|
||||
|
||||
|
||||
@app.get("/erindringer/tts/{sha}.mp3")
|
||||
async def erindringer_tts(sha: str):
|
||||
p = erindringer.tts_fil(sha)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
return FileResponse(p, media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
def _klip_svar(pid: str, kid: str):
|
||||
f = erindringer.klip_fil(pid, kid)
|
||||
if not f:
|
||||
raise HTTPException(404)
|
||||
return FileResponse(f, media_type="audio/mpeg", headers={"Cache-Control": "private, no-store"})
|
||||
|
||||
|
||||
@app.get("/erindringer/klip/{kid}.mp3")
|
||||
async def erindringer_klip(request: Request, kid: str):
|
||||
p = _erindringer_person(request)
|
||||
return _klip_svar(p["pid"], kid)
|
||||
|
||||
|
||||
@app.get("/erindringer/g/{key}/klip/{kid}.mp3")
|
||||
async def erindringer_gaest_klip(request: Request, key: str, kid: str):
|
||||
p = _erindringer_person(request, key)
|
||||
return _klip_svar(p["pid"], kid)
|
||||
|
||||
|
||||
def _fejl(status: int, tekst: str):
|
||||
return JSONResponse({"fejl": tekst}, status_code=status)
|
||||
|
||||
|
||||
@app.post("/erindringer/api/naeste")
|
||||
async def erindringer_naeste(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return _fejl(400, "Ugyldigt kald.")
|
||||
p = _erindringer_person(request, str(data.get("noegle") or ""))
|
||||
slug = str(data.get("projekt") or "")
|
||||
if not erindringer.maa(p, slug):
|
||||
return _fejl(403, "Det emne er ikke åbent for dig.")
|
||||
pr = erindringer.projekt(slug)
|
||||
samtale = str(data.get("samtale") or "")
|
||||
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
q, afslut = await erindringer.naeste_spoergsmaal(p, pr, samtale)
|
||||
lyd = await asyncio.to_thread(erindringer.tts_sti, q)
|
||||
return {"samtale": samtale, "spoergsmaal": q, "afslut": afslut,
|
||||
"lyd_url": f"/erindringer/tts/{lyd.stem}.mp3" if lyd else None,
|
||||
"nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/svar")
|
||||
async def erindringer_svar(request: Request):
|
||||
form = await request.form()
|
||||
p = _erindringer_person(request, str(form.get("noegle") or ""))
|
||||
slug = str(form.get("projekt") or "")
|
||||
if not erindringer.maa(p, slug):
|
||||
return _fejl(403, "Det emne er ikke åbent for dig.")
|
||||
samtale = str(form.get("samtale") or "")
|
||||
if not re.match(r"^[0-9]{8}-[a-f0-9]{6}$", samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
lyd = form.get("lyd")
|
||||
data, filnavn = None, ""
|
||||
if lyd is not None and hasattr(lyd, "read"):
|
||||
data = await lyd.read()
|
||||
filnavn = getattr(lyd, "filename", "") or ""
|
||||
if not data:
|
||||
data = None
|
||||
tekst = str(form.get("tekst") or "")
|
||||
try:
|
||||
meta = await asyncio.to_thread(erindringer.gem_svar, p["pid"], slug, samtale, str(form.get("spoergsmaal") or ""),
|
||||
data, filnavn, tekst or None)
|
||||
except ValueError as e:
|
||||
return _fejl(400, str(e))
|
||||
return {"id": meta["id"], "tekst": meta["tekst"], "varighed": meta["varighed"], "samtale": samtale}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/ret")
|
||||
async def erindringer_ret(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return _fejl(400, "Ugyldigt kald.")
|
||||
p = _erindringer_person(request, str(data.get("noegle") or ""))
|
||||
if not erindringer.ret_tekst(p["pid"], str(data.get("id") or ""), str(data.get("tekst") or "")):
|
||||
return _fejl(404, "Svaret findes ikke.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/admin/erindringer", response_class=HTMLResponse)
|
||||
async def admin_erindringer(request: Request, saved: str = "", ny: str = ""):
|
||||
user = require_admin(request)
|
||||
pr = erindringer.projekter()
|
||||
return templates.TemplateResponse(request, "admin_erindringer.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "ny": ny,
|
||||
"base": str(request.base_url).rstrip("/"),
|
||||
"projekter": [{**v, "slug": k} for k, v in pr.items()],
|
||||
"personer": erindringer.alle_personer(),
|
||||
"har_claude": bool(erindringer.ANTHROPIC_KEY), "har_eleven": bool(erindringer.ELEVEN_KEY), "model": erindringer.MODEL})
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/projekt")
|
||||
async def admin_erindringer_projekt(request: Request):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
slug = re.sub(r"[^a-z0-9-]", "-", str(form.get("slug") or "").strip().lower()).strip("-")[:40]
|
||||
if str(form.get("handling") or "") == "slet":
|
||||
erindringer.slet_projekt(slug)
|
||||
return RedirectResponse("/admin/erindringer?saved=Projektet+er+slettet", status_code=303)
|
||||
start = [s for s in str(form.get("start") or "").splitlines()]
|
||||
if not erindringer.gem_projekt(slug, str(form.get("titel") or ""), str(form.get("beskrivelse") or ""),
|
||||
str(form.get("kontekst") or ""), start):
|
||||
return RedirectResponse("/admin/erindringer?saved=Projektet+skal+have+et+navn+(a-z,+0-9,+bindestreg)+og+en+titel", status_code=303)
|
||||
return RedirectResponse(f"/admin/erindringer?saved=Projektet+{urllib.parse.quote(slug)}+er+gemt", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/gaest")
|
||||
async def admin_erindringer_gaest(request: Request):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
navn = str(form.get("navn") or "").strip()[:80]
|
||||
if not navn:
|
||||
return RedirectResponse("/admin/erindringer?saved=Skriv+et+navn", status_code=303)
|
||||
p = erindringer.ny_gaest(navn, [str(x) for x in form.getlist("projekter")])
|
||||
return RedirectResponse(f"/admin/erindringer?ny={urllib.parse.quote(p['pid'])}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/person/{pid}")
|
||||
async def admin_erindringer_person_save(request: Request, pid: str):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
handling = str(form.get("handling") or "")
|
||||
if handling == "projekter":
|
||||
erindringer.saet_projekter(pid, [str(x) for x in form.getlist("projekter")])
|
||||
besked = "Emnerne er gemt"
|
||||
elif handling == "ny-noegle":
|
||||
erindringer.ny_noegle(pid); besked = "Nyt link lavet; det gamle virker ikke mere"
|
||||
elif handling == "luk":
|
||||
erindringer.luk_gaest(pid, True); besked = "Linket er lukket"
|
||||
elif handling == "aabn":
|
||||
erindringer.luk_gaest(pid, False); besked = "Linket er åbnet igen"
|
||||
else:
|
||||
besked = "Ukendt handling"
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}?saved={urllib.parse.quote(besked)}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/erindringer/p/{pid}", response_class=HTMLResponse)
|
||||
async def admin_erindringer_person(request: Request, pid: str, saved: str = ""):
|
||||
user = require_admin(request)
|
||||
p = erindringer.person(pid)
|
||||
if not p:
|
||||
raise HTTPException(404)
|
||||
pr = erindringer.projekter()
|
||||
k = list(reversed(erindringer.klip_for(pid)))
|
||||
for x in k:
|
||||
x["projekt_titel"] = (pr.get(x.get("projekt")) or {}).get("titel", x.get("projekt"))
|
||||
return templates.TemplateResponse(request, "admin_erindringer_person.html", {
|
||||
"request": request, "user": user, "is_admin": True, "saved": saved, "p": p, "klip": k,
|
||||
"base": str(request.base_url).rstrip("/"), "projekter": [{**v, "slug": s} for s, v in pr.items()]})
|
||||
|
||||
|
||||
@app.get("/admin/erindringer/p/{pid}/klip/{kid}.mp3")
|
||||
async def admin_erindringer_klip(request: Request, pid: str, kid: str):
|
||||
require_admin(request)
|
||||
return _klip_svar(pid, kid)
|
||||
|
||||
|
||||
@app.post("/admin/erindringer/p/{pid}/klip/{kid}")
|
||||
async def admin_erindringer_klip_post(request: Request, pid: str, kid: str):
|
||||
require_admin(request)
|
||||
form = await request.form()
|
||||
handling = str(form.get("handling") or "")
|
||||
if handling == "favorit":
|
||||
erindringer.saet_favorit(pid, kid, str(form.get("favorit") or "") == "1")
|
||||
elif handling == "slet":
|
||||
erindringer.slet_klip(pid, kid)
|
||||
elif handling == "ret":
|
||||
erindringer.ret_tekst(pid, kid, str(form.get("tekst") or ""))
|
||||
return RedirectResponse(f"/admin/erindringer/p/{urllib.parse.quote(pid)}#k-{kid}", status_code=303)
|
||||
|
||||
|
||||
@app.get("/admin/erindringer/p/{pid}/eksport.zip")
|
||||
async def admin_erindringer_eksport(request: Request, pid: str):
|
||||
require_admin(request)
|
||||
if not erindringer.person(pid):
|
||||
raise HTTPException(404)
|
||||
data = await asyncio.to_thread(erindringer.eksport, pid)
|
||||
navn = pid.replace(":", "_")
|
||||
return Response(data, media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="erindringer-{navn}.zip"'})
|
||||
|
||||
|
||||
@app.get("/admin/claude-login", response_class=HTMLResponse)
|
||||
async def admin_claude_login(request: Request):
|
||||
user = require_admin(request)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — erindringer{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||
<h1>Erindringer</h1>
|
||||
<p class="wt-lead">Familien fortæller minder til en AI-interviewer, der stiller ét spørgsmål ad gangen og følger op. Svarene gemmes som lyd og tekst pr. person og kan hentes som zip til podcasten. Indloggede i familiegruppen har altid "Dagbog" på <a href="/erindringer">/erindringer</a>; gæster uden login får et hemmeligt link, som du laver her.</p>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
{% if not har_claude or not har_eleven %}
|
||||
<div class="wt-flash">⚠️ {% if not har_claude %}ANTHROPIC_API_KEY mangler i /etc/portal.env: intervieweren falder tilbage til projektets startspørgsmål.{% endif %}
|
||||
{% if not har_eleven %}ELEVEN_API_KEY mangler: ingen oplæsning og ingen transskription (svar gemmes kun som lyd).{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if ny %}
|
||||
{% for p in personer if p.pid == ny %}
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;margin:1rem 0;border-color:var(--color-accent)">
|
||||
<p style="margin:0 0 .5rem"><strong>Link til {{ p.navn }}</strong>, send det privat (Messenger, sms). Linket er personligt og kan lukkes igen.</p>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<input class="wt-input" readonly id="ny-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('ny-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:1.6rem">Personer</h2>
|
||||
<table class="wt-table">
|
||||
<thead><tr><th>Navn</th><th>Adgang</th><th>Emner</th><th>Svar</th><th>Sidst</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in personer %}
|
||||
<tr>
|
||||
<td><a href="/admin/erindringer/p/{{ p.pid }}">{{ p.navn }}</a></td>
|
||||
<td>{% if p.bruger %}login ({{ p.bruger }}){% elif p.lukket %}<span class="wt-tag muted">gæstelink lukket</span>{% else %}gæstelink{% endif %}</td>
|
||||
<td>{{ p.projekter|join(', ') }}</td>
|
||||
<td>{{ p.antal }}</td>
|
||||
<td>{{ (p.sidst or p.oprettet or '')[:10] }}</td>
|
||||
<td><a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}">Åbn</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="wt-muted">Ingen endnu. Indloggede dukker op, når de åbner /erindringer første gang.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Nyt gæstelink (uden login)</h2>
|
||||
<form method="post" action="/admin/erindringer/gaest" class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0">
|
||||
<label>Navn (som intervieweren bruger)<br><input class="wt-input" name="navn" required maxlength="80" style="width:100%"></label>
|
||||
<p style="margin:.8rem 0 .3rem">Emner, personen må fortælle om:</p>
|
||||
{% for pr in projekter %}
|
||||
<label style="display:block;margin:.2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug != 'dagbog' %}checked{% endif %}> {{ pr.titel }} <span class="wt-muted">({{ pr.slug }})</span></label>
|
||||
{% endfor %}
|
||||
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Lav link</button></p>
|
||||
</form>
|
||||
|
||||
<h2 style="font-size:1.15rem;margin-top:2rem">Emner (projekter)</h2>
|
||||
<p class="wt-muted" style="margin:.3rem 0 .8rem">Baggrunden gives til intervieweren, så spørgsmålene bliver konkrete (navne, steder, årstal, hvad vi allerede ved). Startspørgsmålene åbner hver samtale; derefter improviserer intervieweren ({{ model }}). "dagbog" er indbygget og kan ikke slettes, men teksten kan rettes.</p>
|
||||
{% for pr in projekter %}
|
||||
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
|
||||
<summary style="cursor:pointer"><strong>{{ pr.titel }}</strong> <span class="wt-muted">({{ pr.slug }}) · {{ pr.start|length }} startspørgsmål</span></summary>
|
||||
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
|
||||
<input type="hidden" name="slug" value="{{ pr.slug }}">
|
||||
<label>Titel<br><input class="wt-input" name="titel" value="{{ pr.titel }}" required style="width:100%"></label>
|
||||
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%">{{ pr.beskrivelse }}</textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren (vises ikke til personen)<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%">{{ pr.kontekst }}</textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Startspørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%">{{ pr.start|join('\n') }}</textarea></label>
|
||||
<p style="margin:.8rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<button class="wt-btn wt-btn--primary" type="submit">Gem</button>
|
||||
{% if pr.slug != 'dagbog' %}<button class="wt-btn wt-btn--danger" type="submit" name="handling" value="slet" onclick="return confirm('Slet emnet {{ pr.slug }}? Svarene bliver liggende hos personerne.')">Slet emne</button>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
</details>
|
||||
{% endfor %}
|
||||
<details class="wt-card" style="padding:.8rem 1.2rem;max-width:48rem;margin:.5rem 0">
|
||||
<summary style="cursor:pointer"><strong>Nyt emne</strong></summary>
|
||||
<form method="post" action="/admin/erindringer/projekt" style="margin:.8rem 0 0">
|
||||
<label>Kort navn (a-z, 0-9, bindestreg)<br><input class="wt-input" name="slug" required pattern="[a-z0-9][a-z0-9-]{0,40}" style="width:100%" placeholder="fx ep10-foraeldrene"></label>
|
||||
<label style="display:block;margin-top:.6rem">Titel<br><input class="wt-input" name="titel" required style="width:100%"></label>
|
||||
<label style="display:block;margin-top:.6rem">Beskrivelse (vises til personen)<br><textarea class="wt-input" name="beskrivelse" rows="2" style="width:100%"></textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Baggrund til intervieweren<br><textarea class="wt-input" name="kontekst" rows="8" style="width:100%"></textarea></label>
|
||||
<label style="display:block;margin-top:.6rem">Startspørgsmål, ét pr. linje<br><textarea class="wt-input" name="start" rows="5" style="width:100%"></textarea></label>
|
||||
<p style="margin:.8rem 0 0"><button class="wt-btn wt-btn--primary" type="submit">Opret</button></p>
|
||||
</form>
|
||||
</details>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin — erindringer: {{ p.navn }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="/admin/erindringer" class="wt-muted">← Erindringer</a></p>
|
||||
<h1>{{ p.navn }}</h1>
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
|
||||
<div class="wt-card" style="padding:1rem 1.2rem;max-width:48rem;margin:0 0 1.2rem">
|
||||
{% if p.bruger %}
|
||||
<p style="margin:0 0 .6rem">Logger ind som <strong>{{ p.bruger }}</strong> og bruger <a href="/erindringer">/erindringer</a>.</p>
|
||||
{% else %}
|
||||
<p style="margin:0 0 .6rem"><strong>Gæstelink</strong>{% if p.lukket %} <span class="wt-tag muted">lukket</span>{% endif %}</p>
|
||||
{% if not p.lukket %}
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-bottom:.8rem">
|
||||
<input class="wt-input" readonly id="g-link" value="{{ base }}/erindringer/g/{{ p.noegle }}/" style="flex:1 1 22rem;font-family:ui-monospace,monospace;font-size:.85rem" onclick="this.select()">
|
||||
<button class="wt-btn" type="button" onclick="var i=document.getElementById('g-link');i.select();navigator.clipboard&&navigator.clipboard.writeText(i.value);this.textContent='Kopieret'">Kopiér</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="display:flex;gap:.5rem;flex-wrap:wrap;margin:0 0 .8rem">
|
||||
<button class="wt-btn" name="handling" value="ny-noegle" onclick="return confirm('Nyt link? Det gamle holder op med at virke.')">Nyt link</button>
|
||||
{% if p.lukket %}<button class="wt-btn" name="handling" value="aabn">Åbn igen</button>{% else %}<button class="wt-btn wt-btn--danger" name="handling" value="luk">Luk linket</button>{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/erindringer/person/{{ p.pid }}" style="margin:0">
|
||||
<input type="hidden" name="handling" value="projekter">
|
||||
<p style="margin:.2rem 0 .3rem">Emner:</p>
|
||||
{% for pr in projekter %}
|
||||
<label style="display:inline-block;margin:.2rem .8rem .2rem 0"><input type="checkbox" name="projekter" value="{{ pr.slug }}" {% if pr.slug in p.projekter %}checked{% endif %}{% if p.bruger and pr.slug == 'dagbog' %} disabled checked{% endif %}> {{ pr.titel }}</label>
|
||||
{% endfor %}
|
||||
<p style="margin:.6rem 0 0"><button class="wt-btn" type="submit">Gem emner</button>
|
||||
<a class="wt-btn" href="/admin/erindringer/p/{{ p.pid }}/eksport.zip">Hent alt som zip</a></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">{{ klip|length }} svar</h2>
|
||||
{% for k in klip %}
|
||||
<div class="wt-card" id="k-{{ k.id }}" style="padding:.9rem 1.2rem;margin:.6rem 0;max-width:48rem{% if k.favorit %};border-color:var(--color-accent){% endif %}">
|
||||
<div class="wt-muted" style="font-size:.85rem">{{ k.tid[:16]|replace('T',' ') }} · {{ k.projekt_titel }} · {% if k.skrevet %}skrevet{% else %}{{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}{% if k.redigeret %} · tekst rettet{% endif %} · <span style="font-family:ui-monospace,monospace">{{ k.id }}</span></div>
|
||||
<p style="margin:.4rem 0"><strong>{{ k.spoergsmaal }}</strong></p>
|
||||
{% if not k.skrevet %}<audio controls preload="none" src="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}.mp3" style="width:100%;max-width:100%"></audio>{% endif %}
|
||||
<form method="post" action="/admin/erindringer/p/{{ p.pid }}/klip/{{ k.id }}" style="margin:.5rem 0 0">
|
||||
<textarea class="wt-input" name="tekst" rows="4" style="width:100%">{{ k.tekst }}</textarea>
|
||||
<p style="margin:.5rem 0 0;display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<button class="wt-btn" name="handling" value="ret">Gem tekst</button>
|
||||
<button class="wt-btn{% if k.favorit %} wt-btn--primary{% endif %}" name="handling" value="favorit" onclick="this.form.favorit.value='{{ '0' if k.favorit else '1' }}'">{{ '★ Favorit' if k.favorit else '☆ Markér som favorit' }}</button>
|
||||
<input type="hidden" name="favorit" value="1">
|
||||
<button class="wt-btn wt-btn--danger" name="handling" value="slet" onclick="return confirm('Slet svaret for altid?')">Slet</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu.</p>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
||||
{% if podcasts %}
|
||||
{% if podcasts or erindringer %}
|
||||
<section class="appgroup" id="podsec">
|
||||
<h2>🎙️ Familiens podcast</h2>
|
||||
<div class="grid">
|
||||
@@ -24,6 +24,14 @@
|
||||
<div class="go">Lyt →</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if erindringer %}
|
||||
<div class="card" data-search="erindringer dagbog minder interview">
|
||||
<a class="card-link" href="/erindringer" aria-label="Erindringer"></a>
|
||||
<div class="head"><span class="ico">🎤</span><span class="name">Erindringer</span></div>
|
||||
<div class="sub">Fortæl dine minder eller før dagbog. En interviewer stiller spørgsmålene.</div>
|
||||
<div class="go">Fortæl →</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Erindringer{% endblock %}
|
||||
{% block body %}
|
||||
<style>
|
||||
/* Ingen vandret scroll på iPhone: alt holder sig inden for kortet, og felter
|
||||
har mindst 16 px skrift, ellers zoomer iOS ind. */
|
||||
html, body { overflow-x: hidden; }
|
||||
.er-side { max-width: 100%; overflow-wrap: anywhere; }
|
||||
.er-side audio, .er-side textarea, .er-side .wt-card { max-width: 100%; box-sizing: border-box; }
|
||||
.er-side audio { display: block; width: 100%; min-width: 0; }
|
||||
.er-side textarea { font-size: 16px !important; min-width: 0; min-height: 7rem; line-height: 1.5; }
|
||||
.er-side .wt-btn { white-space: normal; }
|
||||
|
||||
/* Store, rolige knapper til ældre fingre */
|
||||
.er-stor { font-size: 1.1rem; padding: .85rem 1.4rem; min-height: 3.2rem; }
|
||||
.er-knapper { display: flex; flex-wrap: wrap; gap: .7rem; justify-content: center; margin-top: 1rem; }
|
||||
.er-kort { margin: 1rem 0; padding: 1.1rem 1.2rem; }
|
||||
.er-emne { display: flex; flex-direction: column; gap: .5rem; }
|
||||
.er-emne h3 { margin: 0; font-size: 1.25rem; }
|
||||
.er-emne .wt-btn { align-self: flex-start; }
|
||||
|
||||
/* Spørgsmålet: stor skrift, god linjeafstand */
|
||||
.er-spoergsmaal { font-size: 1.3rem; line-height: 1.5; margin: .3rem 0 .8rem; font-weight: 600; }
|
||||
|
||||
/* Den runde optage-knap */
|
||||
.er-midt { display: flex; flex-direction: column; align-items: center; gap: .8rem; margin: 1.4rem 0; text-align: center; }
|
||||
.er-optag {
|
||||
width: 7.5rem; height: 7.5rem; min-width: 88px; min-height: 88px; border-radius: 50%;
|
||||
border: 3px solid var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
color: inherit; font: inherit; font-size: 1.2rem; font-weight: 700; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .15rem;
|
||||
transition: transform .15s, background .2s;
|
||||
}
|
||||
.er-optag:hover { transform: scale(1.04); }
|
||||
.er-optag:disabled { opacity: .5; cursor: default; transform: none; }
|
||||
.er-optag.optager {
|
||||
background: var(--color-bad); border-color: var(--color-bad); color: #fff;
|
||||
animation: er-puls 1.3s ease-in-out infinite;
|
||||
}
|
||||
.er-optag .er-taeller { font-size: .95rem; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
@keyframes er-puls {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-bad) 55%, transparent); }
|
||||
50% { box-shadow: 0 0 0 18px transparent; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .er-optag.optager { animation: none; } }
|
||||
|
||||
.er-status { min-height: 1.5rem; font-size: 1.05rem; color: var(--color-muted); }
|
||||
.er-status.travl { color: var(--color-accent); font-weight: 600; }
|
||||
.er-link { background: none; border: 0; padding: .4rem; color: var(--color-accent); text-decoration: underline;
|
||||
cursor: pointer; font: inherit; font-size: 1rem; }
|
||||
|
||||
/* Svar-lister */
|
||||
.er-liste { list-style: none; padding: 0; margin: .6rem 0 0; }
|
||||
.er-liste li { border-top: 1px solid var(--color-border); padding: .9rem 0; }
|
||||
.er-liste li:first-child { border-top: 0; }
|
||||
.er-liste .er-q { font-weight: 700; margin: 0 0 .3rem; font-size: 1.05rem; }
|
||||
.er-liste .er-meta { color: var(--color-muted); font-size: .9rem; margin: 0 0 .3rem; }
|
||||
.er-tekst { margin: 0; white-space: pre-line; line-height: 1.55; font-size: 1.05rem; }
|
||||
.er-ret { margin-top: .5rem; }
|
||||
.er-ret .wt-btn { font-size: .95rem; }
|
||||
.er-dit-svar { background: color-mix(in srgb, var(--color-good) 8%, transparent); border-color: color-mix(in srgb, var(--color-good) 40%, transparent); }
|
||||
</style>
|
||||
|
||||
<div class="er-side" id="er-side"
|
||||
data-api="{{ api }}" data-rod="{{ rod }}" data-noegle="{{ noegle }}" data-valgt="{{ valgt }}">
|
||||
|
||||
<h1>Erindringer</h1>
|
||||
<p class="wt-lead">Hej {{ navn }}. Fortæl, så gemmer vi det. Du kan altid holde en pause og komme tilbage.</p>
|
||||
|
||||
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
|
||||
<div class="wt-flash" id="er-flash" hidden role="status" aria-live="polite"></div>
|
||||
|
||||
<!-- Trin 2: vælg emne -->
|
||||
<section id="er-emner">
|
||||
<h2>Vælg, hvad du vil fortælle om</h2>
|
||||
{% for p in projekter %}
|
||||
<div class="wt-card er-kort er-emne" data-slug="{{ p.slug }}" data-titel="{{ p.titel }}">
|
||||
<h3>{{ p.titel }}</h3>
|
||||
{% if p.beskrivelse %}<p style="margin:0">{{ p.beskrivelse }}</p>{% endif %}
|
||||
<p class="wt-muted" style="margin:0"><span class="er-antal">{{ p.antal }}</span> svar indtil nu</p>
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" data-vaelg="{{ p.slug }}"
|
||||
aria-label="Fortæl om {{ p.titel }}">Fortæl</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="wt-muted">Der er ingen emner endnu.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
<!-- Trin 3: samtalen -->
|
||||
<section id="er-samtale" hidden>
|
||||
<h2 id="er-samtale-titel">Samtale</h2>
|
||||
|
||||
<div class="wt-card er-kort" id="er-spoergsmaal-kort">
|
||||
<p class="wt-muted" style="margin:0 0 .2rem">Spørgsmål <span id="er-nr"></span></p>
|
||||
<p class="er-spoergsmaal" id="er-spoergsmaal">Henter spørgsmålet…</p>
|
||||
<button type="button" class="wt-btn er-stor" id="er-hoer" hidden aria-label="Hør spørgsmålet læst op">🔊 Hør spørgsmålet</button>
|
||||
<audio id="er-lyd" preload="auto"></audio>
|
||||
</div>
|
||||
|
||||
<div class="er-midt" id="er-optagelse">
|
||||
<button type="button" class="er-optag" id="er-optag" aria-label="Optag dit svar" aria-pressed="false">
|
||||
<span id="er-optag-tekst">Optag</span>
|
||||
<span class="er-taeller" id="er-taeller" hidden>0:00</span>
|
||||
</button>
|
||||
<p class="er-status" id="er-status" aria-live="polite"></p>
|
||||
<p class="wt-muted" style="margin:0">Tal bare, som du ville fortælle det til et barnebarn.</p>
|
||||
|
||||
<div id="er-optaget" hidden style="width:100%;max-width:32rem">
|
||||
<audio id="er-afspil" controls></audio>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send">Send svaret</button>
|
||||
<button type="button" class="wt-btn er-stor" id="er-om">Tag det om</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="er-link" id="er-skriv-link">Skriv i stedet</button>
|
||||
<div id="er-skriv" hidden style="width:100%;max-width:32rem;text-align:left">
|
||||
<label for="er-tekstfelt" class="wt-muted">Skriv dit svar her</label>
|
||||
<textarea id="er-tekstfelt" rows="5"></textarea>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-send-tekst">Send teksten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wt-card er-kort er-dit-svar" id="er-svar-kort" hidden>
|
||||
<p class="wt-muted" style="margin:0 0 .3rem">Dit svar</p>
|
||||
<p class="er-tekst" id="er-svar-tekst"></p>
|
||||
<div class="er-ret" id="er-svar-ret"></div>
|
||||
<div class="er-knapper">
|
||||
<button type="button" class="wt-btn wt-btn--primary er-stor" id="er-naeste">Næste spørgsmål</button>
|
||||
<button type="button" class="wt-btn er-stor" id="er-pause">Hold pause</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Dine svar i dag</h2>
|
||||
<ul class="er-liste" id="er-dagens"></ul>
|
||||
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
|
||||
</section>
|
||||
|
||||
<!-- Trin 4: tidligere svar -->
|
||||
<section id="er-tidligere">
|
||||
<h2>Tidligere svar</h2>
|
||||
{% if klip_seneste %}
|
||||
<ul class="er-liste">
|
||||
{% for k in klip_seneste[:20] %}
|
||||
<li data-id="{{ k.id }}">
|
||||
<p class="er-meta"><span data-tid="{{ k.tid }}">{{ k.tid[:10] }}</span> · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}</p>
|
||||
<p class="er-q">{{ k.spoergsmaal }}</p>
|
||||
<p class="er-tekst" data-tekst>{{ k.tekst }}</p>
|
||||
<audio controls preload="none" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.5rem"></audio>
|
||||
<div class="er-ret"><button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button></div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (window.__erindringer) return;
|
||||
window.__erindringer = true;
|
||||
|
||||
var side = document.getElementById('er-side');
|
||||
var API = side.dataset.api, ROD = side.dataset.rod, NOEGLE = side.dataset.noegle || '';
|
||||
var MAKS_SEK = 600; // 10 minutter pr. svar
|
||||
var MIMER = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
var ui = {
|
||||
emner: el('er-emner'), samtale: el('er-samtale'), titel: el('er-samtale-titel'),
|
||||
nr: el('er-nr'), spoergsmaal: el('er-spoergsmaal'), hoer: el('er-hoer'), lyd: el('er-lyd'),
|
||||
optagelse: el('er-optagelse'), optag: el('er-optag'), optagTekst: el('er-optag-tekst'), taeller: el('er-taeller'),
|
||||
status: el('er-status'), optaget: el('er-optaget'), afspil: el('er-afspil'),
|
||||
send: el('er-send'), om: el('er-om'), skrivLink: el('er-skriv-link'), skriv: el('er-skriv'),
|
||||
tekstfelt: el('er-tekstfelt'), sendTekst: el('er-send-tekst'),
|
||||
svarKort: el('er-svar-kort'), svarTekst: el('er-svar-tekst'), svarRet: el('er-svar-ret'),
|
||||
naeste: el('er-naeste'), pause: el('er-pause'), dagens: el('er-dagens'), dagensTom: el('er-dagens-tom'),
|
||||
flash: el('er-flash')
|
||||
};
|
||||
|
||||
// Tilstand for den igangværende samtale
|
||||
var st = {
|
||||
projekt: null, titel: '', samtale: null, spoergsmaal: '', lydUrl: null,
|
||||
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
|
||||
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
|
||||
};
|
||||
|
||||
/* ---------- Hjælpere ---------- */
|
||||
|
||||
function visFlash(tekst) {
|
||||
ui.flash.textContent = tekst;
|
||||
ui.flash.hidden = !tekst;
|
||||
}
|
||||
function status(tekst, travl) {
|
||||
ui.status.textContent = tekst || '';
|
||||
ui.status.classList.toggle('travl', !!travl);
|
||||
}
|
||||
function mmss(s) {
|
||||
s = Math.max(0, Math.round(s));
|
||||
return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2);
|
||||
}
|
||||
function datoTekst(iso) {
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d)) return iso.slice(0, 10);
|
||||
return d.toLocaleDateString('da-DK', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
} catch (e) { return iso.slice(0, 10); }
|
||||
}
|
||||
function lagerNoegle() { return 'erindringer:samtale:' + st.projekt; }
|
||||
function husk(id) { try { sessionStorage.setItem(lagerNoegle(), id); } catch (e) {} }
|
||||
function glem() { try { sessionStorage.removeItem(lagerNoegle()); } catch (e) {} }
|
||||
function hentHusket() { try { return sessionStorage.getItem(lagerNoegle()); } catch (e) { return null; } }
|
||||
|
||||
// Fælles fetch: JSON-body eller FormData, altid med nøgle og marker-header.
|
||||
// Ingen timeout: transskription kan tage lang tid.
|
||||
function kald(sti, data) {
|
||||
var erForm = data instanceof FormData;
|
||||
if (erForm) data.append('noegle', NOEGLE); else data.noegle = NOEGLE;
|
||||
var opts = { method: 'POST', credentials: 'same-origin', headers: { 'X-Requested-With': 'fetch' } };
|
||||
if (erForm) { opts.body = data; }
|
||||
else { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
||||
return fetch(API + sti, opts).then(function (r) {
|
||||
return r.text().then(function (t) {
|
||||
var j = null;
|
||||
try { j = t ? JSON.parse(t) : null; } catch (e) {}
|
||||
if (!r.ok) throw new Error((j && j.fejl) || 'Noget gik galt (' + r.status + '). Prøv igen om lidt.');
|
||||
return j || {};
|
||||
});
|
||||
}, function () {
|
||||
throw new Error('Vi kunne ikke få forbindelse. Tjek, at du har net, og prøv igen.');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Ret teksten (bruges både i dag og i tidligere svar) ---------- */
|
||||
|
||||
// tekstEls: ét eller flere elementer, der viser samme tekst og skal følges ad
|
||||
function retTekst(id, tekstEls, holder) {
|
||||
if (holder.querySelector('textarea')) return;
|
||||
if (!Array.isArray(tekstEls)) tekstEls = [tekstEls];
|
||||
holder.innerHTML = '';
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = tekstEls[0].textContent;
|
||||
ta.rows = 6;
|
||||
var gem = document.createElement('button');
|
||||
gem.type = 'button'; gem.className = 'wt-btn wt-btn--primary'; gem.textContent = 'Gem';
|
||||
var fortryd = document.createElement('button');
|
||||
fortryd.type = 'button'; fortryd.className = 'wt-btn'; fortryd.textContent = 'Fortryd';
|
||||
var knapper = document.createElement('div');
|
||||
knapper.className = 'er-knapper'; knapper.style.justifyContent = 'flex-start';
|
||||
knapper.appendChild(gem); knapper.appendChild(fortryd);
|
||||
holder.appendChild(ta); holder.appendChild(knapper);
|
||||
ta.focus();
|
||||
|
||||
function tilbage() {
|
||||
holder.innerHTML = '';
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten';
|
||||
b.setAttribute('data-ret', id);
|
||||
holder.appendChild(b);
|
||||
}
|
||||
fortryd.addEventListener('click', tilbage);
|
||||
gem.addEventListener('click', function () {
|
||||
var ny = ta.value.trim();
|
||||
gem.disabled = true; fortryd.disabled = true; gem.textContent = 'Gemmer…';
|
||||
kald('/ret', { id: id, tekst: ny }).then(function () {
|
||||
tekstEls.forEach(function (t) { t.textContent = ny; });
|
||||
tilbage();
|
||||
visFlash('Teksten er rettet.');
|
||||
}).catch(function (e) {
|
||||
gem.disabled = false; fortryd.disabled = false; gem.textContent = 'Gem';
|
||||
visFlash(e.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Alle "Ret teksten"-knapper, også dem der oprettes senere
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-ret]');
|
||||
if (!b) return;
|
||||
var li = b.closest('[data-id]');
|
||||
var tekstEl = li ? li.querySelector('[data-tekst]') : null;
|
||||
if (!li || !tekstEl) return;
|
||||
var els = [tekstEl];
|
||||
// Er det dagens seneste svar, skal "Dit svar"-kortet følge med
|
||||
if (!ui.svarKort.hidden && ui.svarKort.dataset.id === b.dataset.ret) els.push(ui.svarTekst);
|
||||
retTekst(b.dataset.ret, els, b.parentNode);
|
||||
});
|
||||
|
||||
// Datoer i "Tidligere svar" på læsbart dansk
|
||||
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
|
||||
|
||||
/* ---------- Emnevalg ---------- */
|
||||
|
||||
function vaelg(slug) {
|
||||
var kort = ui.emner.querySelector('[data-slug="' + slug + '"]');
|
||||
if (!kort) return;
|
||||
st.projekt = slug;
|
||||
st.titel = kort.dataset.titel || slug;
|
||||
st.samtale = hentHusket();
|
||||
st.sendtIDag = 0;
|
||||
ui.titel.textContent = st.titel;
|
||||
ui.dagens.innerHTML = '';
|
||||
ui.dagensTom.hidden = false;
|
||||
visFlash('');
|
||||
ui.emner.hidden = true;
|
||||
ui.samtale.hidden = false;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
hentSpoergsmaal(true);
|
||||
}
|
||||
|
||||
ui.emner.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-vaelg]');
|
||||
if (b) vaelg(b.dataset.vaelg);
|
||||
});
|
||||
|
||||
function holdPause() {
|
||||
stopStream();
|
||||
stopTimer();
|
||||
nulstilOptagelse();
|
||||
ui.svarKort.hidden = true;
|
||||
ui.samtale.hidden = true;
|
||||
ui.emner.hidden = false;
|
||||
// Opdatér tællingen på emnekortet
|
||||
var kort = ui.emner.querySelector('[data-slug="' + st.projekt + '"] .er-antal');
|
||||
if (kort) kort.textContent = (parseInt(kort.textContent, 10) || 0) + st.sendtIDag;
|
||||
st.sendtIDag = 0;
|
||||
visFlash('Tak for i dag. Dine svar er gemt.');
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
ui.pause.addEventListener('click', holdPause);
|
||||
|
||||
/* ---------- Spørgsmål ---------- */
|
||||
|
||||
function hentSpoergsmaal(foersteForsoeg) {
|
||||
ui.spoergsmaal.textContent = 'Henter spørgsmålet…';
|
||||
ui.nr.textContent = '';
|
||||
ui.hoer.hidden = true;
|
||||
ui.svarKort.hidden = true;
|
||||
nulstilOptagelse();
|
||||
ui.optagelse.hidden = false;
|
||||
status('');
|
||||
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null }).then(function (j) {
|
||||
st.samtale = j.samtale;
|
||||
husk(j.samtale);
|
||||
st.spoergsmaal = j.spoergsmaal || '';
|
||||
st.lydUrl = j.lyd_url || null;
|
||||
ui.spoergsmaal.textContent = st.spoergsmaal;
|
||||
ui.nr.textContent = j.nr ? String(j.nr) : '';
|
||||
ui.hoer.hidden = !st.lydUrl;
|
||||
if (st.lydUrl) {
|
||||
ui.lyd.src = st.lydUrl;
|
||||
// Afspil automatisk, hvis browseren tillader det; ellers kun på klik
|
||||
var p = ui.lyd.play();
|
||||
if (p && p.catch) p.catch(function () {});
|
||||
}
|
||||
}).catch(function (e) {
|
||||
// Et gammelt samtale-id kan være udløbet: prøv én gang forfra
|
||||
if (foersteForsoeg && st.samtale) { st.samtale = null; glem(); return hentSpoergsmaal(false); }
|
||||
ui.spoergsmaal.textContent = 'Vi kunne ikke hente et spørgsmål.';
|
||||
visFlash(e.message + ' Du kan holde pause og prøve igen senere.');
|
||||
});
|
||||
}
|
||||
|
||||
ui.hoer.addEventListener('click', function () {
|
||||
try {
|
||||
ui.lyd.currentTime = 0;
|
||||
var p = ui.lyd.play();
|
||||
if (p && p.catch) p.catch(function () { visFlash('Lyden kunne ikke afspilles. Du kan læse spørgsmålet ovenfor.'); });
|
||||
} catch (e) {}
|
||||
});
|
||||
ui.naeste.addEventListener('click', function () { hentSpoergsmaal(false); });
|
||||
|
||||
/* ---------- Optagelse ---------- */
|
||||
|
||||
function vaelgMime() {
|
||||
if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return '';
|
||||
for (var i = 0; i < MIMER.length; i++) if (MediaRecorder.isTypeSupported(MIMER[i])) return MIMER[i];
|
||||
return '';
|
||||
}
|
||||
function endelse(mime) {
|
||||
if (mime.indexOf('mp4') > -1) return 'mp4';
|
||||
if (mime.indexOf('ogg') > -1) return 'ogg';
|
||||
return 'webm';
|
||||
}
|
||||
function stopStream() {
|
||||
if (st.stream) { st.stream.getTracks().forEach(function (t) { t.stop(); }); st.stream = null; }
|
||||
}
|
||||
function stopTimer() {
|
||||
if (st.timer) { clearInterval(st.timer); st.timer = null; }
|
||||
}
|
||||
function nulstilOptagelse() {
|
||||
st.blob = null; st.chunks = []; st.sekunder = 0;
|
||||
ui.optag.disabled = false;
|
||||
ui.optag.classList.remove('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'false');
|
||||
ui.optagTekst.textContent = 'Optag';
|
||||
ui.taeller.hidden = true; ui.taeller.textContent = '0:00';
|
||||
ui.optag.hidden = false;
|
||||
ui.optaget.hidden = true;
|
||||
if (ui.afspil.src) { try { URL.revokeObjectURL(ui.afspil.src); } catch (e) {} ui.afspil.removeAttribute('src'); }
|
||||
ui.skriv.hidden = true; ui.skrivLink.hidden = false;
|
||||
ui.tekstfelt.value = '';
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false;
|
||||
st.sender = false;
|
||||
}
|
||||
|
||||
function startOptagelse() {
|
||||
visFlash('');
|
||||
var mime = vaelgMime();
|
||||
if (!window.MediaRecorder || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
visFlash('Din browser kan ikke optage lyd. Brug "Skriv i stedet" herunder.');
|
||||
visSkriv();
|
||||
return;
|
||||
}
|
||||
ui.optag.disabled = true;
|
||||
status('Gør mikrofonen klar…');
|
||||
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
|
||||
st.stream = stream;
|
||||
st.chunks = [];
|
||||
st.mime = mime;
|
||||
st.ext = endelse(mime);
|
||||
try {
|
||||
st.recorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
} catch (e) {
|
||||
stopStream();
|
||||
throw new Error('Optageren kunne ikke startes. Brug "Skriv i stedet", eller prøv en anden browser.');
|
||||
}
|
||||
if (!st.mime) st.mime = st.recorder.mimeType || '';
|
||||
st.ext = endelse(st.mime);
|
||||
st.recorder.addEventListener('dataavailable', function (e) { if (e.data && e.data.size) st.chunks.push(e.data); });
|
||||
st.recorder.addEventListener('stop', optagelseFaerdig);
|
||||
st.recorder.addEventListener('error', function () {
|
||||
visFlash('Optagelsen blev afbrudt. Prøv igen, eller brug "Skriv i stedet".');
|
||||
stopOptagelse();
|
||||
});
|
||||
st.recorder.start(1000);
|
||||
st.startet = Date.now();
|
||||
st.sekunder = 0;
|
||||
ui.optag.disabled = false;
|
||||
ui.optag.classList.add('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'true');
|
||||
ui.optag.setAttribute('aria-label', 'Stop optagelsen');
|
||||
ui.optagTekst.textContent = 'Stop';
|
||||
ui.taeller.hidden = false; ui.taeller.textContent = '0:00';
|
||||
ui.skrivLink.hidden = true;
|
||||
status('Vi optager. Tryk på knappen, når du er færdig.');
|
||||
st.timer = setInterval(function () {
|
||||
st.sekunder = Math.round((Date.now() - st.startet) / 1000);
|
||||
ui.taeller.textContent = mmss(st.sekunder);
|
||||
if (st.sekunder >= MAKS_SEK) {
|
||||
stopOptagelse();
|
||||
visFlash('Vi stoppede optagelsen efter 10 minutter. Du kan sende svaret nu og fortsætte på næste spørgsmål.');
|
||||
}
|
||||
}, 1000);
|
||||
}).catch(function (e) {
|
||||
stopStream();
|
||||
ui.optag.disabled = false;
|
||||
status('');
|
||||
var navn = e && e.name;
|
||||
if (navn === 'NotAllowedError' || navn === 'SecurityError' || navn === 'PermissionDeniedError') {
|
||||
visFlash('Vi fik ikke lov til at bruge mikrofonen. Tillad mikrofonen i browseren, eller brug "Skriv i stedet".');
|
||||
} else if (navn === 'NotFoundError' || navn === 'DevicesNotFoundError') {
|
||||
visFlash('Vi kunne ikke finde en mikrofon. Brug "Skriv i stedet".');
|
||||
} else {
|
||||
visFlash((e && e.message) || 'Mikrofonen kunne ikke startes. Prøv igen, eller brug "Skriv i stedet".');
|
||||
}
|
||||
visSkriv();
|
||||
});
|
||||
}
|
||||
|
||||
function stopOptagelse() {
|
||||
stopTimer();
|
||||
ui.optag.disabled = true;
|
||||
status('Gør optagelsen klar…');
|
||||
try {
|
||||
if (st.recorder && st.recorder.state !== 'inactive') st.recorder.stop();
|
||||
else optagelseFaerdig();
|
||||
} catch (e) { optagelseFaerdig(); }
|
||||
}
|
||||
|
||||
function optagelseFaerdig() {
|
||||
stopStream(); // ellers bliver den røde mikrofon-prik stående på iPhone
|
||||
ui.optag.classList.remove('optager');
|
||||
ui.optag.setAttribute('aria-pressed', 'false');
|
||||
ui.optag.setAttribute('aria-label', 'Optag dit svar');
|
||||
ui.optagTekst.textContent = 'Optag';
|
||||
ui.taeller.hidden = true;
|
||||
if (!st.chunks.length) {
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('Der kom ingen lyd med. Prøv igen, og tal tæt på telefonen.');
|
||||
return;
|
||||
}
|
||||
st.blob = new Blob(st.chunks, { type: st.mime || 'application/octet-stream' });
|
||||
ui.afspil.src = URL.createObjectURL(st.blob);
|
||||
ui.optag.hidden = true;
|
||||
ui.optaget.hidden = false;
|
||||
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
||||
ui.send.focus();
|
||||
}
|
||||
|
||||
ui.optag.addEventListener('click', function () {
|
||||
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
|
||||
});
|
||||
ui.om.addEventListener('click', function () {
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('');
|
||||
});
|
||||
|
||||
/* ---------- Skriv i stedet ---------- */
|
||||
|
||||
function visSkriv() {
|
||||
ui.skriv.hidden = false;
|
||||
ui.skrivLink.hidden = true;
|
||||
ui.tekstfelt.focus();
|
||||
}
|
||||
ui.skrivLink.addEventListener('click', visSkriv);
|
||||
|
||||
/* ---------- Send svar ---------- */
|
||||
|
||||
function sendSvar(form) {
|
||||
if (st.sender) return;
|
||||
st.sender = true;
|
||||
ui.send.disabled = true; ui.om.disabled = true; ui.sendTekst.disabled = true; ui.optag.disabled = true;
|
||||
status('Gemmer og skriver af… Det kan tage op til et halvt minut.', true);
|
||||
visFlash('');
|
||||
form.append('projekt', st.projekt);
|
||||
form.append('samtale', st.samtale || '');
|
||||
form.append('spoergsmaal', st.spoergsmaal);
|
||||
kald('/svar', form).then(function (j) {
|
||||
st.sendtIDag++;
|
||||
visSvar(j);
|
||||
}).catch(function (e) {
|
||||
// Behold det optagede, så det kan sendes igen
|
||||
st.sender = false;
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.sendTekst.disabled = false; ui.optag.disabled = false;
|
||||
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
|
||||
visFlash(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
ui.send.addEventListener('click', function () {
|
||||
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
|
||||
var form = new FormData();
|
||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||
sendSvar(form);
|
||||
});
|
||||
ui.sendTekst.addEventListener('click', function () {
|
||||
var t = ui.tekstfelt.value.trim();
|
||||
if (!t) { visFlash('Skriv lidt i feltet først.'); ui.tekstfelt.focus(); return; }
|
||||
var form = new FormData();
|
||||
form.append('tekst', t);
|
||||
sendSvar(form);
|
||||
});
|
||||
|
||||
function visSvar(j) {
|
||||
var tekst = j.tekst || '';
|
||||
ui.optagelse.hidden = true;
|
||||
status('');
|
||||
ui.svarTekst.textContent = tekst;
|
||||
ui.svarKort.dataset.id = j.id;
|
||||
ui.svarKort.hidden = false;
|
||||
var dagensTekst = tilfoejDagens(j, tekst);
|
||||
ui.svarRet.innerHTML = '';
|
||||
var ret = document.createElement('button');
|
||||
ret.type = 'button'; ret.className = 'wt-btn'; ret.textContent = 'Ret teksten';
|
||||
ret.addEventListener('click', function () { retTekst(j.id, [ui.svarTekst, dagensTekst], ui.svarRet); });
|
||||
ui.svarRet.appendChild(ret);
|
||||
ui.naeste.focus();
|
||||
nulstilOptagelse();
|
||||
}
|
||||
|
||||
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet
|
||||
function tilfoejDagens(j, tekst) {
|
||||
var li = document.createElement('li');
|
||||
li.setAttribute('data-id', j.id);
|
||||
var q = document.createElement('p'); q.className = 'er-q'; q.textContent = st.spoergsmaal;
|
||||
var t = document.createElement('p'); t.className = 'er-tekst'; t.setAttribute('data-tekst', ''); t.textContent = tekst;
|
||||
li.appendChild(q); li.appendChild(t);
|
||||
if (j.varighed) {
|
||||
var m = document.createElement('p'); m.className = 'er-meta'; m.textContent = 'Varighed ' + mmss(j.varighed);
|
||||
li.appendChild(m);
|
||||
}
|
||||
var ret = document.createElement('div'); ret.className = 'er-ret';
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'wt-btn'; b.textContent = 'Ret teksten'; b.setAttribute('data-ret', j.id);
|
||||
ret.appendChild(b); li.appendChild(ret);
|
||||
ui.dagens.insertBefore(li, ui.dagens.firstChild);
|
||||
ui.dagensTom.hidden = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
// Slip mikrofonen, hvis siden forlades midt i en optagelse
|
||||
window.addEventListener('pagehide', function () { stopTimer(); stopStream(); });
|
||||
|
||||
// Åbn et projekt med det samme, hvis serveren bad om det
|
||||
if (side.dataset.valgt) vaelg(side.dataset.valgt);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Røgtest af erindringer-modulet med FastAPI TestClient, uden OIDC: sessionen sættes direkte.
|
||||
# Kør i CT151: PORTAL_DATA=/tmp/ef-test/data + env fra /etc/portal.env. Rører ikke prod-data.
|
||||
import io, json, os, sys
|
||||
from fastapi.testclient import TestClient
|
||||
import main, erindringer
|
||||
|
||||
c = TestClient(main.app, base_url="https://weircon.dk")
|
||||
adm = {"username": "agw", "name": "Asger", "groups": [main.config.ADMIN_GROUP, "Familie"]}
|
||||
|
||||
|
||||
def login(client, user):
|
||||
# Starlette-session: skriv via en lille rute er omstændeligt; monkeypatch current_user i stedet.
|
||||
main.current_user = lambda request: user
|
||||
|
||||
|
||||
login(c, adm)
|
||||
r = c.get("/admin/erindringer"); assert r.status_code == 200 and "Erindringer" in r.text, r.status_code
|
||||
r = c.post("/admin/erindringer/projekt", data={"slug": "test-ep", "titel": "Testemne", "beskrivelse": "b", "kontekst": "Personen er Asger. Vi ved at han bor i Sønderlund.", "start": "Hvad husker du fra Rørbæk?\nHvem var din morfar?"}, follow_redirects=False)
|
||||
assert r.status_code == 303, r.text
|
||||
assert erindringer.projekt("test-ep")["start"][0].startswith("Hvad husker")
|
||||
r = c.post("/admin/erindringer/gaest", data={"navn": "Tina Test", "projekter": ["test-ep"]}, follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
pid = [p for p in erindringer.alle_personer() if p["navn"] == "Tina Test"][0]["pid"]; noegle = erindringer.person(pid)["noegle"]
|
||||
print("gæst", pid)
|
||||
|
||||
# API som gæst (uden session)
|
||||
login(c, None)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 200, r.text
|
||||
q1 = r.json(); print("Q1", q1["spoergsmaal"], "| lyd:", bool(q1["lyd_url"]))
|
||||
if q1["lyd_url"]:
|
||||
assert c.get(q1["lyd_url"]).status_code == 200
|
||||
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q1["spoergsmaal"], "noegle": noegle, "tekst": "Jeg husker min morfar Gustav i Villa Monika i Hannerup. Han havde en stor have og en motorcykel, som han aldrig kørte på."})
|
||||
assert r.status_code == 200, r.text; s1 = r.json(); print("svar1", s1["id"])
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle}); assert r.status_code == 200, r.text
|
||||
q2 = r.json(); print("Q2 (Claude)", q2["spoergsmaal"], "| afslut:", q2["afslut"])
|
||||
# lydsvar: 2 s tone som mp3 lavet med ffmpeg
|
||||
os.system("ffmpeg -y -v error -f lavfi -i 'sine=frequency=440:duration=2' -c:a libmp3lame /tmp/ef-test/tone.mp3")
|
||||
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": q2["spoergsmaal"], "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
|
||||
assert r.status_code == 200, r.text; s2 = r.json(); print("svar2 lyd", s2["id"], "varighed", s2["varighed"], "tekst:", repr(s2["tekst"])[:60])
|
||||
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}.mp3"); assert r.status_code == 200 and r.headers["content-type"].startswith("audio")
|
||||
r = c.post("/erindringer/api/ret", json={"id": s1["id"], "tekst": "Rettet tekst.", "noegle": noegle}); assert r.status_code == 200
|
||||
assert erindringer.klip(pid, s1["id"])["tekst"] == "Rettet tekst."
|
||||
# forkert nøgle -> 404
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": "x" * 40}); assert r.status_code == 404
|
||||
# indlogget familie: dagbog findes, admin-person-side, eksport
|
||||
login(c, adm)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "dagbog", "samtale": None, "noegle": ""}); assert r.status_code == 200, r.text
|
||||
print("dagbog Q", r.json()["spoergsmaal"])
|
||||
r = c.get(f"/admin/erindringer/p/{pid}"); assert r.status_code == 200 and "Rettet tekst." in r.text
|
||||
r = c.get(f"/admin/erindringer/p/{pid}/eksport.zip"); assert r.status_code == 200 and r.headers["content-type"] == "application/zip" and len(r.content) > 1000
|
||||
r = c.post(f"/admin/erindringer/p/{pid}/klip/{s1['id']}", data={"handling": "favorit", "favorit": "1"}, follow_redirects=False); assert r.status_code == 303
|
||||
assert erindringer.klip(pid, s1["id"])["favorit"] is True
|
||||
r = c.post(f"/admin/erindringer/person/{pid}", data={"handling": "luk"}, follow_redirects=False); assert r.status_code == 303
|
||||
login(c, None)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": None, "noegle": noegle}); assert r.status_code == 404
|
||||
print("ALT OK")
|
||||
Reference in New Issue
Block a user