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
CD / deploy (push) Successful in 15s
CI / check (push) Successful in 22s

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:
root
2026-10-06 22:34:36 +02:00
parent 3dee428a37
commit 49a33b25a6
6 changed files with 347 additions and 29 deletions
+4
View File
@@ -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
View File
@@ -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
+119 -11
View File
@@ -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")
+9
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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