Compare commits

..
Author SHA1 Message Date
mathiasandClaude Sonnet 5.5 12d1365607 test(web): the 499 test cancels the client mid-Accept, as a real hang-up does
CI / Lint / Test / Vet (pull_request) Successful in 27s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 9d7afb2bcb fix(web): the title-part hang-up is handled like the rest, and an abandoned request is answered 499, not an implicit 200 (fourth review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 1266e18038 fix(uploads): every worker log carries a kind, including store and temp-file errors (fourth review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 51ee5d48e5 feat(errkind): a database error is reduced to its SQLSTATE, never its message, which can quote a transcript value (fourth review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:47:13 +02:00
mathiasandClaude Sonnet 5.5 cff5cd2f0a test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 fc8ac376fa test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 73fe203d62 test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 fbc3ee61c1 test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 e97d4400b0 test: fourth review -- a hang-up anywhere in the body, an abandoned request is not an implicit 200, a database error is logged as its SQLSTATE (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:46:07 +02:00
mathiasandClaude Sonnet 5.5 6e50a97b6d docs(adr): third review -- the hang-up path, the client-read marker, the note kept visible
CI / Lint / Test / Vet (pull_request) Successful in 26s
CI / Build & Import (pull_request) Skipped
CI / Deploy via GitOps (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 815ee98d4c test(web): the title input that proves a bidi character is dropped, not replaced
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 067620d06a chore(web): regenerate uploads_templ.go
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 91e475807c fix(web): a finished upload's record opens when a step carries a note (third review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 6d044ba403 fix(web): a client that hangs up is logged at INFO and not answered; the 400 follows the marker, not io.ErrUnexpectedEOF; hasNote (third review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 632dbeba0e feat(uploads): ErrClientRead marks an error from reading the client's body (third review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:36:42 +02:00
mathiasandClaude Sonnet 5.5 fb5dc9d461 test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 272a5db75f test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 1b0fc668bc test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 375da83c5e test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 af4e9c8632 test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
mathiasandClaude Sonnet 5.5 04a3bc261f test: third review -- a real disconnect is not an error, a DB drop is not the client's, a note stays visible, every bidi character and the 80% boundary pinned (red)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 17:35:29 +02:00
16 changed files with 421 additions and 119 deletions
+7 -1
View File
@@ -1751,7 +1751,13 @@ wording while only `content` is merged.
never a stuck row or a server error.
- **The configured upload chat model is callable.** It is added to the shared chat service's model
list when uploads are on and it is local, because the service re-maps a model it does not offer
(a valid `iguana/gemma4-31b` was being silently swapped for `koala/phi4-mini`).
(a valid `iguana/gemma4-31b` was being silently swapped for `koala/phi4-mini`). The video chat's own switcher
lists that local model too while uploads are on.
- **A client that hangs up mid-upload is not an error**: the handler logs INFO and writes nothing
(rendering the error page ran a database read on a cancelled context and logged a false ERROR). The
400 "cut off" answer is keyed on a marker for errors from *reading the client's body*
(`uploads.ErrClientRead`), not on `io.ErrUnexpectedEOF`, which is also what a database drop looks like.
- **A note is not hidden**: a finished upload's record opens by default when any step carries a note.
- **Bad input is a 4xx**: a non-UUID id is a 404, a malformed or cut-off multipart body is a 400
whose text is not logged, and a title with control bytes, bidirectional controls or invalid UTF-8 is
cleaned (emoji joiners, Swedish letters and variation selectors are kept), not a 500.
+10 -1
View File
@@ -17,13 +17,20 @@ import (
// HTTPStatuser is implemented by errors that are a non-2xx answer from a service.
type HTTPStatuser interface{ HTTPStatus() int }
// Of is the kind of err: "timeout", "canceled", "http_<code>", "network", or "error".
// SQLStater is implemented by database errors that carry a SQLSTATE (pgx's PgError does).
// The five-character code is a fixed vocabulary; the error's message is not: Postgres quotes
// the offending value, which for a transcript row is transcript text.
type SQLStater interface{ SQLState() string }
// Of is the kind of err: "timeout", "canceled", "http_<code>", "sql_<state>", "network",
// or "error".
// It is empty for nil.
func Of(err error) string {
if err == nil {
return ""
}
var hs HTTPStatuser
var ss SQLStater
var ne net.Error
switch {
case errors.Is(err, context.DeadlineExceeded):
@@ -32,6 +39,8 @@ func Of(err error) string {
return "canceled"
case errors.As(err, &hs):
return fmt.Sprintf("http_%d", hs.HTTPStatus())
case errors.As(err, &ss) && ss.SQLState() != "":
return "sql_" + ss.SQLState()
case errors.As(err, &ne):
if ne.Timeout() {
return "timeout"
+18
View File
@@ -54,6 +54,24 @@ func TestOf_ReducesAnErrorToAKindAndNeverItsText(t *testing.T) {
}
}
type sqlErr struct{}
func (sqlErr) Error() string {
return `ERROR: invalid input syntax for type json: "Anders ansvarar för virket"`
}
func (sqlErr) SQLState() string { return "22P02" }
func TestOf_ADatabaseErrorIsReducedToItsSQLStateNeverItsMessage(t *testing.T) {
// A Postgres error can quote the offending value, which for a transcript row is
// transcript text. The five-character SQLSTATE is a fixed vocabulary (fourth review).
err := fmt.Errorf("store: complete upload: %w", sqlErr{})
got := errkind.Of(err)
assert.Equal(t, "sql_22P02", got)
assert.NotContains(t, got, "Anders")
}
func TestOf_AKindCarriesNoWordFromTheError(t *testing.T) {
err := fmt.Errorf("convert /spool/upload-555: %w", errors.New("Anders ansvarar för virket"))
+26 -1
View File
@@ -58,6 +58,27 @@ func NewIntake(s Store, p Prober, sub Submitter, l Limits, tmpDir string) *Intak
return &Intake{store: s, prober: p, sub: sub, limits: l, tmpDir: tmpDir}
}
// ErrClientRead marks an error that came from READING the client's body: a connection that
// dropped, a body cut short. It is the sender's to retry, unlike a spool, probe or database
// fault, which is ours. It is a marker on the error, not a sentinel that others may wrap:
// io.ErrUnexpectedEOF alone is also what a database drop looks like.
var ErrClientRead = errors.New("uploads: client body read failed")
// clientReader remembers the last non-EOF error its source returned, so Accept can tell
// a failure reading the client from a failure writing the spool file.
type clientReader struct {
r io.Reader
err error
}
func (c *clientReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if err != nil && !errors.Is(err, io.EOF) {
c.err = err
}
return n, err
}
// busyMessage is what a user is told when the queue is full.
const busyMessage = "Too many recordings are waiting to be transcribed. Please try again in a few minutes."
@@ -86,11 +107,15 @@ func (in *Intake) Accept(ctx context.Context, userID, title string, src io.Reade
}()
// Read one byte past the cap so "exactly at the limit" and "over" are told apart.
n, err := io.Copy(f, io.LimitReader(src, in.limits.MaxBytes+1))
cr := &clientReader{r: io.LimitReader(src, in.limits.MaxBytes+1)}
n, err := io.Copy(f, cr)
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
if cr.err != nil && errors.Is(err, cr.err) {
return "", fmt.Errorf("%w: %w", ErrClientRead, err)
}
return "", fmt.Errorf("uploads: receive: %w", err)
}
if n > in.limits.MaxBytes {
+30
View File
@@ -3,6 +3,8 @@ package uploads_test
import (
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
@@ -64,3 +66,31 @@ func TestAccept_ASubmitterThatCannotSayStillWorksAsBefore(t *testing.T) {
require.NoError(t, err, "a Submitter without HasRoom is accepted: the check at Submit remains")
}
// brokenBody is a client body that fails mid-read, like a connection that dropped.
type brokenBody struct{ n int }
func (b *brokenBody) Read(p []byte) (int, error) {
if b.n > 0 {
b.n = 0
return copy(p, "partial audio"), nil
}
return 0, io.ErrUnexpectedEOF
}
func TestAccept_AnErrorReadingTheClientsBodyIsMarkedAsTheClientsAndAWrappedSubmitErrorIsNot(t *testing.T) {
r := newRig(t)
_, err := r.in.Accept(context.Background(), "user-1", "t", &brokenBody{n: 1})
require.Error(t, err)
assert.ErrorIs(t, err, uploads.ErrClientRead, "a dropped client body is the sender's to retry")
assert.Empty(t, r.tmpFiles(t))
// A fault that is ours must NOT carry the marker, even if it wraps the same sentinel.
r2 := newRig(t)
r2.sub.err = fmt.Errorf("db: %w", io.ErrUnexpectedEOF)
_, err = r2.in.Accept(context.Background(), "user-1", "t", strings.NewReader("data"))
require.Error(t, err)
assert.NotErrorIs(t, err, uploads.ErrClientRead)
}
+4 -4
View File
@@ -242,7 +242,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
start := time.Now()
if err := w.store.StartTranscribing(ctx, job.UserID, job.UploadID); err != nil {
log.Error("upload: start transcribing", "err", err)
log.Error("upload: start transcribing", "kind", errkind.Of(err))
w.fail(ctx, job, log, domain.UploadErrTranscriptionFailed)
return
}
@@ -279,7 +279,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
wav, err := w.tempWAV()
if err != nil {
log.Error("upload: temp wav", "err", err)
log.Error("upload: temp wav", "kind", errkind.Of(err))
abort(domain.UploadErrTranscriptionFailed)
return
}
@@ -322,7 +322,7 @@ func (w *Worker) Process(ctx context.Context, job Job) {
tr.Begin("save", 0)
if err := w.store.CompleteUpload(ctx, job.UserID, job.UploadID, t); err != nil {
log.Error("upload: store transcript", "err", err)
log.Error("upload: store transcript", "kind", errkind.Of(err))
abort(domain.UploadErrTranscriptionFailed)
return
}
@@ -339,7 +339,7 @@ func (w *Worker) fail(ctx context.Context, job Job, log *slog.Logger, kind domai
return
}
if err := w.store.FailUpload(ctx, job.UserID, job.UploadID, kind); err != nil {
log.Error("upload: record failure", "err", err)
log.Error("upload: record failure", "kind", errkind.Of(err))
}
}
+2
View File
@@ -104,6 +104,8 @@ func TestWorkerDuration_TheShortfallNoteIsPinnedAtEightyPercentAndTwoMinutes(t *
noted bool
}{
{"79 percent of an hour", 60 * time.Minute, 0.79 * 3600, true},
{"exactly 80 percent of an hour", 60 * time.Minute, 2880, false},
{"one second under 80 percent of an hour", 60 * time.Minute, 2879, true},
{"81 percent of an hour", 60 * time.Minute, 0.81 * 3600, false},
{"exactly two minutes short and under 80 percent", 6 * time.Minute, 4 * 60, false},
{"two minutes and a second short and under 80 percent", 6 * time.Minute, 4*60 - 1, true},
+16
View File
@@ -197,3 +197,19 @@ func TestWorkerProgress_AFailureLogsAKindNeverTheErrorTextOrThePath(t *testing.T
})
}
}
func TestWorkerProgress_AStoreFailureLogsItsSQLStateNeverTheValueItQuotes(t *testing.T) {
// A Postgres error can quote the offending value; for a transcript row that is the
// transcript (fourth adversarial review). The log gets the SQLSTATE.
r := newWRig(t, time.Minute)
r.st.completeErr = dbErr{}
buf := &syncLog{}
w := newProgressWorker(t, r, r.tr, uploads.WithLogger(slog.New(slog.NewTextHandler(buf, nil))))
w.Process(context.Background(), r.spool(t, "a"))
out := buf.String()
assert.NotContains(t, out, "zebrakaka")
assert.Contains(t, out, "upload: store transcript")
assert.Contains(t, out, "kind=sql_22P02")
}
+16
View File
@@ -90,3 +90,19 @@ func TestWorkerHasRoom_SaysWhetherASubmitWouldBeAccepted(t *testing.T) {
require.NoError(t, submit(w, "bob", 1))
assert.False(t, w.HasRoom("carol"), "the queue as a whole is at its cap")
}
func TestWorkerHasRoom_AgreesWithSubmitAtEveryStepAndIsFalseOnceStopped(t *testing.T) {
w := queueWorker(t, 2, 1)
for i, user := range []string{"a", "a", "b", "c"} {
room := w.HasRoom(user)
err := submit(w, user, i)
assert.Equal(t, room, err == nil, "step %d (%s): HasRoom=%v but Submit returned %v", i, user, room, err)
}
r := newWRig(t, time.Minute)
stopped := uploads.NewWorker(r.st, r.conv, r.tr, r.tmp, time.Minute)
ctx, cancel := context.WithCancel(context.Background())
cancel()
stopped.Run(ctx)
assert.False(t, stopped.HasRoom("a"), "a worker that has stopped has no room")
}
+8
View File
@@ -324,3 +324,11 @@ func TestWorkerRun_StoppingDeletesQueuedAudioAndRefusesNewJobs(t *testing.T) {
assert.Empty(t, r.leftovers(t), "audio of queued jobs is deleted on stop: the rows are swept to interrupted at next start")
assert.ErrorIs(t, r.w.Submit(r.spool(t, "c")), uploads.ErrStopped)
}
// dbErr is an error shaped like pgx's: a message that quotes a value, and an SQLSTATE.
type dbErr struct{}
func (dbErr) Error() string {
return `ERROR: invalid input syntax for type json: "zebrakaka secret words"`
}
func (dbErr) SQLState() string { return "22P02" }
+37 -2
View File
@@ -95,6 +95,9 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
var tooBig *http.MaxBytesError
if a.abandoned(w, r) {
return
}
if !errors.As(err, &tooBig) {
// A malformed multipart body (a header with a control byte, a truncated
// part) is the sender's to fix: a 400, not a server error, and the error
@@ -110,6 +113,9 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
case "title":
b, err := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
if err != nil {
if a.abandoned(w, r) {
return
}
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
return
}
@@ -130,10 +136,27 @@ func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
}
// abandoned reports, logs at INFO and answers 499, because the client has already gone: its request
// context is cancelled, so there is nobody to answer and rendering a page would run its
// database read on a dead context and log a false ERROR. A hang-up mid-upload is routine.
func (a *App) abandoned(w http.ResponseWriter, r *http.Request) bool {
if r.Context().Err() == nil {
return false
}
a.logger().Info("upload abandoned", "reason", "client_gone")
// 499 (the "client closed request" convention): a client that half-closed after sending
// can still read, and an unwritten status would reach it as a false, empty 200.
w.WriteHeader(499)
return true
}
// uploadFailure maps an Accept (or multipart) error to a response: a validation
// rejection is the user's to fix (422, or 413 for size) and carries its own
// message; anything else is ours.
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
if a.abandoned(w, r) {
return
}
var rej *uploads.RejectError
var tooBig *http.MaxBytesError
switch {
@@ -152,8 +175,9 @@ func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID strin
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
"This file is larger than the upload limit.")
case errors.Is(err, io.ErrUnexpectedEOF):
// The sender hung up mid-file: theirs to retry, not a server fault.
case errors.Is(err, uploads.ErrClientRead):
// An error reading the CLIENT's body: theirs to retry, not a server fault. Only that
// marker counts: io.ErrUnexpectedEOF alone is also what a database drop looks like.
a.logger().Info("upload rejected", "reason", "truncated")
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.")
default:
@@ -219,6 +243,17 @@ func titleFromFilename(name string) string {
return t
}
// hasNote reports whether any phase carries a note. A note is the only warning for a
// recording that is shorter than its file claimed, so a record that has one is shown open.
func hasNote(steps []domain.ProgressStep) bool {
for _, s := range steps {
if s.Note != "" {
return true
}
}
return false
}
// uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404.
var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+1 -1
View File
@@ -68,7 +68,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
</p>
@reconciliationNote(u.Reconciliation)
if len(u.Progress) > 0 {
<details class="what-was-done">
<details class="what-was-done" if hasNote(u.Progress) { open }>
<summary>What was done</summary>
@progressSteps(u.Progress, now, false)
</details>
+13 -29
View File
@@ -1,9 +1,8 @@
package web_test
import (
"bytes"
"context"
"io"
"fmt"
"log/slog"
"net/http"
"testing"
@@ -63,37 +62,22 @@ func TestUploadPost_AMalformedMultipartHeaderIsA400AndItsTextIsNotLogged(t *test
assert.NotContains(t, buf.String(), "zebrakaka", "a file name is private content")
}
func TestUploadPost_ABodyCutOffInsideTheFilePartIsA400NotA500(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
req := uploadReq(t, "t", "a.m4a", bytes.Repeat([]byte("a"), 4096))
raw, err := io.ReadAll(req.Body)
require.NoError(t, err)
req.Body = io.NopCloser(bytes.NewReader(raw[:len(raw)-2000])) // the sender hung up mid-file
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
rec := do(t, app, req)
assert.Equal(t, http.StatusBadRequest, rec.Code, "a connection that drops mid-upload is the sender's to retry, not a server fault")
assert.Empty(t, sub.jobs)
assert.NotContains(t, buf.String(), "level=ERROR")
}
func TestUploadPost_BidirectionalControlCharactersAreStrippedFromATitle(t *testing.T) {
func TestUploadPost_EveryBidirectionalControlCharacterIsStrippedFromATitle(t *testing.T) {
// U+202E reverses the text that follows it, so a title could render reversed on its
// owner's own page (second adversarial review).
app, sub := newUploadApp(t, audioOK, 200<<20)
// owner's own page (second adversarial review). Every one of the twelve is pinned.
controls := []rune{0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069, 0x200E, 0x200F, 0x061C}
for _, c := range controls {
t.Run(fmt.Sprintf("U+%04X", c), func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
rec := do(t, app, uploadReq(t, "Sprint \u202Ereview \u2066x\u2069 \u200Fok", "a.m4a", []byte("x")))
rec := do(t, app, uploadReq(t, "Sprint"+string(c)+"review", "a.m4a", []byte("x")))
require.Equal(t, http.StatusSeeOther, rec.Code)
var title string
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
for _, r := range []string{"\u202E", "\u2066", "\u2069", "\u200F"} {
assert.NotContains(t, title, r)
require.Equal(t, http.StatusSeeOther, rec.Code)
var title string
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
assert.Equal(t, "Sprintreview", title, "dropped, not replaced by a space: a space would leave a visible gap")
})
}
assert.Contains(t, title, "Sprint")
assert.Contains(t, title, "review")
}
func TestUploadPost_SwedishLettersAndEmojiSurviveTheTitleCleaning(t *testing.T) {
@@ -0,0 +1,124 @@
package web_test
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// A client that hangs up in the middle of an upload is routine, not a server fault. The
// in-process recorder cannot see it (its context never cancels), so this talks to a real
// listener (third adversarial review: over a socket every such case logged an ERROR and
// half-close returned a 500, because the error page was rendered on a cancelled context).
// body positions at which the client can hang up: inside the title field, inside the file
// part's own headers, and mid-file. Every one must be handled alike (fourth adversarial
// review: the title branch had no guard and still logged an ERROR).
const (
inTitle = "in the title"
inHeaders = "in the file part's headers"
midFile = "mid-file"
)
func partialUpload(t *testing.T, addr, where string, hangUp func(*net.TCPConn)) {
t.Helper()
c, err := net.Dial("tcp", addr)
require.NoError(t, err)
tc := c.(*net.TCPConn)
const boundary = "xBOUNDARYx"
var body string
switch where {
case inTitle:
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nhalf a tit"
case inHeaders:
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filena"
default:
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.m4a\"\r\nContent-Type: audio/mp4\r\n\r\n" + strings.Repeat("a", 4000)
}
_, err = fmt.Fprintf(tc, "POST /uploads HTTP/1.1\r\nHost: x\r\nContent-Type: multipart/form-data; boundary=%s\r\nContent-Length: 200000\r\n\r\n%s", boundary, body)
require.NoError(t, err)
time.Sleep(50 * time.Millisecond) // let the server start reading
hangUp(tc)
}
func TestUploadPost_AClientThatHangsUpAnywhereInTheBodyIsLoggedAtInfoAndNeverAsAnError(t *testing.T) {
hangUps := map[string]func(*net.TCPConn){
"a half close": func(c *net.TCPConn) { _ = c.CloseWrite(); time.Sleep(100 * time.Millisecond); _ = c.Close() },
"a full close": func(c *net.TCPConn) { _ = c.Close() },
"a reset (RST)": func(c *net.TCPConn) { _ = c.SetLinger(0); _ = c.Close() },
}
for _, where := range []string{inTitle, inHeaders, midFile} {
for name, hangUp := range hangUps {
t.Run(where+", "+name, func(t *testing.T) {
app, sub := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
srv := httptest.NewServer(app.Router())
defer srv.Close()
partialUpload(t, strings.TrimPrefix(srv.URL, "http://"), where, hangUp)
require.Eventually(t, func() bool {
out := buf.String()
return strings.Contains(out, "upload abandoned") || strings.Contains(out, "upload rejected") || strings.Contains(out, "level=ERROR")
}, 3*time.Second, 20*time.Millisecond, "the server never finished handling the aborted upload")
time.Sleep(100 * time.Millisecond)
out := buf.String()
assert.NotContains(t, out, "level=ERROR", "a client hanging up is not a server fault:\n%s", out)
assert.Empty(t, sub.jobs)
})
}
}
}
// goneMidAccept is an Uploader during whose Accept the client disappears: the request's
// context is cancelled, then Accept fails the way a read from a dead connection does.
type goneMidAccept struct{ cancel context.CancelFunc }
func (g goneMidAccept) Accept(context.Context, string, string, io.Reader) (string, error) {
g.cancel()
return "", fmt.Errorf("receive: %w", io.ErrClosedPipe)
}
func TestUploadPost_AnAbandonedRequestGetsA499NotAnImplicitEmpty200(t *testing.T) {
// If the context is cancelled but the connection can still be written to (a client that
// half-closed after sending), a missing status becomes 200 with an empty body: a false
// success (fourth adversarial review).
app, _ := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app.Uploads = goneMidAccept{cancel: cancel}
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")).WithContext(ctx))
assert.Equal(t, 499, rec.Code, "never an implicit 200 for an upload that was not accepted")
assert.Contains(t, buf.String(), "upload abandoned")
assert.NotContains(t, buf.String(), "level=ERROR")
}
func TestUploadPost_ADatabaseDropThatLooksLikeAnEOFIsStillAServerError(t *testing.T) {
// pgx turns a connection that drops mid-message into io.ErrUnexpectedEOF. Mapping that
// sentinel to "your upload was cut off" (a 400) would hide a real fault: only an error
// from reading the CLIENT's body is theirs (third adversarial review).
app, _ := newUploadApp(t, audioOK, 200<<20)
buf := &lockedBuf{}
app.Log = slog.New(slog.NewTextHandler(buf, nil))
app.Uploads = failingUploader{err: fmt.Errorf("failed to receive message: %w", io.ErrUnexpectedEOF)}
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")))
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Contains(t, buf.String(), "level=ERROR")
}
+21 -2
View File
@@ -252,7 +252,7 @@ func TestUploadProgress_TheFullPageCarriesTheVerySameProgressListAsTheFragment(t
"the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll")
}
func TestUploadProgress_AStepNoteIsShownUnderItsStepAndEscaped(t *testing.T) {
func TestUploadProgress_AStepNoteIsShownDirectlyUnderItsStepAndEscaped(t *testing.T) {
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
id := seedUpload(t, userID, "m", "transcribing", "")
@@ -262,7 +262,26 @@ func TestUploadProgress_AStepNoteIsShownUnderItsStepAndEscaped(t *testing.T) {
b := statusBody(t, app, id)
assert.Contains(t, b, "The audio is 20 min long")
audio := strings.Index(b, "Preparing the audio")
note := strings.Index(b, "The audio is 20 min long")
next := strings.Index(b, "Transcribing with iguana/pianissimo")
require.True(t, audio >= 0 && note > audio && next > note, "the note must sit between its own step and the next one:\n%s", b)
assert.Contains(t, b, "step-note")
assert.NotContains(t, b, "<b>cut off</b>", "a note is words, never markup")
}
func TestUploadProgress_ADonePageShowsItsRecordOpenWhenAStepHasANote(t *testing.T) {
// A truncated recording must not look like a clean one: the note that replaced the
// refusal is the only warning, so it cannot sit inside a collapsed list (third review).
app, _ := newUploadApp(t, audioOK, 200<<20)
fixedClock(app)
noted := seedUpload(t, userID, "noted", "done", "Synthetic transcript text.")
steps := []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}
steps[0].Note = "The audio is 20 min long; the file claimed 1 h 19 min."
seedProgress(t, noted, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
clean := seedUpload(t, userID, "clean", "done", "Synthetic transcript text.")
seedProgress(t, clean, []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
assert.Contains(t, statusBody(t, app, noted), `<details class="what-was-done" open>`)
assert.NotContains(t, statusBody(t, app, clean), `<details class="what-was-done" open>`, "a clean record stays collapsed")
}
+88 -78
View File
@@ -312,7 +312,17 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
return templ_7745c5c3_Err
}
if len(u.Progress) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<details class=\"what-was-done\"><summary>What was done</summary>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<details class=\"what-was-done\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if hasNote(u.Progress) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " open")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "><summary>What was done</summary>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -320,13 +330,13 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</details> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</details> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if chatEnabled {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p><a class=\"btn\" href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p><a class=\"btn\" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -339,12 +349,12 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">Ask about this recording</a></p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">Ask about this recording</a></p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<details open><summary>Transcript</summary><pre class=\"transcript\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<details open><summary>Transcript</summary><pre class=\"transcript\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -357,12 +367,12 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</pre></details></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</pre></details></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.UploadFailed:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"upload-status upload-failed flash flash-error\" role=\"alert\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -370,7 +380,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -383,12 +393,12 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(u.Progress) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<details class=\"what-was-done\" open><summary>Where it stopped</summary>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<details class=\"what-was-done\" open><summary>Where it stopped</summary>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -396,17 +406,17 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
default:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"upload-status upload-working\" role=\"status\" hx-get=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"upload-status upload-working\" role=\"status\" hx-get=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -419,7 +429,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-trigger=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" hx-trigger=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -432,7 +442,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" hx-swap=\"outerHTML\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" hx-swap=\"outerHTML\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -447,7 +457,7 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -456,18 +466,18 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time)
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p>Transcribing… a long recording takes a few minutes.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p>Transcribing… a long recording takes a few minutes.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<p>Queued — waiting for its turn to be transcribed.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<p>Queued — waiting for its turn to be transcribed.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -511,7 +521,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<article class=\"account\"><h1>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<article class=\"account\"><h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -524,7 +534,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</h1><section id=\"chat-section\" class=\"chat-dock chat-dock-open\"><h2 class=\"chat-heading\">Ask about this recording</h2><p class=\"chat-scope muted\">Answers come only from this recording's transcript, which is private to you.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</h1><section id=\"chat-section\" class=\"chat-dock chat-dock-open\"><h2 class=\"chat-heading\">Ask about this recording</h2><p class=\"chat-scope muted\">Answers come only from this recording's transcript, which is private to you.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -532,7 +542,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</section><p><a href=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section><p><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -545,7 +555,7 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">Back to the transcript</a></p></article>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">Back to the transcript</a></p></article>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -585,7 +595,7 @@ func videoNotice(hadVideo bool) templ.Component {
}
ctx = templ.ClearChildren(ctx)
if hadVideo {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<p class=\"muted video-notice\">This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<p class=\"muted video-notice\">This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -622,7 +632,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if r != nil {
switch r.Status {
case domain.ReconcileDone:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"reconciliation\"><p>Reconciled from two transcripts (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"reconciliation\"><p>Reconciled from two transcripts (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -635,7 +645,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " and ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " and ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -648,7 +658,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "): ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "): ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -661,7 +671,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " differences, ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " differences, ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -674,7 +684,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " settled automatically, ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " settled automatically, ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -687,7 +697,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " decided by the model (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " decided by the model (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -700,7 +710,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "), ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "), ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -713,17 +723,17 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " uncertain.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " uncertain.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(shownSpots(r)) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<details><summary>Spots to check against the audio</summary><ul>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<details><summary>Spots to check against the audio</summary><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, c := range shownSpots(r) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -736,12 +746,12 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ": ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ": ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.Before != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"muted\">…")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<span class=\"muted\">…")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -754,7 +764,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -768,7 +778,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " / ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " / ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -781,12 +791,12 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " — ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " — ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if c.Unsure {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<span class=\"chip chip-warn\">uncertain</span> kept ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<span class=\"chip chip-warn\">uncertain</span> kept ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -800,7 +810,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<span class=\"chip\">changed</span> used ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<span class=\"chip\">changed</span> used ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -814,17 +824,17 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</ul>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if moreSpots(r) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<p class=\"muted\">and ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<p class=\"muted\">and ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -837,22 +847,22 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " more not listed</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " more not listed</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</details>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.ReconcileSingle:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<p class=\"muted reconciliation\">Only one engine produced this transcript (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<p class=\"muted reconciliation\">Only one engine produced this transcript (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -865,12 +875,12 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "), so there was no second opinion to reconcile it with.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "), so there was no second opinion to reconcile it with.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case domain.ReconcileSkipped:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<p class=\"muted reconciliation\">Two transcripts were made but could not be reconciled (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"muted reconciliation\">Two transcripts were made but could not be reconciled (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -883,7 +893,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "); the text shown is ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "); the text shown is ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -896,7 +906,7 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "'s, unreconciled.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "'s, unreconciled.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -932,7 +942,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
templ_7745c5c3_Var43 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<ol class=\"steps\" aria-label=\"Progress\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<ol class=\"steps\" aria-label=\"Progress\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -942,7 +952,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<li class=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -955,32 +965,32 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if s.State == domain.StepRunning {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " aria-current=\"step\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " aria-current=\"step\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, ">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, ">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if s.State == domain.StepRunning && live {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"step-mark\" aria-hidden=\"true\"><span class=\"spinner\"></span></span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"step-mark\" aria-hidden=\"true\"><span class=\"spinner\"></span></span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else if s.State == domain.StepRunning {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<span class=\"step-mark step-mark-warn\" aria-hidden=\"true\">!</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<span class=\"step-mark step-mark-warn\" aria-hidden=\"true\">!</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"step-mark\" aria-hidden=\"true\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span class=\"step-mark\" aria-hidden=\"true\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -993,12 +1003,12 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<span class=\"step-label\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"step-label\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1011,12 +1021,12 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if t := stepTime(s, now); t != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"step-time\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"step-time\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1029,13 +1039,13 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if s.Note != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"step-note\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"step-note\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1048,13 +1058,13 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span> ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if s.State == domain.StepRunning && s.Total > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<progress class=\"step-bar\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<progress class=\"step-bar\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1067,7 +1077,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" max=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" max=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1080,7 +1090,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\"></progress> <span class=\"step-count\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\"></progress> <span class=\"step-count\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1093,7 +1103,7 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " of ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " of ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1106,17 +1116,17 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ.
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</li>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</ol>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</ol>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1149,7 +1159,7 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component {
ctx = templ.ClearChildren(ctx)
switch h {
case healthLost:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"step-health step-health-lost\" role=\"alert\">The service stopped reporting ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<p class=\"step-health step-health-lost\" role=\"alert\">The service stopped reporting ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1162,12 +1172,12 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
case healthQuiet:
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<p class=\"step-health step-health-quiet\">Nothing new for ")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<p class=\"step-health step-health-quiet\">Nothing new for ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1180,7 +1190,7 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.</p>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}