feat(erindringer): kladde-upload ved stop, afskrift og næste spørgsmål i baggrunden, dagbog pr. dag, afspil spørgsmål + svar; tabeller ruller selv på mobil
Optagelsen sendes op som kladde, når man stopper, så STT kører mens man lytter; 'Send svaret' bekræfter. Næste spørgsmål (Claude + TTS) forberedes i baggrunden efter hvert svar og hentes på 0 s. Dagbogens samtale er datoen (vælg dag). 'Spørgsmål og svar' afspiller oplæsningen og svaret efter hinanden; /erindringer/alle viser alt dag for dag. Baggrundsjobs holdes med stærke referencer og logger fejl. portal.css: én standardregel, så tabeller på smalle skærme ruller i sig selv og lange ord brydes, i stedet for at siden får vandret scroll. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
@@ -30,6 +30,10 @@ pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git).
|
||||
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).
|
||||
- Flow: optagelsen sendes op som kladde i samme øjeblik man stopper (afskriften kører i baggrunden,
|
||||
mens man lytter), "Send svaret" bekræfter kun, og næste spørgsmål (tekst + oplæsning) forberedes
|
||||
i baggrunden, så det kommer med det samme. Dagbogen føres én dag ad gangen (samtale = datoen).
|
||||
Spørgsmålets oplæsning gemmes som `<id>-sp.mp3` ved siden af svaret; "Spørgsmål og svar" afspiller begge.
|
||||
- Uden nøgler virker siden stadig: intervieweren bruger emnets startspørgsmål, og svar gemmes
|
||||
uden transskription.
|
||||
|
||||
|
||||
+69
-7
@@ -226,8 +226,8 @@ 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."""
|
||||
def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = None, med_kladder: bool = False) -> list[dict]:
|
||||
"""Klip for en person, ældste først. Kladder (optagelser, der ikke er sendt endnu) udelades."""
|
||||
d = _klipmappe(pid)
|
||||
if not PID.match(pid or "") or not d.exists():
|
||||
return []
|
||||
@@ -242,8 +242,10 @@ def klip_for(pid: str, projekt_slug: str | None = None, samtale: str | None = No
|
||||
continue
|
||||
if samtale and k.get("samtale") != samtale:
|
||||
continue
|
||||
if k.get("kladde") and not med_kladder:
|
||||
continue
|
||||
ud.append(k)
|
||||
return sorted(ud, key=lambda k: k["id"])
|
||||
return sorted(ud, key=lambda k: (k.get("tid") or "", k["id"]))
|
||||
|
||||
|
||||
def klip(pid: str, kid: str) -> dict | None:
|
||||
@@ -270,7 +272,8 @@ def _varighed(p: Path) -> int:
|
||||
|
||||
|
||||
def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: bytes | None, filnavn: str,
|
||||
tekst: str | None, spoergsmaal_talt: str = "", lyd_sha: str = "", kilde: str = "") -> dict:
|
||||
tekst: str | None, spoergsmaal_talt: str = "", lyd_sha: str = "", kilde: str = "",
|
||||
kladde: bool = False) -> dict:
|
||||
"""Gem et svar: lyd konverteres til mp3 og transskriberes; ren tekst gemmes som det er.
|
||||
Spørgsmålets oplæsning (lyd_sha fra tts-cachen) kopieres med som <id>-sp.mp3, så spørgsmål og
|
||||
svar kan klippes sammen i en podcast. Returnerer klip-metadata. Kaster ValueError med besked."""
|
||||
@@ -302,8 +305,7 @@ def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: b
|
||||
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)
|
||||
# Transskriptionen laves bagefter (transskriber_klip), så svaret gemmes med det samme
|
||||
finally:
|
||||
raa.unlink(missing_ok=True)
|
||||
else:
|
||||
@@ -315,11 +317,57 @@ def gem_svar(pid: str, projekt_slug: str, samtale: str, spoergsmaal: str, lyd: b
|
||||
meta = {"id": kid, "tid": _nu(), "projekt": projekt_slug, "samtale": samtale, "spoergsmaal": spoergsmaal.strip()[:600],
|
||||
"spoergsmaal_talt": (spoergsmaal_talt or "").strip()[:800], "spoergsmaal_lyd": bool(sp),
|
||||
"kilde": kilde if kilde in ("forberedt", "opfoelgning", "afrunding") else "",
|
||||
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd}
|
||||
"tekst": (tekst or "").strip(), "varighed": varighed, "favorit": False, "skrevet": not lyd,
|
||||
"kladde": bool(kladde), "status": "transskriberes" if (lyd and not (tekst or "").strip()) else "klar"}
|
||||
_skriv(d / f"{kid}.json", meta)
|
||||
return meta
|
||||
|
||||
|
||||
def transskriber_klip(pid: str, kid: str) -> dict | None:
|
||||
"""Transskribér et gemt lydsvar og skriv teksten ind; idempotent."""
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return None
|
||||
if k.get("status") != "transskriberes":
|
||||
return k
|
||||
f = klip_fil(pid, kid)
|
||||
tekst = transskriber(f) if f else ""
|
||||
k = klip(pid, kid) or k # genlæs: brugeren kan have bekræftet imens
|
||||
k["tekst"] = k["tekst"] or tekst
|
||||
k["status"] = "klar" if tekst else "fejl"
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return k
|
||||
|
||||
|
||||
def bekraeft(pid: str, kid: str) -> dict | None:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
return None
|
||||
k.pop("kladde", None)
|
||||
_skriv(_klipmappe(pid) / f"{kid}.json", k)
|
||||
return k
|
||||
|
||||
|
||||
# ---------------- næste spørgsmål, forberedt i baggrunden ----------------
|
||||
|
||||
def _naeste_sti(pid: str, samtale: str) -> Path:
|
||||
return _klipmappe(pid) / f"_naeste_{samtale}.json"
|
||||
|
||||
|
||||
def gem_forberedt(pid: str, samtale: str, basis: str, q: dict, lyd_sha: str) -> None:
|
||||
_skriv(_naeste_sti(pid, samtale), {"basis": basis, "q": q, "lyd_sha": lyd_sha, "tid": _nu()})
|
||||
|
||||
|
||||
def hent_forberedt(pid: str, samtale: str, basis: str) -> dict | None:
|
||||
"""Det forudberegnede næste spørgsmål, hvis det bygger på netop det seneste svar."""
|
||||
p = _naeste_sti(pid, samtale)
|
||||
v = _læs(p, None)
|
||||
if v and v.get("basis") == basis:
|
||||
p.unlink(missing_ok=True)
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def ret_tekst(pid: str, kid: str, tekst: str) -> bool:
|
||||
k = klip(pid, kid)
|
||||
if not k:
|
||||
@@ -484,6 +532,9 @@ async def naeste_spoergsmaal(p: dict, pr: dict, samtale: str, sprunget: list[str
|
||||
spørgsmål; uden nøgle (eller ved fejl) tages de forberedte i rækkefølge."""
|
||||
denne, tidligere = _historik(p["pid"], pr["slug"], samtale)
|
||||
forberedte = list(pr.get("start") or DAGBOG["start"])
|
||||
if pr["slug"] == "dagbog" and forberedte:
|
||||
# Én dags dagbog: ét åbningsspørgsmål (skifter fra dag til dag), opfølgninger, afrunding.
|
||||
forberedte = [forberedte[int(samtale[:8]) % len(forberedte)]]
|
||||
stillede = {k.get("spoergsmaal") for k in denne + tidligere} | set(sprunget or [])
|
||||
tilbage = [s for s in forberedte if s not in stillede]
|
||||
opfoelg_max = int(pr.get("opfoelg", 2) if pr.get("opfoelg") is not None else 2)
|
||||
@@ -536,6 +587,17 @@ def ny_samtale() -> str:
|
||||
return time.strftime("%Y%m%d") + "-" + secrets.token_hex(3)
|
||||
|
||||
|
||||
SAMTALE = re.compile(r"^[0-9]{8}(-[a-f0-9]{6})?$")
|
||||
|
||||
|
||||
def dagbog_samtale(dato: str = "") -> str:
|
||||
"""Dagbogen føres én dag ad gangen: samtalen ER datoen (YYYYMMDD), i dag medmindre andet vælges."""
|
||||
d = re.sub(r"[^0-9]", "", dato or "")
|
||||
if len(d) == 8 and "19000101" <= d <= time.strftime("%Y%m%d"):
|
||||
return d
|
||||
return time.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def eksport(pid: str) -> bytes:
|
||||
"""Zip med alle klip (mp3 + json) og en samlet tekstfil, til podcast-produktionen."""
|
||||
import io
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
@@ -639,7 +640,7 @@ def _erindringer_person(request: Request, noegle: str = "") -> dict:
|
||||
raise HTTPException(403, "Kun for familien")
|
||||
|
||||
|
||||
def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str):
|
||||
def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: str, valgt: str, alle: bool = False):
|
||||
user = current_user(request)
|
||||
pr = []
|
||||
for slug in p.get("projekter") or []:
|
||||
@@ -647,12 +648,15 @@ def _erindringer_side(request: Request, p: dict, rod: str, noegle: str, saved: s
|
||||
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]
|
||||
alle_klip = list(reversed(erindringer.klip_for(p["pid"])))
|
||||
seneste = alle_klip if alle else alle_klip[:20]
|
||||
for k in seneste:
|
||||
k["projekt_titel"] = titler.get(k.get("projekt"), k.get("projekt"))
|
||||
k["dag"] = (k.get("tid") or "")[:10]
|
||||
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,
|
||||
"alle": alle, "antal_alle": len(alle_klip), "idag": time.strftime("%Y-%m-%d"),
|
||||
"saved": saved, "valgt": valgt if valgt in titler else ""})
|
||||
|
||||
|
||||
@@ -662,6 +666,20 @@ async def erindringer_side(request: Request, saved: str = "", valgt: str = ""):
|
||||
return _erindringer_side(request, p, "/erindringer", "", saved, valgt)
|
||||
|
||||
|
||||
@app.get("/erindringer/alle", response_class=HTMLResponse)
|
||||
async def erindringer_alle(request: Request):
|
||||
p = _erindringer_person(request)
|
||||
return _erindringer_side(request, p, "/erindringer", "", "", "", alle=True)
|
||||
|
||||
|
||||
@app.get("/erindringer/g/{key}/alle/", response_class=HTMLResponse)
|
||||
async def erindringer_gaest_alle(request: Request, key: str):
|
||||
p = _erindringer_person(request, key)
|
||||
r = _erindringer_side(request, p, f"/erindringer/g/{key}", key, "", "", alle=True)
|
||||
r.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
|
||||
return r
|
||||
|
||||
|
||||
@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)
|
||||
@@ -725,15 +743,59 @@ async def erindringer_naeste(request: Request):
|
||||
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):
|
||||
if slug == "dagbog":
|
||||
samtale = erindringer.dagbog_samtale(str(data.get("dato") or ""))
|
||||
elif not erindringer.SAMTALE.match(samtale):
|
||||
samtale = erindringer.ny_samtale()
|
||||
sprunget = [str(x)[:400] for x in (data.get("sprunget") or []) if isinstance(x, str)][:50]
|
||||
q = await _naeste_med_lyd(p, pr, samtale, sprunget)
|
||||
return {"samtale": samtale, "spoergsmaal": q["spoergsmaal"], "talt": q["talt"], "afslut": q["afslut"],
|
||||
"kilde": q["kilde"], "lyd_url": f"/erindringer/tts/{q['lyd_sha']}.mp3" if q.get("lyd_sha") else None,
|
||||
"lyd_sha": q.get("lyd_sha") or "", "nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
|
||||
|
||||
|
||||
async def _naeste_med_lyd(p: dict, pr: dict, samtale: str, sprunget: list[str]) -> dict:
|
||||
"""Næste spørgsmål med oplæsning: tag det forudberegnede, hvis det bygger på det seneste svar;
|
||||
ellers vent (kort) på at det seneste svar er skrevet af, og regn det ud nu."""
|
||||
seneste = erindringer.klip_for(p["pid"], pr["slug"], samtale)
|
||||
basis = seneste[-1]["id"] if seneste else ""
|
||||
if not sprunget:
|
||||
v = erindringer.hent_forberedt(p["pid"], samtale, basis)
|
||||
if v and v.get("q"):
|
||||
return {**v["q"], "lyd_sha": v.get("lyd_sha") or ""}
|
||||
if seneste and seneste[-1].get("status") == "transskriberes":
|
||||
for _ in range(25): # op til ca. 25 s: teksten er på vej fra STT
|
||||
await asyncio.sleep(1)
|
||||
k = erindringer.klip(p["pid"], basis)
|
||||
if not k or k.get("status") != "transskriberes":
|
||||
break
|
||||
q = await erindringer.naeste_spoergsmaal(p, pr, samtale, sprunget)
|
||||
lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"])
|
||||
return {"samtale": samtale, "spoergsmaal": q["spoergsmaal"], "talt": q["talt"], "afslut": q["afslut"],
|
||||
"kilde": q["kilde"], "lyd_url": f"/erindringer/tts/{lyd.stem}.mp3" if lyd else None,
|
||||
"lyd_sha": lyd.stem if lyd else "", "tilbage": len([s for s in (pr.get("start") or []) if s not in sprunget]),
|
||||
"nr": len(erindringer.klip_for(p["pid"], slug, samtale)) + 1}
|
||||
return {**q, "lyd_sha": lyd.stem if lyd else ""}
|
||||
|
||||
|
||||
_baggrund: set = set() # stærke referencer: en create_task uden reference kan blive samlet op af GC midtvejs
|
||||
|
||||
|
||||
def _i_baggrunden(coro) -> None:
|
||||
t = asyncio.create_task(coro)
|
||||
_baggrund.add(t)
|
||||
t.add_done_callback(_baggrund.discard)
|
||||
|
||||
|
||||
async def _efter_svar(p: dict, pr: dict, samtale: str, kid: str) -> None:
|
||||
"""Baggrundsarbejde efter et svar: transskribér, og forbered næste spørgsmål (tekst + lyd),
|
||||
så det ligger klar, når personen trykker 'Næste spørgsmål'."""
|
||||
try:
|
||||
await asyncio.to_thread(erindringer.transskriber_klip, p["pid"], kid)
|
||||
k = erindringer.klip(p["pid"], kid)
|
||||
if not k or k.get("kladde"):
|
||||
return # ikke sendt endnu (eller slettet): intet at forberede
|
||||
q = await erindringer.naeste_spoergsmaal(p, pr, samtale, [])
|
||||
lyd = await asyncio.to_thread(erindringer.tts_sti, q["talt"])
|
||||
erindringer.gem_forberedt(p["pid"], samtale, kid, q, lyd.stem if lyd else "")
|
||||
except Exception: # baggrund: må aldrig vælte appen; næste kald regner selv
|
||||
logging.getLogger("portal.erindringer").exception("baggrundsjob efter svar %s fejlede", kid)
|
||||
|
||||
|
||||
@app.post("/erindringer/api/svar")
|
||||
@@ -744,8 +806,8 @@ async def erindringer_svar(request: Request):
|
||||
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()
|
||||
if not erindringer.SAMTALE.match(samtale):
|
||||
samtale = erindringer.dagbog_samtale() if slug == "dagbog" else erindringer.ny_samtale()
|
||||
lyd = form.get("lyd")
|
||||
data, filnavn = None, ""
|
||||
if lyd is not None and hasattr(lyd, "read"):
|
||||
@@ -754,13 +816,59 @@ async def erindringer_svar(request: Request):
|
||||
if not data:
|
||||
data = None
|
||||
tekst = str(form.get("tekst") or "")
|
||||
kladde = str(form.get("kladde") or "") == "1"
|
||||
try:
|
||||
meta = await asyncio.to_thread(erindringer.gem_svar, p["pid"], slug, samtale, str(form.get("spoergsmaal") or ""),
|
||||
data, filnavn, tekst or None, str(form.get("spoergsmaal_talt") or ""),
|
||||
str(form.get("lyd_sha") or ""), str(form.get("kilde") or ""))
|
||||
str(form.get("lyd_sha") or ""), str(form.get("kilde") or ""), kladde)
|
||||
except ValueError as e:
|
||||
return _fejl(400, str(e))
|
||||
return {"id": meta["id"], "tekst": meta["tekst"], "varighed": meta["varighed"], "samtale": samtale}
|
||||
_i_baggrunden(_efter_svar(p, erindringer.projekt(slug), samtale, meta["id"]))
|
||||
return {"id": meta["id"], "tekst": meta["tekst"], "status": meta["status"], "varighed": meta["varighed"],
|
||||
"samtale": samtale, "kladde": kladde}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/status")
|
||||
async def erindringer_status(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return _fejl(400, "Ugyldigt kald.")
|
||||
p = _erindringer_person(request, str(data.get("noegle") or ""))
|
||||
k = erindringer.klip(p["pid"], str(data.get("id") or ""))
|
||||
if not k:
|
||||
return _fejl(404, "Svaret findes ikke.")
|
||||
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/bekraeft")
|
||||
async def erindringer_bekraeft(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return _fejl(400, "Ugyldigt kald.")
|
||||
p = _erindringer_person(request, str(data.get("noegle") or ""))
|
||||
k = erindringer.bekraeft(p["pid"], str(data.get("id") or ""))
|
||||
if not k:
|
||||
return _fejl(404, "Svaret findes ikke.")
|
||||
if k.get("status") != "transskriberes":
|
||||
# Teksten er allerede klar: forbered næste spørgsmål nu (ellers gør baggrundsjobbet det)
|
||||
pr = erindringer.projekt(k.get("projekt") or "")
|
||||
if pr:
|
||||
_i_baggrunden(_efter_svar(p, pr, k.get("samtale") or "", k["id"]))
|
||||
return {"id": k["id"], "tekst": k.get("tekst", ""), "status": k.get("status", "klar"), "varighed": k.get("varighed", 0)}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/slet")
|
||||
async def erindringer_slet(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.slet_klip(p["pid"], str(data.get("id") or "")):
|
||||
return _fejl(404, "Svaret findes ikke.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/erindringer/api/ret")
|
||||
|
||||
@@ -173,3 +173,12 @@ th { color:var(--muted); font-weight:600; font-size:.8rem; text-transform:upperc
|
||||
.ucard:has(input:checked) .uchk { background:var(--accent); border-color:var(--accent); color:var(--color-accent-ink); }
|
||||
.ucard.hidden { display:none; }
|
||||
|
||||
|
||||
/* Tabeller og andet bredt indhold må aldrig give siden vandret scroll på en telefon.
|
||||
Standard for alle sider: på smalle skærme bliver en tabel sit eget rulleområde,
|
||||
og lange ord/nøgler brydes. (Ops-siden har desuden sin egen kort-visning.) */
|
||||
.wt-main { overflow-wrap: anywhere; min-width: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.wt-main table { display: block; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.wt-main table td, .wt-main table th { overflow-wrap: normal; }
|
||||
}
|
||||
|
||||
+123
-11
@@ -90,6 +90,7 @@
|
||||
<!-- Trin 3: samtalen -->
|
||||
<section id="er-samtale" hidden>
|
||||
<h2 id="er-samtale-titel">Samtale</h2>
|
||||
<p id="er-dag" hidden class="wt-muted" style="margin:0 0 .8rem">Dagbog for <input type="date" id="er-dato" max="{{ idag }}" value="{{ idag }}" style="font-size:16px;padding:.2rem .4rem"> <span class="wt-muted">(én dag ad gangen; vælg en anden dag for at skrive bagud)</span></p>
|
||||
|
||||
<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>
|
||||
@@ -143,21 +144,32 @@
|
||||
<p class="wt-muted" id="er-dagens-tom">Ingen endnu. Dit første svar kommer her.</p>
|
||||
</section>
|
||||
|
||||
<!-- Trin 4: tidligere svar -->
|
||||
<!-- Trin 4: tidligere svar, dag for dag; "Afspil begge" spiller spørgsmålet og derefter svaret -->
|
||||
<section id="er-tidligere">
|
||||
<h2>Tidligere svar</h2>
|
||||
<h2>{% if alle %}Alle svar{% else %}Tidligere svar{% endif %}</h2>
|
||||
{% if klip_seneste %}
|
||||
<ul class="er-liste">
|
||||
{% for k in klip_seneste[:20] %}
|
||||
{% for k in klip_seneste %}
|
||||
{% if loop.first or k.dag != loop.previtem.dag %}
|
||||
<li class="er-dagsskel"><h3 style="margin:1.2rem 0 .2rem;font-size:1.05rem"><span data-tid="{{ k.tid }}">{{ k.dag }}</span></h3></li>
|
||||
{% endif %}
|
||||
<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-meta">{{ 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>
|
||||
<div class="er-knapper" style="justify-content:flex-start;margin-top:.4rem">
|
||||
{% if k.spoergsmaal_lyd %}<button type="button" class="wt-btn" data-begge="{{ k.id }}" aria-label="Afspil spørgsmål og svar">▶ Spørgsmål og svar</button>{% endif %}
|
||||
<button type="button" class="wt-btn" data-ret="{{ k.id }}">Ret teksten</button>
|
||||
</div>
|
||||
{% if k.spoergsmaal_lyd %}<audio preload="none" data-sp="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}-sp.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}"></audio>{% endif %}
|
||||
<audio controls preload="none" data-svar="{{ k.id }}" src="{{ rod }}/klip/{{ k.id }}.mp3{% if noegle %}?noegle={{ noegle }}{% endif %}" style="margin-top:.4rem"></audio>
|
||||
<div class="er-ret"></div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if not alle and antal_alle > klip_seneste|length %}
|
||||
<p><a class="wt-btn" href="{{ rod }}/alle{% if noegle %}/{% endif %}">Se alle {{ antal_alle }} svar</a></p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="wt-muted">Ingen svar endnu. Det første gemmes her, når du har fortalt noget.</p>
|
||||
{% endif %}
|
||||
@@ -184,12 +196,14 @@
|
||||
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'), afrunding: el('er-afrunding'), spring: el('er-spring')
|
||||
flash: el('er-flash'), afrunding: el('er-afrunding'), spring: el('er-spring'),
|
||||
dag: el('er-dag'), dato: el('er-dato')
|
||||
};
|
||||
|
||||
// Tilstand for den igangværende samtale
|
||||
var st = {
|
||||
projekt: null, titel: '', samtale: null, spoergsmaal: '', talt: '', lydSha: '', kilde: '', lydUrl: null,
|
||||
kladdeId: null, kladdeKlar: false, dato: '', poll: null,
|
||||
recorder: null, stream: null, chunks: [], blob: null, mime: '', ext: 'webm',
|
||||
timer: null, sekunder: 0, startet: 0, sender: false, sendtIDag: 0, lydAfspillet: false
|
||||
};
|
||||
@@ -295,6 +309,22 @@
|
||||
retTekst(b.dataset.ret, els, b.parentNode);
|
||||
});
|
||||
|
||||
// "Spørgsmål og svar": spil spørgsmålets oplæsning, og derefter svaret
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest && e.target.closest('[data-begge]');
|
||||
if (!b) return;
|
||||
var li = b.closest('[data-id]');
|
||||
var sp = li && li.querySelector('audio[data-sp]');
|
||||
var sv = li && li.querySelector('audio[data-svar]');
|
||||
if (!sv) return;
|
||||
document.querySelectorAll('audio').forEach(function (a) { if (a !== sp && a !== sv) { try { a.pause(); } catch (x) {} } });
|
||||
var spilSvar = function () { try { sv.currentTime = 0; var p = sv.play(); if (p && p.catch) p.catch(function () {}); } catch (x) {} };
|
||||
if (sp) {
|
||||
sp.onended = spilSvar;
|
||||
try { sp.currentTime = 0; var p = sp.play(); if (p && p.catch) p.catch(spilSvar); } catch (x) { spilSvar(); }
|
||||
} else spilSvar();
|
||||
});
|
||||
|
||||
// Datoer i "Tidligere svar" på læsbart dansk
|
||||
document.querySelectorAll('[data-tid]').forEach(function (s) { s.textContent = datoTekst(s.dataset.tid); });
|
||||
|
||||
@@ -305,7 +335,10 @@
|
||||
if (!kort) return;
|
||||
st.projekt = slug;
|
||||
st.titel = kort.dataset.titel || slug;
|
||||
st.samtale = hentHusket();
|
||||
// Dagbogen føres én dag ad gangen: serveren vælger samtalen ud fra datoen
|
||||
st.dato = (slug === 'dagbog') ? ui.dato.value : '';
|
||||
ui.dag.hidden = (slug !== 'dagbog');
|
||||
st.samtale = (slug === 'dagbog') ? null : hentHusket();
|
||||
st.sendtIDag = 0;
|
||||
ui.titel.textContent = st.titel;
|
||||
ui.dagens.innerHTML = '';
|
||||
@@ -321,6 +354,13 @@
|
||||
var b = e.target.closest && e.target.closest('[data-vaelg]');
|
||||
if (b) vaelg(b.dataset.vaelg);
|
||||
});
|
||||
ui.dato.addEventListener('change', function () {
|
||||
if (st.projekt !== 'dagbog' || st.sender) return;
|
||||
st.dato = ui.dato.value;
|
||||
st.samtale = null;
|
||||
ui.dagens.innerHTML = ''; ui.dagensTom.hidden = false;
|
||||
hentSpoergsmaal(false);
|
||||
});
|
||||
|
||||
function holdPause() {
|
||||
stopStream();
|
||||
@@ -348,7 +388,7 @@
|
||||
nulstilOptagelse();
|
||||
ui.optagelse.hidden = false;
|
||||
status('');
|
||||
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null, sprunget: hentSprunget() }).then(function (j) {
|
||||
kald('/naeste', { projekt: st.projekt, samtale: st.samtale || null, sprunget: hentSprunget(), dato: st.dato || '' }).then(function (j) {
|
||||
st.samtale = j.samtale;
|
||||
husk(j.samtale);
|
||||
st.spoergsmaal = j.spoergsmaal || '';
|
||||
@@ -522,12 +562,52 @@
|
||||
ui.optaget.hidden = false;
|
||||
status('Hør dit svar igennem, og send det, når du er tilfreds.');
|
||||
ui.send.focus();
|
||||
sendKladde();
|
||||
}
|
||||
|
||||
// Optagelsen sendes op med det samme som kladde, så afskriften er i gang, mens man lytter.
|
||||
// "Send svaret" bekræfter bare; "Tag det om" sletter kladden igen.
|
||||
function sendKladde() {
|
||||
st.kladdeId = null; st.kladdeKlar = false;
|
||||
var form = new FormData();
|
||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||
form.append('kladde', '1');
|
||||
form.append('projekt', st.projekt);
|
||||
form.append('samtale', st.samtale || '');
|
||||
form.append('spoergsmaal', st.spoergsmaal);
|
||||
form.append('spoergsmaal_talt', st.talt || '');
|
||||
form.append('lyd_sha', st.lydSha || '');
|
||||
form.append('kilde', st.kilde || '');
|
||||
var blob = st.blob;
|
||||
kald('/svar', form).then(function (j) {
|
||||
if (st.blob !== blob) { kald('/slet', { id: j.id }).catch(function () {}); return; } // taget om imens
|
||||
st.kladdeId = j.id;
|
||||
st.kladdeKlar = true;
|
||||
if (j.samtale) { st.samtale = j.samtale; if (st.projekt !== 'dagbog') husk(j.samtale); }
|
||||
if (st.sender) bekraeft(); // brugeren trykkede "Send" før kladden var oppe
|
||||
}).catch(function () {
|
||||
st.kladdeKlar = false; // falder tilbage til almindelig sending ved "Send"
|
||||
});
|
||||
}
|
||||
|
||||
function bekraeft() {
|
||||
kald('/bekraeft', { id: st.kladdeId }).then(function (j) {
|
||||
st.sendtIDag++;
|
||||
visSvar(j);
|
||||
}).catch(function (e) {
|
||||
st.sender = false;
|
||||
ui.send.disabled = false; ui.om.disabled = false; ui.optag.disabled = false;
|
||||
status('Svaret blev ikke sendt. Det er stadig her, så prøv igen.');
|
||||
visFlash(e.message);
|
||||
});
|
||||
}
|
||||
|
||||
ui.optag.addEventListener('click', function () {
|
||||
if (st.recorder && st.recorder.state === 'recording') stopOptagelse(); else startOptagelse();
|
||||
});
|
||||
ui.om.addEventListener('click', function () {
|
||||
if (st.kladdeId) { kald('/slet', { id: st.kladdeId }).catch(function () {}); }
|
||||
st.kladdeId = null; st.kladdeKlar = false;
|
||||
nulstilOptagelse();
|
||||
status('');
|
||||
visFlash('');
|
||||
@@ -570,6 +650,16 @@
|
||||
|
||||
ui.send.addEventListener('click', function () {
|
||||
if (!st.blob) { visFlash('Der er ikke noget optaget endnu. Tryk på Optag først.'); return; }
|
||||
if (st.sender) return;
|
||||
if (st.kladdeId || st.kladdeKlar !== false) {
|
||||
// Kladden er oppe (eller på vej): bekræft, i stedet for at sende lyden igen
|
||||
st.sender = true;
|
||||
ui.send.disabled = true; ui.om.disabled = true; ui.optag.disabled = true;
|
||||
status('Gemmer…', true);
|
||||
visFlash('');
|
||||
if (st.kladdeId) bekraeft();
|
||||
return;
|
||||
}
|
||||
var form = new FormData();
|
||||
form.append('lyd', st.blob, 'svar.' + st.ext);
|
||||
sendSvar(form);
|
||||
@@ -583,20 +673,42 @@
|
||||
});
|
||||
|
||||
function visSvar(j) {
|
||||
var tekst = j.tekst || '';
|
||||
var venter = (j.status === 'transskriberes');
|
||||
var tekst = j.tekst || (venter ? 'Teksten er på vej. Du kan gå videre til næste spørgsmål imens.' : '');
|
||||
ui.optagelse.hidden = true;
|
||||
status('');
|
||||
ui.svarTekst.textContent = tekst;
|
||||
ui.svarTekst.classList.toggle('wt-muted', venter);
|
||||
ui.svarKort.dataset.id = j.id;
|
||||
ui.svarKort.hidden = false;
|
||||
var dagensTekst = tilfoejDagens(j, tekst);
|
||||
var dagensTekst = tilfoejDagens(j, 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); });
|
||||
ret.hidden = venter;
|
||||
ui.svarRet.appendChild(ret);
|
||||
ui.naeste.focus();
|
||||
st.kladdeId = null; st.kladdeKlar = false;
|
||||
nulstilOptagelse();
|
||||
if (venter) ventPaaTekst(j.id, [ui.svarTekst, dagensTekst], ret);
|
||||
}
|
||||
|
||||
// Afskriften kommer i baggrunden: spørg hvert par sekunder, og sæt teksten ind, når den er der
|
||||
function ventPaaTekst(id, els, retKnap) {
|
||||
if (st.poll) clearInterval(st.poll);
|
||||
var n = 0;
|
||||
st.poll = setInterval(function () {
|
||||
n++;
|
||||
kald('/status', { id: id }).then(function (j) {
|
||||
if (j.status !== 'transskriberes' || n > 60) {
|
||||
clearInterval(st.poll); st.poll = null;
|
||||
var t = j.tekst || (j.status === 'fejl' ? 'Afskriften lykkedes ikke; lyden er gemt, og du kan skrive teksten selv.' : '');
|
||||
els.forEach(function (e) { if (e) { e.textContent = t; e.classList.remove('wt-muted'); } });
|
||||
if (retKnap) retKnap.hidden = false;
|
||||
}
|
||||
}).catch(function () {});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Sætter svaret øverst i "Dine svar i dag" og returnerer tekst-elementet
|
||||
|
||||
@@ -45,6 +45,29 @@ r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}.mp3"); assert r.status_code
|
||||
r = c.get(f"/erindringer/g/{noegle}/klip/{s2['id']}-sp.mp3"); assert r.status_code == 200, "spørgsmålets lyd mangler"
|
||||
k2 = erindringer.klip(pid, s2["id"]); assert k2["spoergsmaal_lyd"] and k2["kilde"] == q2["kilde"] and k2["spoergsmaal_talt"] == q2["talt"]
|
||||
r = c.post("/erindringer/api/ret", json={"id": s1["id"], "tekst": "Rettet tekst.", "noegle": noegle}); assert r.status_code == 200
|
||||
# kladde-flow: optagelsen sendes op med det samme, afskrift i baggrunden, så bekræftes den
|
||||
import time as _t
|
||||
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": "Hvad lavede du i haven?", "kladde": "1", "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")})
|
||||
assert r.status_code == 200, r.text; s3 = r.json(); print("kladde", s3["id"], s3["status"], "svartid ok")
|
||||
assert s3["kladde"] is True and not [k for k in erindringer.klip_for(pid) if k["id"] == s3["id"]], "kladde må ikke tælle med"
|
||||
r = c.post("/erindringer/api/bekraeft", json={"id": s3["id"], "noegle": noegle}); assert r.status_code == 200
|
||||
for _ in range(40):
|
||||
st = c.post("/erindringer/api/status", json={"id": s3["id"], "noegle": noegle}).json()
|
||||
if st["status"] != "transskriberes": break
|
||||
_t.sleep(1)
|
||||
print("status efter bekræft:", st["status"], repr(st["tekst"])[:40])
|
||||
assert [k for k in erindringer.klip_for(pid) if k["id"] == s3["id"]], "bekræftet klip skal tælle med"
|
||||
_t.sleep(8) # baggrunden forbereder næste spørgsmål
|
||||
t0 = _t.time(); r = c.post("/erindringer/api/naeste", json={"projekt": "test-ep", "samtale": q1["samtale"], "noegle": noegle}); dt = _t.time() - t0
|
||||
assert r.status_code == 200; print("næste efter kladde: %.1fs" % dt, r.json()["spoergsmaal"][:60], "| lyd:", bool(r.json()["lyd_url"]))
|
||||
# slet kladde
|
||||
r = c.post("/erindringer/api/svar", data={"projekt": "test-ep", "samtale": q1["samtale"], "spoergsmaal": "x", "kladde": "1", "noegle": noegle}, files={"lyd": ("svar.mp3", open("/tmp/ef-test/tone.mp3", "rb"), "audio/mpeg")}); s4 = r.json()
|
||||
r = c.post("/erindringer/api/slet", json={"id": s4["id"], "noegle": noegle}); assert r.status_code == 200 and erindringer.klip(pid, s4["id"]) is None
|
||||
# dagbog: samtale = dato
|
||||
login(c, adm)
|
||||
r = c.post("/erindringer/api/naeste", json={"projekt": "dagbog", "samtale": None, "noegle": "", "dato": "2026-10-05"}); assert r.status_code == 200 and r.json()["samtale"] == "20261005", r.text
|
||||
r = c.get("/erindringer/alle"); assert r.status_code == 200 and "Alle svar" in r.text
|
||||
login(c, None)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user