diff --git a/README.md b/README.md index 14f886b..3801d03 100644 --- a/README.md +++ b/README.md @@ -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 `-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. diff --git a/erindringer.py b/erindringer.py index 451ded9..53816bb 100644 --- a/erindringer.py +++ b/erindringer.py @@ -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 -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 diff --git a/main.py b/main.py index 7ff5439..c2fe676 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/static/portal.css b/static/portal.css index fbf530f..226e0ae 100644 --- a/static/portal.css +++ b/static/portal.css @@ -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; } +} diff --git a/templates/erindringer.html b/templates/erindringer.html index ed82bd9..caf7c2b 100644 --- a/templates/erindringer.html +++ b/templates/erindringer.html @@ -90,6 +90,7 @@ - +
-

Tidligere svar

+

{% if alle %}Alle svar{% else %}Tidligere svar{% endif %}

{% if klip_seneste %}
    - {% for k in klip_seneste[:20] %} + {% for k in klip_seneste %} + {% if loop.first or k.dag != loop.previtem.dag %} +
  • {{ k.dag }}

  • + {% endif %}
  • -

    {{ k.tid[:10] }} · {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}

    +

    {{ k.projekt_titel or k.projekt }}{% if k.varighed %} · {{ k.varighed // 60 }}:{{ '%02d' % (k.varighed % 60) }}{% endif %}

    {{ k.spoergsmaal }}

    {{ k.tekst }}

    - -
    +
    + {% if k.spoergsmaal_lyd %}{% endif %} + +
    + {% if k.spoergsmaal_lyd %}{% endif %} + +
  • {% endfor %}
+ {% if not alle and antal_alle > klip_seneste|length %} +

Se alle {{ antal_alle }} svar

+ {% endif %} {% else %}

Ingen svar endnu. Det første gemmes her, når du har fortalt noget.

{% 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 diff --git a/tests/erindringer_smoke.py b/tests/erindringer_smoke.py index 4029f68..fb72104 100644 --- a/tests/erindringer_smoke.py +++ b/tests/erindringer_smoke.py @@ -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