From 9cc954df400c28bc0838901ce7c9a24aa2fd71a9 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:01:49 +0200 Subject: [PATCH 01/27] test(llm): the client reads a bounded response body, success and error (#53), red Co-Authored-By: Claude Sonnet 5.5 --- internal/adapters/llm/bodycap_test.go | 81 +++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/adapters/llm/bodycap_test.go diff --git a/internal/adapters/llm/bodycap_test.go b/internal/adapters/llm/bodycap_test.go new file mode 100644 index 0000000..293381f --- /dev/null +++ b/internal/adapters/llm/bodycap_test.go @@ -0,0 +1,81 @@ +package llm + +// Observes how much of a gateway response the client will read. The body is read before +// the status is looked at, so a misbehaving gateway or proxy that streams forever was +// bounded only by the client's overall timeout (#53). + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// endlessGateway answers with the given status and streams filler until the client hangs +// up; wrote reports how many bytes it managed to send. +func endlessGateway(t *testing.T, status int, wrote *atomic.Int64) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + chunk := []byte(strings.Repeat("a", 4096)) + for { + n, err := w.Write(chunk) + wrote.Add(int64(n)) + if err != nil { + return + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestClient_ASuccessBodyLargerThanTheCapIsAnErrorAndTheClientStopsReading(t *testing.T) { + var wrote atomic.Int64 + srv := endlessGateway(t, http.StatusOK, &wrote) + c := New(srv.URL, "", "m", 30*time.Second, WithMaxResponseBytes(8192)) + + start := time.Now() + _, err := c.Complete(context.Background(), "s", "u") + + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("want a response-too-large error, got %v", err) + } + if time.Since(start) > 5*time.Second { + t.Fatalf("the client kept reading: took %s", time.Since(start)) + } + // Hanging up lets the server's writes fail; allow what the transport had already buffered. + time.Sleep(100 * time.Millisecond) + if got := wrote.Load(); got > 8192+1<<20 { + t.Fatalf("server managed to send %d bytes after a cap of 8192", got) + } +} + +func TestClient_AFailingBodyIsReadOnlyUpToTheErrorBound(t *testing.T) { + var wrote atomic.Int64 + srv := endlessGateway(t, http.StatusBadGateway, &wrote) + c := New(srv.URL, "", "m", 30*time.Second) + + start := time.Now() + _, err := c.Complete(context.Background(), "s", "u") + + he, ok := err.(*HTTPError) + if !ok { + t.Fatalf("want *HTTPError, got %T: %v", err, err) + } + if he.Code != http.StatusBadGateway { + t.Fatalf("code %d", he.Code) + } + if len(he.Body) > maxErrorBody+1 { + t.Fatalf("kept %d bytes of an error body, bound is %d", len(he.Body), maxErrorBody) + } + if time.Since(start) > 5*time.Second { + t.Fatalf("the client kept reading an endless error body: took %s", time.Since(start)) + } +} From 61d1ff2255b5403080c539d9b07a6a6d5b9c3e75 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:01:53 +0200 Subject: [PATCH 02/27] fix(llm): read a bounded response body, 8 MiB on success and the error bound on failure (#53) Co-Authored-By: Claude Sonnet 5.5 --- internal/adapters/llm/client.go | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go index 33a3688..1c5a92f 100644 --- a/internal/adapters/llm/client.go +++ b/internal/adapters/llm/client.go @@ -37,6 +37,21 @@ type Client struct { temp float64 httpClient *http.Client usageHook func(model string, prompt, completion int) + maxBody int64 +} + +// defaultMaxResponseBytes bounds a success body. A completion is a few KB of text; this +// leaves room for a long one and none for a gateway that streams forever (#53). +const defaultMaxResponseBytes = 8 << 20 + +// WithMaxResponseBytes overrides the bound on a success response body. A non-positive n is +// ignored (keeps the default). +func WithMaxResponseBytes(n int64) Option { + return func(c *Client) { + if n > 0 { + c.maxBody = n + } + } } // Option configures a Client at construction. Variadic so the existing 4-arg @@ -101,6 +116,7 @@ func New(baseURL, apiKey, model string, timeout time.Duration, opts ...Option) * maxTokens: defaultMaxTokens, temp: defaultTemperature, httpClient: &http.Client{Timeout: timeout}, + maxBody: defaultMaxResponseBytes, } for _, opt := range opts { opt(c) @@ -184,12 +200,17 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err } defer resp.Body.Close() //nolint:errcheck - out, err := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + // Only the bounded head is ever used (HTTPError.Error truncates to it), so read no more. + head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1)) + return "", &HTTPError{Code: resp.StatusCode, Body: string(head)} + } + out, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBody+1)) if err != nil { return "", fmt.Errorf("read response: %w", err) } - if resp.StatusCode != http.StatusOK { - return "", &HTTPError{Code: resp.StatusCode, Body: string(out)} + if int64(len(out)) > c.maxBody { + return "", fmt.Errorf("LLM response exceeds %d bytes", c.maxBody) } var cr chatResponse From 4301e691f5ba1dee324e6afe8da23de6618d8b5f Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:02:16 +0200 Subject: [PATCH 03/27] test(web): a cloud-served transcript says the audio left the homelab (#52), red Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads_cloud_note_test.go | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 internal/web/uploads_cloud_note_test.go diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go new file mode 100644 index 0000000..f6ebf76 --- /dev/null +++ b/internal/web/uploads_cloud_note_test.go @@ -0,0 +1,55 @@ +package web_test + +// A transcript produced by the cloud fallback means this recording's audio left the +// homelab, which the reader is told in plain words (#52). "Local" is config.IsLocalModel: +// one definition, not a second string match in the view. + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setEndpoint(t *testing.T, id, endpoint string) { + t.Helper() + _, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET endpoint = $2 WHERE id = $1`, id, endpoint) + require.NoError(t, err) +} + +func TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, "berget/kb-whisper") + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.Contains(t, b, "left the homelab") + assert.Contains(t, b, "cloud service (berget)") +} + +func TestUploadStatus_ALocallyServedTranscriptCarriesNoCloudNote(t *testing.T) { + for _, endpoint := range []string{"iguana/kb-whisper", "koala/whisper", ""} { + t.Run(endpoint, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, endpoint) + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.NotContains(t, b, "left the homelab") + }) + } +} + +func TestUploadStatus_AnEndpointThatOnlyLooksLocalIsStillNamedAsCloud(t *testing.T) { + // The prefix alone must not decide it: IsLocalModel refuses path tricks. + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, "iguana/../berget/kb-whisper") + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.Contains(t, b, "left the homelab") +} From a43ee1950500da324f5eacea5bef26196df56cd8 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:02:55 +0200 Subject: [PATCH 04/27] feat(web): helper naming the cloud provider of a transcript, one definition of local (#52) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/web/uploads.go b/internal/web/uploads.go index 1558523..d35ee3f 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -18,6 +18,7 @@ import ( "git.d-ma.be/mathias/tapir/internal/adapters/chat" "git.d-ma.be/mathias/tapir/internal/adapters/store" + "git.d-ma.be/mathias/tapir/internal/config" "git.d-ma.be/mathias/tapir/internal/domain" "git.d-ma.be/mathias/tapir/internal/errkind" "git.d-ma.be/mathias/tapir/internal/uploads" @@ -243,6 +244,17 @@ func titleFromFilename(name string) string { return t } +// cloudProvider names the cloud service that produced a transcript, or false when it was +// served locally (or by an unknown endpoint). "Local" is config.IsLocalModel: the same +// definition the start-up validation uses, not a second string match here (#52). +func cloudProvider(endpoint string) (string, bool) { + if endpoint == "" || config.IsLocalModel(endpoint) { + return "", false + } + provider, _, _ := strings.Cut(endpoint, "/") + return provider, true +} + // 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 { From a65f2f2492d1249ef9b669204732c46065aab782 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:02:55 +0200 Subject: [PATCH 05/27] feat(web): say in plain words when a recording's audio left the homelab (#52) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.templ | 6 + internal/web/uploads_templ.go | 565 ++++++++++++++++++---------------- 2 files changed, 298 insertions(+), 273 deletions(-) diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ index 658179a..97bcbd2 100644 --- a/internal/web/uploads.templ +++ b/internal/web/uploads.templ @@ -66,6 +66,12 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) } of audio by { u.Endpoint }.

+ if provider, cloud := cloudProvider(u.Endpoint); cloud { +

+ This recording's audio left the homelab: it was transcribed by a cloud service ({ provider }) + because the local engine could not. +

+ } @reconciliationNote(u.Reconciliation) if len(u.Progress) > 0 {
diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go index 9ccc76d..44cff63 100644 --- a/internal/web/uploads_templ.go +++ b/internal/web/uploads_templ.go @@ -307,22 +307,41 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if provider, cloud := cloudProvider(u.Endpoint); cloud { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

This recording's audio left the homelab: it was transcribed by a cloud service (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(provider) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 71, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ") because the local engine could not.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } templ_7745c5c3_Err = reconciliationNote(u.Reconciliation).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(u.Progress) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
What was done") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">What was done") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -330,49 +349,49 @@ 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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if chatEnabled { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Ask about this recording

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">Ask about this recording

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
Transcript
")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Transcript
")
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			var templ_7745c5c3_Var16 string
-			templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
+			var templ_7745c5c3_Var17 string
+			templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(u.Content)
 			if templ_7745c5c3_Err != nil {
-				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 81, Col: 40}
+				return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 87, Col: 40}
 			}
-			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
 			if templ_7745c5c3_Err != nil {
 				return templ_7745c5c3_Err
 			}
-			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case domain.UploadFailed: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -380,25 +399,25 @@ 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, 30, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind)) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 87, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 93, Col: 42} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(u.Progress) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
Where it stopped") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
Where it stopped") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -406,43 +425,43 @@ 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, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" hx-trigger=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("every " + uploadPollEvery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 106, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -457,7 +476,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, 38, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -466,18 +485,18 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

Transcribing… a long recording takes a few minutes.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

Transcribing… a long recording takes a few minutes.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

Queued — waiting for its turn to be transcribed.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

Queued — waiting for its turn to be transcribed.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -504,12 +523,12 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var20 := templ.GetChildren(ctx) - if templ_7745c5c3_Var20 == nil { - templ_7745c5c3_Var20 = templ.NopComponent + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var22 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -521,20 +540,20 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 124, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 130, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

Ask about this recording

Answers come only from this recording's transcript, which is private to you.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

Ask about this recording

Answers come only from this recording's transcript, which is private to you.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -542,26 +561,26 @@ 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, 44, "

Back to the transcript

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">Back to the transcript

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout("Tapir — Ask about "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Tapir — Ask about "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -589,13 +608,13 @@ func videoNotice(hadVideo bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if hadVideo { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -624,289 +643,289 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if r != nil { switch r.Status { case domain.ReconcileDone: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

Reconciled from two transcripts (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var26 string - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 157, Col: 47} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " and ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

Reconciled from two transcripts (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt) + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 157, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 163, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "): ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " and ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes)) + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 158, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 163, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " differences, ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "): ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved)) + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 159, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 164, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " settled automatically, ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " differences, ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated)) + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 160, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 165, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " decided by the model (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " settled automatically, ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler) + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 160, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 166, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "), ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " decided by the model (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure)) + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 161, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 166, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " uncertain.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "), ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 167, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " uncertain.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(shownSpots(r)) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
Spots to check against the audio
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
    Spots to check against the audio
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, c := range shownSpots(r) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds)) + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 169, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 175, Col: 33} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ": ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ": ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if c.Before != "" { - 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 } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Before) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Before) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 171, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 177, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) 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 } } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 173, Col: 18} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt) + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 173, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " — ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " — ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if c.Unsure { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "uncertain kept ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 175, Col: 70} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "changed used ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "uncertain kept ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 177, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 181, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "changed used ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 183, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if moreSpots(r) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

and ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

and ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r))) + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 183, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 189, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " more not listed

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " more not listed

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case domain.ReconcileSingle: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "

Only one engine produced this transcript (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 189, Col: 88} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "), so there was no second opinion to reconcile it with.

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - case domain.ReconcileSkipped: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

Two transcripts were made but could not be reconciled (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

Only one engine produced this transcript (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 191, Col: 101} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 195, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "); the text shown is ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "), so there was no second opinion to reconcile it with.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case domain.ReconcileSkipped: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

Two transcripts were made but could not be reconciled (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 191, Col: 132} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 101} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "'s, unreconciled.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "); the text shown is ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 132} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "'s, unreconciled.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -937,122 +956,103 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ. }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var44 := templ.GetChildren(ctx) + if templ_7745c5c3_Var44 == nil { + templ_7745c5c3_Var44 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - 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 } for _, s := range steps { - templ_7745c5c3_Var44 := []any{"step step-" + string(s.State)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) + var templ_7745c5c3_Var45 = []any{"step step-" + string(s.State)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var45...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
    1. ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, ">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if s.State == domain.StepRunning && live { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if s.State == domain.StepRunning { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "! ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "! ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s)) + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 215, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 221, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 217, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 223, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if t := stepTime(s, now); t != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(t) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 219, Col: 32} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if s.Note != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note) + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 222, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 225, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { @@ -1063,70 +1063,89 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ. return templ_7745c5c3_Err } } - if s.State == domain.StepRunning && s.Total > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Done)) + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 225, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 228, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" max=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " ") + 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, 92, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" max=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done)) + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.Itoa(s.Total)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 226, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 231, Col: 90} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " of ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total)) + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 226, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 232, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " of ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 232, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
    2. ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1152,45 +1171,45 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var54 := templ.GetChildren(ctx) - if templ_7745c5c3_Var54 == nil { - templ_7745c5c3_Var54 = templ.NopComponent + templ_7745c5c3_Var55 := templ.GetChildren(ctx) + if templ_7745c5c3_Var55 == nil { + templ_7745c5c3_Var55 = templ.NopComponent } ctx = templ.ClearChildren(ctx) switch h { case healthLost: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

The service stopped reporting ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 239, Col: 54} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - 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.

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - case healthQuiet: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

Nothing new for ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

The service stopped reporting ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var56 string templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 244, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 245, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - 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.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case healthQuiet: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

Nothing new for ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 250, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From ba18668796f29ed41a934d48c9a04f3f2866a9e1 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:03:07 +0200 Subject: [PATCH 06/27] docs(feature): cloud-served transcripts say the audio left the homelab; empty transcript also triggers the fallback (#52) Co-Authored-By: Claude Sonnet 5.5 --- docs/homelab-integration.md | 3 ++- docs/use-cases/audio_upload.feature | 5 +++++ test/acceptance/scenario_coverage_test.go | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md index 6537d96..7472084 100644 --- a/docs/homelab-integration.md +++ b/docs/homelab-integration.md @@ -14,7 +14,8 @@ it** — endpoints and aliases drift, and this file is a snapshot (2026-06-06), not LiteLLM. Use `30401` for off-cluster LiteLLM. `confirm` resolved. - **Speech-to-text (ADR-033):** the same gateway serves `POST /v1/audio/transcriptions` for `iguana/kb-whisper` (primary, local) and `berget/kb-whisper` (fallback, EU cloud — private audio - leaves the homelab if it is used). **Call it via the NodePort / in-cluster address above, NOT + leaves the homelab if it is used: when the primary fails **or returns an empty transcript**, + e.g. a silent or music-only file; the transcript page then says so, #52). **Call it via the NodePort / in-cluster address above, NOT `https://llm-api.d-ma.be`:** that public ingress (openresty) returns `413` for bodies over roughly 8–10 MB (~5 minutes of 16 kHz WAV), while the NodePort took 174 MB (90 minutes) in one request (measured 2026-10-01, `docs/research/audio-length-ceiling-2026-10-01.md`). Send multipart diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature index e45c465..cd3ed30 100644 --- a/docs/use-cases/audio_upload.feature +++ b/docs/use-cases/audio_upload.feature @@ -240,6 +240,11 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it Then it is transcribed anyway and its audio step says how long the audio really is And a complete recording whose header over-estimated its length is not refused + Scenario: A transcript made by the cloud fallback says the audio left the homelab + When the transcript was produced by an endpoint that is not local + Then its page says in plain words that the audio left the homelab and names the provider + And a locally transcribed recording carries no such note + Scenario: A full queue is a retry, not an error When too many recordings are already waiting Then the upload is answered as busy, its row is failed honestly and its audio is deleted diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go index 9bbf026..0dfcbba 100644 --- a/test/acceptance/scenario_coverage_test.go +++ b/test/acceptance/scenario_coverage_test.go @@ -122,6 +122,8 @@ var scenarioCoverage = map[string]string{ "I can ask about my recording and get a grounded answer": "TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget", "A long recording is not cut to its first third": "TestAnswerMaxCharsOverridesTheServiceCap", + "A transcript made by the cloud fallback says the audio left the homelab": "TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab", + // audio_upload.feature, ADR-034 "Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled", "Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel", From 68815755e82bfbd046b479b196f70352c17239a0 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:03:37 +0200 Subject: [PATCH 07/27] test(progress): a hung save or beat must not stall the reporter or the heartbeat (#51), red Co-Authored-By: Claude Sonnet 5.5 --- internal/progress/tracker_stall_test.go | 98 +++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 internal/progress/tracker_stall_test.go diff --git a/internal/progress/tracker_stall_test.go b/internal/progress/tracker_stall_test.go new file mode 100644 index 0000000..ce59436 --- /dev/null +++ b/internal/progress/tracker_stall_test.go @@ -0,0 +1,98 @@ +package progress_test + +// Observes what a database that stops answering does to the job it reports on (#51): +// progress is cosmetic, so a save that never returns must not stall the engine calls that +// report through the tracker, nor the heartbeat that says the process is alive. + +import ( + "bytes" + "context" + "log/slog" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/domain" + "git.d-ma.be/mathias/tapir/internal/progress" +) + +// stallSink never answers until its context ends, like a database connection that hung. +type stallSink struct{ beats atomic.Int32 } + +func (s *stallSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error { + <-ctx.Done() + return ctx.Err() +} + +func (s *stallSink) Beat(ctx context.Context) error { + s.beats.Add(1) + <-ctx.Done() + return ctx.Err() +} + +type lockedBuf struct { + mu sync.Mutex + b bytes.Buffer +} + +func (l *lockedBuf) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.Write(p) +} + +func (l *lockedBuf) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.b.String() +} + +func stallTracker(sink progress.Sink, buf *lockedBuf) *progress.Tracker { + log := slog.New(slog.NewTextHandler(buf, nil)) + return progress.NewTracker(context.Background(), sink, log, + []progress.Planned{{Key: "a", Label: "A"}}, progress.WithSaveTimeout(50*time.Millisecond)) +} + +func TestTracker_AHungSaveDoesNotBlockTheReporterPastItsTimeout(t *testing.T) { + buf := &lockedBuf{} + tr := stallTracker(&stallSink{}, buf) + + done := make(chan struct{}) + go func() { tr.Begin("a", 0); tr.Advance("a", 1); tr.Done("a"); close(done) }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("the reporter was blocked by a save that never returned") + } + require.Contains(t, buf.String(), "progress: not saved", "a dropped save is logged") +} + +func TestTracker_AHungBeatDoesNotStopTheHeartbeat(t *testing.T) { + sink := &stallSink{} + tr := stallTracker(sink, &lockedBuf{}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour) + + require.Eventually(t, func() bool { return sink.beats.Load() >= 3 }, 3*time.Second, 5*time.Millisecond, + "each beat is abandoned at its timeout and the next one is tried") +} + +func TestTracker_TheStillRunningLineIsNotHeldUpBehindAHungSave(t *testing.T) { + buf := &lockedBuf{} + tr := stallTracker(&stallSink{}, buf) + go tr.Begin("a", 0) // holds the tracker's lock inside a save that hangs until its timeout + time.Sleep(10 * time.Millisecond) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tr.Heartbeat(ctx, time.Hour, 5*time.Millisecond) + + require.Eventually(t, func() bool { return strings.Contains(buf.String(), "still running") }, 3*time.Second, 5*time.Millisecond) +} From 2ec1557a8cedc83b8915d7968fec897c622f0f3c Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:03:56 +0200 Subject: [PATCH 08/27] fix(progress): bound every save and heartbeat write so a hung database cannot stall the job (#51) Co-Authored-By: Claude Sonnet 5.5 --- internal/progress/tracker.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go index 71c3d2f..d8878b4 100644 --- a/internal/progress/tracker.go +++ b/internal/progress/tracker.go @@ -41,8 +41,9 @@ type Sink interface { } const ( - defaultMinSave = 2 * time.Second - logAdvanceEach = time.Minute + defaultMinSave = 2 * time.Second + defaultSaveTimeout = 5 * time.Second + logAdvanceEach = time.Minute ) // Option tunes a Tracker. @@ -55,6 +56,11 @@ func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now = // Done, Fail and Finish always save. func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } } +// WithSaveTimeout bounds one save or heartbeat write. Progress is cosmetic, so a database +// that stops answering costs a dropped write, never a stalled job: saves run under the +// tracker's lock, which Begin and Advance (called by the engines) need (#51). +func WithSaveTimeout(d time.Duration) Option { return func(t *Tracker) { t.saveTimeout = d } } + // Tracker implements Reporter for one job. type Tracker struct { ctx context.Context @@ -63,6 +69,8 @@ type Tracker struct { now func() time.Time minSave time.Duration + saveTimeout time.Duration + mu sync.Mutex steps []domain.ProgressStep lastAdvanceSave time.Time @@ -72,7 +80,7 @@ type Tracker struct { // NewTracker plans a job's phases. ctx is used for the saves, so it should outlive the // job's own timeout: a job that timed out still gets its failure recorded. func NewTracker(ctx context.Context, sink Sink, log *slog.Logger, plan []Planned, opts ...Option) *Tracker { - t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave} + t := &Tracker{ctx: ctx, sink: sink, log: log, now: time.Now, minSave: defaultMinSave, saveTimeout: defaultSaveTimeout} for _, o := range opts { o(t) } @@ -212,7 +220,10 @@ func (t *Tracker) Heartbeat(ctx context.Context, beatEvery, stillEvery time.Dura case <-ctx.Done(): return case <-beat.C: - if err := t.sink.Beat(ctx); err != nil && ctx.Err() == nil { + beatCtx, cancel := context.WithTimeout(ctx, t.saveTimeout) + err := t.sink.Beat(beatCtx) + cancel() + if err != nil && ctx.Err() == nil { t.log.Warn("progress: heartbeat not saved", "err", err) } case <-still.C: @@ -254,7 +265,9 @@ func (t *Tracker) find(key string) *domain.ProgressStep { // dropped: progress is cosmetic and must never fail the job it describes. func (t *Tracker) save() { snapshot := append([]domain.ProgressStep(nil), t.steps...) - if err := t.sink.SaveProgress(t.ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil { + ctx, cancel := context.WithTimeout(t.ctx, t.saveTimeout) + defer cancel() + if err := t.sink.SaveProgress(ctx, snapshot, t.now()); err != nil && t.ctx.Err() == nil { t.log.Warn("progress: not saved", "err", err) } } From 813192e5ba5b1b005cf36e3b1a3c610a24c675b5 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:04:15 +0200 Subject: [PATCH 09/27] test: heartbeats are stamped with the application clock, not the database's NOW() (#51), red Co-Authored-By: Claude Sonnet 5.5 --- cmd/tapir/uploads_test.go | 2 +- .../uploaded_transcripts_progress_test.go | 8 ++--- internal/progress/tracker_stall_test.go | 2 +- internal/progress/tracker_test.go | 33 +++++++++++++++---- internal/uploads/worker_test.go | 2 +- 5 files changed, 33 insertions(+), 14 deletions(-) diff --git a/cmd/tapir/uploads_test.go b/cmd/tapir/uploads_test.go index d96aeb2..cbedef2 100644 --- a/cmd/tapir/uploads_test.go +++ b/cmd/tapir/uploads_test.go @@ -44,7 +44,7 @@ func (f *fakeUploadsStore) SaveProgress(context.Context, string, string, []domai return nil } -func (f *fakeUploadsStore) Heartbeat(context.Context, string, string) error { return nil } +func (f *fakeUploadsStore) Heartbeat(context.Context, string, string, time.Time) error { return nil } func (f *fakeUploadsStore) FailInterruptedUploads(context.Context) (int, error) { f.sweeps++ diff --git a/internal/adapters/store/uploaded_transcripts_progress_test.go b/internal/adapters/store/uploaded_transcripts_progress_test.go index 3456df2..7fdf827 100644 --- a/internal/adapters/store/uploaded_transcripts_progress_test.go +++ b/internal/adapters/store/uploaded_transcripts_progress_test.go @@ -68,13 +68,13 @@ func TestUploadProgress_AHeartbeatMovesAliveAtAndNotProgressAt(t *testing.T) { ctx := context.Background() at := time.Date(2026, 10, 2, 8, 30, 0, 0, time.UTC) require.NoError(t, s.SaveProgress(ctx, userA, id, []domain.ProgressStep{{Key: "audio", Label: "a", State: domain.StepRunning}}, at)) - before := time.Now().Add(-time.Second) + beatAt := at.Add(90 * time.Second) // deliberately not the database's NOW() - require.NoError(t, s.Heartbeat(ctx, userA, id)) + require.NoError(t, s.Heartbeat(ctx, userA, id, beatAt)) got, err := s.GetUpload(ctx, userA, id) require.NoError(t, err) - assert.True(t, got.AliveAt.After(before), "the heartbeat must stamp the row with now") + assert.True(t, got.AliveAt.Equal(beatAt), "the heartbeat is stamped with the application's clock, the same one progress_at uses, not the database's NOW() (#51)") assert.True(t, got.ProgressAt.Equal(at), "alive is not progress: a heartbeat must not move progress_at") } @@ -99,7 +99,7 @@ func TestUploadProgress_ByAnotherUserChangesNothing(t *testing.T) { ctx := context.Background() errSave := s.SaveProgress(ctx, userB, id, []domain.ProgressStep{{Key: "x", Label: "X", State: domain.StepDone}}, time.Now()) - errBeat := s.Heartbeat(ctx, userB, id) + errBeat := s.Heartbeat(ctx, userB, id, time.Now()) assert.ErrorIs(t, errSave, store.ErrUploadNotFound) assert.ErrorIs(t, errBeat, store.ErrUploadNotFound) diff --git a/internal/progress/tracker_stall_test.go b/internal/progress/tracker_stall_test.go index ce59436..f31f425 100644 --- a/internal/progress/tracker_stall_test.go +++ b/internal/progress/tracker_stall_test.go @@ -28,7 +28,7 @@ func (s *stallSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ return ctx.Err() } -func (s *stallSink) Beat(ctx context.Context) error { +func (s *stallSink) Beat(ctx context.Context, _ time.Time) error { s.beats.Add(1) <-ctx.Done() return ctx.Err() diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go index 52b00ee..8fc2aff 100644 --- a/internal/progress/tracker_test.go +++ b/internal/progress/tracker_test.go @@ -17,11 +17,12 @@ import ( // fakeSink records every saved snapshot and every heartbeat. type fakeSink struct { - mu sync.Mutex - saves [][]domain.ProgressStep - at []time.Time - beats int - err error + mu sync.Mutex + saves [][]domain.ProgressStep + at []time.Time + beats int + beatAt []time.Time + err error } func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep, at time.Time) error { @@ -32,10 +33,11 @@ func (f *fakeSink) SaveProgress(_ context.Context, steps []domain.ProgressStep, return f.err } -func (f *fakeSink) Beat(context.Context) error { +func (f *fakeSink) Beat(_ context.Context, at time.Time) error { f.mu.Lock() defer f.mu.Unlock() f.beats++ + f.beatAt = append(f.beatAt, at) return f.err } @@ -297,7 +299,7 @@ func (r *retainingSink) SaveProgress(_ context.Context, steps []domain.ProgressS r.got = append(r.got, steps) return nil } -func (r *retainingSink) Beat(context.Context) error { return nil } +func (r *retainingSink) Beat(context.Context, time.Time) error { return nil } func TestTracker_ASnapshotHandedToTheSinkIsNotChangedByLaterTransitions(t *testing.T) { sink := &retainingSink{} @@ -400,3 +402,20 @@ func TestTracker_AnnotateAttachesANoteToAStepAndSavesItAtOnce(t *testing.T) { require.Equal(t, "20 min of the 79 the file claimed", sink.last(t)[0].Note) require.NotPanics(t, func() { tr.Annotate("no-such-step", "x") }) } + +func TestTracker_BeatIsStampedWithTheTrackersClock(t *testing.T) { + // alive_at used the database's NOW() while the page compared it with the application + // clock; a skewed database clock showed a healthy job as lost (#51). + tr, sink, clk, _ := newTracker(t) + clk.tick(42 * time.Second) + want := clk.now() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour) + + require.Eventually(t, func() bool { return sink.beatCount() >= 1 }, 2*time.Second, 5*time.Millisecond) + sink.mu.Lock() + defer sink.mu.Unlock() + require.True(t, sink.beatAt[0].Equal(want), "beat stamped %v, the tracker's clock says %v", sink.beatAt[0], want) +} diff --git a/internal/uploads/worker_test.go b/internal/uploads/worker_test.go index 8fac296..728e404 100644 --- a/internal/uploads/worker_test.go +++ b/internal/uploads/worker_test.go @@ -70,7 +70,7 @@ func (s *wStore) SaveProgress(_ context.Context, _, _ string, steps []domain.Pro return nil } -func (s *wStore) Heartbeat(context.Context, string, string) error { +func (s *wStore) Heartbeat(context.Context, string, string, time.Time) error { time.Sleep(s.beatDelay) s.mu.Lock() defer s.mu.Unlock() From b8af7f32c79f0b8f62a2f19029fd5a34d13171a0 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:04:42 +0200 Subject: [PATCH 10/27] fix(progress): stamp the heartbeat with the application clock the page compares it with (#51) Co-Authored-By: Claude Sonnet 5.5 --- internal/adapters/store/uploaded_transcripts.go | 12 +++++++----- internal/progress/tracker.go | 4 ++-- internal/uploads/worker.go | 6 ++++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go index a77d160..c62fb5a 100644 --- a/internal/adapters/store/uploaded_transcripts.go +++ b/internal/adapters/store/uploaded_transcripts.go @@ -80,12 +80,14 @@ func (s *Store) SaveProgress(ctx context.Context, userID, id string, steps []dom WHERE id = $1 AND user_id = $2`, b, at) } -// Heartbeat stamps the upload as alive now. It does not touch progress_at: alive says -// the service is still reporting, not that work is moving (ADR-035). -func (s *Store) Heartbeat(ctx context.Context, userID, id string) error { +// Heartbeat stamps the upload as alive at the given time. It does not touch progress_at: +// alive says the service is still reporting, not that work is moving (ADR-035). The time +// comes from the application clock, the one progress_at and the page's health check use, +// so a database clock that disagrees cannot make a healthy job look lost (#51). +func (s *Store) Heartbeat(ctx context.Context, userID, id string, at time.Time) error { return s.updateUpload(ctx, userID, id, "heartbeat", - `UPDATE uploaded_transcripts SET alive_at = NOW() - WHERE id = $1 AND user_id = $2`) + `UPDATE uploaded_transcripts SET alive_at = $3 + WHERE id = $1 AND user_id = $2`, at) } // FailUpload moves a queued or transcribing upload to failed with a typed reason. A diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go index d8878b4..c365d58 100644 --- a/internal/progress/tracker.go +++ b/internal/progress/tracker.go @@ -37,7 +37,7 @@ type Reporter interface { // is for people, so a failed write is logged and never stops the job. type Sink interface { SaveProgress(ctx context.Context, steps []domain.ProgressStep, at time.Time) error - Beat(ctx context.Context) error + Beat(ctx context.Context, at time.Time) error } const ( @@ -221,7 +221,7 @@ func (t *Tracker) Heartbeat(ctx context.Context, beatEvery, stillEvery time.Dura return case <-beat.C: beatCtx, cancel := context.WithTimeout(ctx, t.saveTimeout) - err := t.sink.Beat(beatCtx) + err := t.sink.Beat(beatCtx, t.now()) cancel() if err != nil && ctx.Err() == nil { t.log.Warn("progress: heartbeat not saved", "err", err) diff --git a/internal/uploads/worker.go b/internal/uploads/worker.go index 9fa2958..a29fecf 100644 --- a/internal/uploads/worker.go +++ b/internal/uploads/worker.go @@ -47,7 +47,7 @@ type WorkerStore interface { CompleteUpload(ctx context.Context, userID, id string, t domain.Transcription) error FailUpload(ctx context.Context, userID, id string, kind domain.UploadErrorKind) error SaveProgress(ctx context.Context, userID, id string, steps []domain.ProgressStep, at time.Time) error - Heartbeat(ctx context.Context, userID, id string) error + Heartbeat(ctx context.Context, userID, id string, at time.Time) error } // WorkerOption tunes a Worker. @@ -91,7 +91,9 @@ func (s jobSink) SaveProgress(ctx context.Context, steps []domain.ProgressStep, return s.st.SaveProgress(ctx, s.userID, s.id, steps, at) } -func (s jobSink) Beat(ctx context.Context) error { return s.st.Heartbeat(ctx, s.userID, s.id) } +func (s jobSink) Beat(ctx context.Context, at time.Time) error { + return s.st.Heartbeat(ctx, s.userID, s.id, at) +} // Worker transcribes accepted uploads in-process, ONE AT A TIME (ADR-033). It is // not a second deployable: it shares the web process's lifetime, which is why it From 4ef3e85d16879587edb5b2f548fc168a1aa64041 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:05:16 +0200 Subject: [PATCH 11/27] test(web): the home list links to recordings only when uploads are mounted (#37), red Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads_nav_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/web/uploads_nav_test.go diff --git a/internal/web/uploads_nav_test.go b/internal/web/uploads_nav_test.go new file mode 100644 index 0000000..bf62bdd --- /dev/null +++ b/internal/web/uploads_nav_test.go @@ -0,0 +1,19 @@ +package web_test + +// /uploads was mounted but nothing linked to it (#37). The shared Layout has no access to +// the App, so the entry point is the home list, shown only when uploads are mounted. + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHomeList_LinksToRecordingsOnlyWhenUploadsAreMounted(t *testing.T) { + with, _ := newUploadApp(t, audioOK, 200<<20) + without := newApp(t) + + assert.Contains(t, body(t, do(t, with, get("/"))), `href="/uploads"`) + assert.NotContains(t, body(t, do(t, without, get("/"))), `href="/uploads"`, + "a link to a route that is not mounted would be a dead end") +} From 453464a22b8a0d238f13ffc4a1a2835b4f3f291e Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:05:28 +0200 Subject: [PATCH 12/27] feat(web): the home list links to recordings when uploads are mounted (#37) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/handlers.go | 2 +- internal/web/paste_test.go | 2 +- internal/web/views.templ | 5 +- internal/web/views_templ.go | 706 ++++++++++++++++++------------------ 4 files changed, 364 insertions(+), 351 deletions(-) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 2e06cb5..141120a 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -309,7 +309,7 @@ func (a *App) handleList(w http.ResponseWriter, r *http.Request) { a.render(w, r, summaryList(buckets, hasConnected, autoSummarize)) return } - a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize)) + a.render(w, r, ListPage(buckets, f, stats, takeFlash(w, r), hasConnected, channels, autoSummarize, a.Uploads != nil)) } // handleDetail renders one summary in full (highlights, takeaways, action group). diff --git a/internal/web/paste_test.go b/internal/web/paste_test.go index ee92feb..6ca9a54 100644 --- a/internal/web/paste_test.go +++ b/internal/web/paste_test.go @@ -66,7 +66,7 @@ func TestParseYouTubeVideoID(t *testing.T) { func TestListPageShowsPasteFormOnlyWhenConnected(t *testing.T) { render := func(connected bool) string { var buf bytes.Buffer - if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil, true).Render(context.Background(), &buf); err != nil { + if err := ListPage(listBuckets{}, Filter{}, PipelineStats{}, "", connected, nil, true, false).Render(context.Background(), &buf); err != nil { t.Fatalf("render: %v", err) } return buf.String() diff --git a/internal/web/views.templ b/internal/web/views.templ index 55d9c9b..d137c44 100644 --- a/internal/web/views.templ +++ b/internal/web/views.templ @@ -107,9 +107,12 @@ templ flashBanner(code string) { // #summary-list region; a non-HTMX request renders the whole page. flash carries // a one-shot notification (e.g. "connected", "registered") surfaced on arrival // after a POST→redirect. -templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool) { +templ ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool, uploadsEnabled bool) { @Layout("Tapir — Summaries") { @flashBanner(flash) + if uploadsEnabled { +

Recordings: upload audio and get a private transcript.

+ } if hasConnected { @pasteForm() } diff --git a/internal/web/views_templ.go b/internal/web/views_templ.go index 8f2732f..2943941 100644 --- a/internal/web/views_templ.go +++ b/internal/web/views_templ.go @@ -294,7 +294,7 @@ func flashBanner(code string) templ.Component { } ctx = templ.ClearChildren(ctx) if f, ok := flashFor(code); ok { - templ_7745c5c3_Var9 := []any{"flash", "flash-" + f.Kind} + var templ_7745c5c3_Var9 = []any{"flash", "flash-" + f.Kind} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -338,7 +338,7 @@ func flashBanner(code string) templ.Component { // #summary-list region; a non-HTMX request renders the whole page. flash carries // a one-shot notification (e.g. "connected", "registered") surfaced on arrival // after a POST→redirect. -func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool) templ.Component { +func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasConnected bool, channels []string, autoSummarize bool, uploadsEnabled bool) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -379,13 +379,23 @@ func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasCon if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if uploadsEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Recordings: upload audio and get a private transcript.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } if hasConnected { templ_7745c5c3_Err = pasteForm().Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -395,7 +405,7 @@ func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasCon return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -405,27 +415,27 @@ func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasCon return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if (stats.RateLimited+stats.Pending) > 0 && autoSummarize { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually. Check back tomorrow.

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if (stats.RateLimited+stats.Pending) > 0 && !autoSummarize { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

You are in Manual mode: new videos appear here but are not summarized automatically. Use the Summarize button on the ones you want.

Switch to Automatic to have new videos summarized for you.

") + if (stats.RateLimited+stats.Pending) > 0 && autoSummarize { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Tapir fetches captions slowly on purpose, to respect YouTube's limits — new summaries land gradually. Check back tomorrow.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if (stats.RateLimited+stats.Pending) > 0 && !autoSummarize { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

You are in Manual mode: new videos appear here but are not summarized automatically. Use the Summarize button on the ones you want.

Switch to Automatic to have new videos summarized for you.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -433,7 +443,7 @@ func ListPage(b listBuckets, f Filter, stats PipelineStats, flash string, hasCon if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -472,68 +482,68 @@ func pipelineBar(s PipelineStats) templ.Component { templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if s.Summarized > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d ready", s.Summarized)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 150, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 153, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if s.RateLimited+s.Pending > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d in queue", s.RateLimited+s.Pending)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 153, Col: 62} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } + if s.RateLimited+s.Pending > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d in queue", s.RateLimited+s.Pending)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 156, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } if s.NoText > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d no captions", s.NoText)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 156, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 159, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -566,7 +576,7 @@ func pasteForm() templ.Component { templ_7745c5c3_Var18 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -595,73 +605,73 @@ func filterForm(f Filter, channels []string) templ.Component { templ_7745c5c3_Var19 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(channels) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " filtering…
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "> Summarized only filtering…") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -698,33 +708,33 @@ func summaryList(b listBuckets, hasConnected bool, autoSummarize bool) templ.Com ctx = templ.ClearChildren(ctx) if b.empty() { if hasConnected { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
Your account is connected ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
Your account is connected ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if autoSummarize { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "Tapir is finding your subscriptions and fetching captions — summaries appear here gradually. Check back later.") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Tapir is finding your subscriptions. You are in Manual mode, so videos appear here with a Summarize button — pick the ones you want, or switch to Automatic in your account.") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "Tapir is finding your subscriptions. You are in Manual mode, so videos appear here with a Summarize button — pick the ones you want, or switch to Automatic in your account.") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
No videos yet Connect your YouTube account to get started.

Connect YouTube

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
No videos yet Connect your YouTube account to get started.

Connect YouTube

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } else { - 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 } @@ -734,48 +744,48 @@ func summaryList(b listBuckets, hasConnected bool, autoSummarize bool) templ.Com 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 b.NoCaption > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d video(s) have no captions and can't be summarized.", b.NoCaption)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 243, Col: 113} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 246, Col: 113} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(b.Older) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Show %d older videos — summarize on demand", len(b.Older))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 247, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 250, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -785,7 +795,7 @@ func summaryList(b listBuckets, hasConnected bool, autoSummarize bool) templ.Com return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -826,12 +836,12 @@ func VideoCard(r store.SummaryRow) templ.Component { templ_7745c5c3_Var25 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var26 := []any{"card", templ.KV("card-pending", !r.Summarized)} + var templ_7745c5c3_Var26 = []any{"card", templ.KV("card-pending", !r.Summarized)} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if r.Summarized { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 281, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 284, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if cardMeta(r) != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 284, Col: 39} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } + if cardMeta(r) != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 287, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } if r.Summarized { if p := previewText(r.Summary, 160); p != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(p) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 288, Col: 33} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 291, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if r.Summarized { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if r.AIProvider != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.AIProvider) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 295, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 298, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if r.FallbackUsed { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "fallback") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1019,125 +1019,135 @@ func VideoCard(r store.SummaryRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if r.FallbackUsed { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "fallback") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } if len(r.Actions) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(r.Actions, ", ")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 301, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 304, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } else if r.TranscriptStatus == "none" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " No transcript available") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " No transcript available") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if r.SummarizeRequested { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " Queued summarizing shortly") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " Queued summarizing shortly") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if r.TranscriptStatus == "rate_limited" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " In queue
    In queue
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" hx-swap=\"outerHTML\" class=\"card-nudge-form\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " Not summarized
    Not summarized
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" hx-swap=\"outerHTML\" class=\"card-nudge-form\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1172,72 +1182,72 @@ func expandedCard(r store.SummaryRow, chatEnabled bool) templ.Component { templ_7745c5c3_Var44 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var46 string templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 351, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 354, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " collapse ↑
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\" hx-swap=\"outerHTML\" class=\"card-collapse\" title=\"Collapse\">collapse ↑
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1251,7 +1261,7 @@ func expandedCard(r store.SummaryRow, chatEnabled bool) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1285,7 +1295,7 @@ func TapirSpinner() templ.Component { templ_7745c5c3_Var50 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
    ")
    +		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
    ")
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    @@ -1293,7 +1303,7 @@ func TapirSpinner() templ.Component {
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    -		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
    ")
    +		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
    ")
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    @@ -1301,7 +1311,7 @@ func TapirSpinner() templ.Component {
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    -		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
    ")
    +		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
    ")
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    @@ -1309,33 +1319,33 @@ func TapirSpinner() templ.Component {
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    -		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var52 string templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(tapirBarFill) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 378, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 381, Col: 99} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

    Fetching captions… Chewing the cud… Munching leaves… Distilling the gist… Summarizing…

    Summarizing…

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "

    Fetching captions… Chewing the cud… Munching leaves… Distilling the gist… Summarizing…

    Summarizing…

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1369,64 +1379,64 @@ func processingCard(r store.SummaryRow) templ.Component { templ_7745c5c3_Var53 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" hx-trigger=\"every 2s\" hx-swap=\"outerHTML\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var56 string templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 406, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 409, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if cardMeta(r) != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var57 string templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 408, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 411, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1435,7 +1445,7 @@ func processingCard(r store.SummaryRow) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1468,69 +1478,69 @@ func waitingCard(r store.SummaryRow) templ.Component { templ_7745c5c3_Var58 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" hx-trigger=\"every 30s\" hx-swap=\"outerHTML\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var61 string templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 426, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 429, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if cardMeta(r) != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var62 string templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 428, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 431, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
    ")
    +		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "
    ")
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    @@ -1538,7 +1548,7 @@ func waitingCard(r store.SummaryRow) templ.Component {
     		if templ_7745c5c3_Err != nil {
     			return templ_7745c5c3_Err
     		}
    -		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "

    Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.

  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "

    Waiting on YouTube rate limits. Tapir keeps trying, slowly and politely, and the summary will appear here on its own.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1569,56 +1579,56 @@ func noCaptionsCard(r store.SummaryRow) templ.Component { templ_7745c5c3_Var63 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var65 string templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 443, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 446, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if cardMeta(r) != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var66 string templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(cardMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 445, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 448, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "

    No captions available, so Tapir cannot summarize this one.

  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "

    No captions available, so Tapir cannot summarize this one.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1651,86 +1661,86 @@ func summaryBody(r store.SummaryRow) templ.Component { templ_7745c5c3_Var67 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if detailMeta(r) != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var68 string templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(detailMeta(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 458, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 461, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if r.FallbackUsed { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "fallback") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "fallback") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if url, ok := embedURL(r.ProviderVideoID); ok { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" loading=\"lazy\" referrerpolicy=\"strict-origin-when-cross-origin\" allow=\"accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if r.URL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

    watch on source ↗

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\" rel=\"noopener noreferrer\">watch on source ↗

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1740,77 +1750,77 @@ func summaryBody(r store.SummaryRow) templ.Component { return templ_7745c5c3_Err } if len(r.Takeaways) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "

    Takeaways

      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

      Takeaways

        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, t := range r.Takeaways { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "
      • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var72 string templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 485, Col: 12} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 488, Col: 12} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(r.Highlights) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "

    Highlights

      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "

      Highlights

        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, h := range r.Highlights { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "
      • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var73 string templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(h) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 495, Col: 12} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 498, Col: 12} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "

    Summary

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "

    Summary

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var74 string templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(r.Summary) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 502, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 505, Col: 29} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1855,20 +1865,20 @@ func DetailPage(r store.SummaryRow, chatEnabled bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "

    ← Summaries

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "

    ← Summaries

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var77 string templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 514, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 517, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1882,7 +1892,7 @@ func DetailPage(r store.SummaryRow, chatEnabled bool) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1922,33 +1932,33 @@ func chatReveal(videoID string) templ.Component { templ_7745c5c3_Var78 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "
    Dig deeper — ask about this video →
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "\" hx-target=\"#chat-section\" hx-swap=\"outerHTML\">Dig deeper — ask about this video →") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1980,7 +1990,7 @@ func chatSection(v chatView) templ.Component { templ_7745c5c3_Var81 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "

    Ask about this video

    Answers come only from this video's stored transcript — Tapir never fetches anything new here.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "

    Ask about this video

    Answers come only from this video's stored transcript — Tapir never fetches anything new here.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1988,7 +1998,7 @@ func chatSection(v chatView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2032,20 +2042,20 @@ func ChatPage(r store.SummaryRow, v chatView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "

    ← Summaries

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "

    ← Summaries

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var84 string templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(displayTitle(r)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 560, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 563, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2057,7 +2067,7 @@ func ChatPage(r store.SummaryRow, v chatView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2097,231 +2107,231 @@ func chatPanel(v chatView) templ.Component { templ_7745c5c3_Var85 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !v.Available { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "

    Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "

    Chat isn't available for this video — its transcript isn't stored, and chat never fetches new captions. Summarize the video first to store its transcript.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { if len(v.History) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, t := range v.History { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var86 string templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(t.Question) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 580, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 583, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var87 string templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(t.Answer) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 581, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 584, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Truncated { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "

    Working from a bounded portion of a long transcript — answers about the end of the ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

    Working from a bounded portion of a long transcript — answers about the end of the ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var88 string templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(v.subject()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 586, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 589, Col: 129} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " may be incomplete.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, " may be incomplete.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var89 string templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 589, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 592, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\" hx-target=\"#chat-panel\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, t := range v.History { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, " thinking…
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\" required aria-label=\"Your question\"> thinking…") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2365,53 +2375,53 @@ func RegisterPage(email, errMsg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "

    Complete your registration

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "

    Complete your registration

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if email != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "

    Signed in as ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "

    Signed in as ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var99 string templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 628, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 631, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, ".

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, ".

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "

    Choose a display name to finish setting up your Tapir account.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "

    Choose a display name to finish setting up your Tapir account.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errMsg != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var100 string templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 632, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 635, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2466,43 +2476,43 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "

    Account

    Display name
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "

    Account

    Display name
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var103 string templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 656, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 659, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if email != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "
    Signed in as
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "
    Signed in as
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var104 string templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(email) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 659, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 662, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "

    Summarization

    Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "

    Summarization

    Automatic summarizes new videos from about the last week as they are discovered. Older videos stay browsable — summarize them on demand. Manual lets you pick which videos to summarize — every new video appears in your list with a Summarize button.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2510,172 +2520,172 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(channelErrors) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "

    Unavailable channels

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "

    Unavailable channels

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var105 string templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 676, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 679, Col: 100} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, " These may have been deleted or made private on YouTube.

      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " These may have been deleted or made private on YouTube.

        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, ce := range channelErrors { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "
      • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var106 string templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(ce.ChannelName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 682, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 685, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " unavailable since ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, " unavailable since ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var107 string templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(ce.FirstSeen.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 684, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 687, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "

    Connected accounts

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "

    Connected accounts

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(conns) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "

    No connected video accounts yet.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "

    No connected video accounts yet.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "
        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, c := range conns { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "
      • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var108 string templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 699, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 702, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if c.ProviderAccount != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var109 string templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(c.ProviderAccount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 701, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 704, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var110 string templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 703, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 706, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "
        connected ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "
        connected ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var111 string templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(c.ConnectedAt.Format("2006-01-02")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 705, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 708, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if !hasYouTube(conns) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "

    Connect YouTube

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "

    Connect YouTube

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "

    Delete account

    Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

    Delete account…

    This permanently deletes your account and all data. Are you sure?

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "

    Delete account

    Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.

    Delete account…

    This permanently deletes your account and all data. Are you sure?

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2714,46 +2724,46 @@ func summarizeModeControl(auto bool) templ.Component { templ_7745c5c3_Var113 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "

    Current mode: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "

    Current mode: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var114 string templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeLabel(auto)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 743, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 746, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2786,33 +2796,33 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { templ_7745c5c3_Var117 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2824,7 +2834,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2832,7 +2842,7 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2863,25 +2873,25 @@ func actionButton(verb string, isActive bool) templ.Component { templ_7745c5c3_Var120 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var121 := []any{"action", templ.KV("active", isActive)} + var templ_7745c5c3_Var121 = []any{"action", templ.KV("active", isActive)} templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var121...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 8509004eb7f29d77372be9f4604cf03d811a0cc0 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:05:52 +0200 Subject: [PATCH 13/27] test(store): an owner can delete a finished upload; an active or foreign one is refused (#37), red Co-Authored-By: Claude Sonnet 5.5 --- .../store/uploaded_transcripts_delete_test.go | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 internal/adapters/store/uploaded_transcripts_delete_test.go diff --git a/internal/adapters/store/uploaded_transcripts_delete_test.go b/internal/adapters/store/uploaded_transcripts_delete_test.go new file mode 100644 index 0000000..fbabf9b --- /dev/null +++ b/internal/adapters/store/uploaded_transcripts_delete_test.go @@ -0,0 +1,68 @@ +package store_test + +// Deleting one recording (#37). A finished or failed upload can be removed by its owner; +// one that is queued or transcribing is refused until it ends, because the worker would +// otherwise write its result to a row that is gone (D3 in the sprint plan). + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.d-ma.be/mathias/tapir/internal/adapters/store" + "git.d-ma.be/mathias/tapir/internal/domain" +) + +func TestDeleteUpload_RemovesAFinishedOrFailedUploadOfTheOwner(t *testing.T) { + for name, finish := range map[string]func(*store.Store, string) error{ + "done": func(s *store.Store, id string) error { + return s.CompleteUpload(context.Background(), userA, id, domain.Transcription{Text: "synthetic", Endpoint: "iguana/kb-whisper"}) + }, + "failed": func(s *store.Store, id string) error { + return s.FailUpload(context.Background(), userA, id, domain.UploadErrTranscriptionFailed) + }, + } { + t.Run(name, func(t *testing.T) { + s := newStore(t) + id := startedUpload(t, s) + require.NoError(t, s.StartTranscribing(context.Background(), userA, id)) + require.NoError(t, finish(s, id)) + + require.NoError(t, s.DeleteUpload(context.Background(), userA, id)) + + _, err := s.GetUpload(context.Background(), userA, id) + assert.ErrorIs(t, err, store.ErrUploadNotFound) + }) + } +} + +func TestDeleteUpload_RefusesAnUploadThatIsStillQueuedOrTranscribingAndKeepsTheRow(t *testing.T) { + s := newStore(t) + ctx := context.Background() + queued := startedUpload(t, s) + running, err := s.CreateUpload(ctx, userA, "running", false) + require.NoError(t, err) + require.NoError(t, s.StartTranscribing(ctx, userA, running)) + + for _, id := range []string{queued, running} { + assert.ErrorIs(t, s.DeleteUpload(ctx, userA, id), store.ErrUploadInProgress) + _, err := s.GetUpload(ctx, userA, id) + assert.NoError(t, err, "a refused delete must leave the row") + } +} + +func TestDeleteUpload_AnotherUsersUploadIsNotFoundAndSurvives(t *testing.T) { + s := newStore(t) + ctx := context.Background() + id := startedUpload(t, s) + require.NoError(t, s.StartTranscribing(ctx, userA, id)) + require.NoError(t, s.FailUpload(ctx, userA, id, domain.UploadErrTranscriptionFailed)) + + assert.ErrorIs(t, s.DeleteUpload(ctx, userB, id), store.ErrUploadNotFound, + "existence is not disclosed to a non-owner") + + _, err := s.GetUpload(ctx, userA, id) + assert.NoError(t, err, "B's delete must not have removed A's row") +} From 93860210ef56ae4ba39fd838186b814570238153 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:05:58 +0200 Subject: [PATCH 14/27] feat(store): DeleteUpload removes a finished upload of the owner (#37) Co-Authored-By: Claude Sonnet 5.5 --- .../adapters/store/uploaded_transcripts.go | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/adapters/store/uploaded_transcripts.go b/internal/adapters/store/uploaded_transcripts.go index c62fb5a..4d30e7a 100644 --- a/internal/adapters/store/uploaded_transcripts.go +++ b/internal/adapters/store/uploaded_transcripts.go @@ -17,6 +17,10 @@ import ( // nothing about whether someone else's upload exists (ADR-033). var ErrUploadNotFound = errors.New("store: uploaded transcript not found") +// ErrUploadInProgress is returned by DeleteUpload for an upload that is still queued or +// transcribing: the worker would write its result to a row that no longer exists. +var ErrUploadInProgress = errors.New("store: uploaded transcript is still being processed") + // Uploaded transcripts are PRIVATE per-user content, stored in their own // RLS-scoped table (migration 017) — never in the shared `transcripts` (ADR-021). // Every method goes through withUser, so row-level security confines it to the @@ -103,6 +107,33 @@ func (s *Store) FailUpload(ctx context.Context, userID, id string, kind domain.U string(kind)) } +// DeleteUpload removes one of the caller's finished (done or failed) uploads. An upload +// still queued or transcribing is refused with ErrUploadInProgress and left untouched; one +// that is not the caller's is ErrUploadNotFound, as for every other write (#37). +func (s *Store) DeleteUpload(ctx context.Context, userID, id string) error { + return s.withUser(ctx, userID, func(tx pgx.Tx) error { + ct, err := tx.Exec(ctx, + `DELETE FROM uploaded_transcripts + WHERE id = $1 AND user_id = $2 AND status IN ('done', 'failed')`, id, userID) + if err != nil { + return fmt.Errorf("store: delete upload: %w", err) + } + if ct.RowsAffected() > 0 { + return nil + } + var exists bool + if err := tx.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM uploaded_transcripts WHERE id = $1 AND user_id = $2)`, + id, userID).Scan(&exists); err != nil { + return fmt.Errorf("store: delete upload: %w", err) + } + if exists { + return ErrUploadInProgress + } + return ErrUploadNotFound + }) +} + // updateUpload runs one UPDATE keyed on ($1 = id, $2 = userID) plus extra args, // and maps "no row touched" to ErrUploadNotFound — which is what a write to another // user's upload looks like under RLS (the row is invisible, not forbidden). From 89611164e32231892e08fd288b55557c2a307b31 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:06:22 +0200 Subject: [PATCH 15/27] test(web): per-recording delete is owner-scoped, refused while active, offered only when finished (#37), red Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads_delete_handler_test.go | 85 +++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 internal/web/uploads_delete_handler_test.go diff --git a/internal/web/uploads_delete_handler_test.go b/internal/web/uploads_delete_handler_test.go new file mode 100644 index 0000000..2d62e62 --- /dev/null +++ b/internal/web/uploads_delete_handler_test.go @@ -0,0 +1,85 @@ +package web_test + +// Deleting one recording (#37). Owner-scoped like every other upload route: another user's +// id is a 404 and the row survives; an upload still being processed is refused. + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func deleteReq(id string) *http.Request { + return httptest.NewRequest(http.MethodPost, "/uploads/"+id+"/delete", nil) +} + +func uploadExists(t *testing.T, id string) bool { + t.Helper() + var n int + require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT count(*) FROM uploaded_transcripts WHERE id = $1`, id).Scan(&n)) + return n == 1 +} + +func TestUploadDelete_AnOwnerRemovesAFinishedOrFailedRecordingAndLandsOnTheList(t *testing.T) { + for _, status := range []string{"done", "failed"} { + t.Run(status, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "Roadmap sync", status, "transcription_failed") + + rec := do(t, app, deleteReq(id)) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/uploads", rec.Header().Get("Location")) + assert.False(t, uploadExists(t, id)) + }) + } +} + +func TestUploadDelete_AnotherUsersRecordingIs404AndSurvives(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + theirs := seedUpload(t, otherUserID, "Their private meeting", "done", meetingText) + + rec := do(t, app, deleteReq(theirs)) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.True(t, uploadExists(t, theirs), "a non-owner's delete must not remove the row") +} + +func TestUploadDelete_ARecordingStillBeingTranscribedIsRefusedWith409AndKept(t *testing.T) { + for _, status := range []string{"queued", "transcribing"} { + t.Run(status, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", status, "") + + rec := do(t, app, deleteReq(id)) + + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Contains(t, body(t, rec), "still being transcribed") + assert.True(t, uploadExists(t, id)) + }) + } +} + +func TestUploadDelete_ANonIDIsA404NotAServerError(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + + assert.Equal(t, http.StatusNotFound, do(t, app, deleteReq("not-a-uuid")).Code) +} + +func TestUploadPage_OffersDeleteOnlyForAFinishedOrFailedRecording(t *testing.T) { + for status, want := range map[string]bool{"done": true, "failed": true, "queued": false, "transcribing": false} { + t.Run(status, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", status, "transcription_failed") + + b := body(t, do(t, app, get("/uploads/"+id))) + + assert.Equal(t, want, strings.Contains(b, `action="/uploads/`+id+`/delete"`)) + }) + } +} From f309a390274059a84de8846e98d95cff78d2994f Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:06:46 +0200 Subject: [PATCH 16/27] feat(web): POST /uploads/{id}/delete, owner-scoped and refused while the recording is active (#37) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/handlers.go | 4 ++++ internal/web/uploads.go | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 141120a..4175757 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -59,6 +59,9 @@ type Store interface { // non-owner's id is ErrUploadNotFound. GetUpload(ctx context.Context, userID, id string) (*domain.UploadedTranscript, error) ListUploads(ctx context.Context, userID string) ([]domain.UploadedTranscript, error) + // DeleteUpload removes a finished upload; ErrUploadInProgress while it is still being + // processed, ErrUploadNotFound for a non-owner. + DeleteUpload(ctx context.Context, userID, id string) error // SetTranscriptStatus clears or updates a video's transcript backoff state. // Used by handleRetryNow to clear rate_limited_at before immediate processing. @@ -200,6 +203,7 @@ func (a *App) Router() http.Handler { app.HandleFunc("POST /uploads", a.handleUploadPost) app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail) app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus) + app.HandleFunc("POST /uploads/{id}/delete", a.handleUploadDelete) // Chat over the uploaded transcript reuses the ADR-027 Chatter; mounted only // when one is wired. if a.Chat != nil { diff --git a/internal/web/uploads.go b/internal/web/uploads.go index d35ee3f..f4638f6 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -210,6 +210,32 @@ func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) { a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now())) } +// handleUploadDelete removes one of the caller's finished recordings and returns to the +// list. Another user's id is a 404 like every other upload route; a recording still being +// transcribed is refused (409) rather than deleted under the worker (#37). +func (a *App) handleUploadDelete(w http.ResponseWriter, r *http.Request) { + userID, ok := a.currentUserID(w, r) + if !ok { + return + } + id := r.PathValue("id") + if !uuidShape.MatchString(id) { + http.NotFound(w, r) + return + } + switch err := a.Store.DeleteUpload(r.Context(), userID, id); { + case err == nil: + http.Redirect(w, r, "/uploads", http.StatusSeeOther) + case errors.Is(err, store.ErrUploadNotFound): + http.NotFound(w, r) + case errors.Is(err, store.ErrUploadInProgress): + a.renderUploadsPage(w, r, userID, http.StatusConflict, + "This recording is still being transcribed. You can delete it once it has finished.") + default: + a.serverError(w, r, "delete upload", err) + } +} + func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) { userID, ok := a.currentUserID(w, r) if !ok { From c909b4e775bc427f33ff7325c4ae105d6a5e2f6f Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:06:46 +0200 Subject: [PATCH 17/27] feat(web): confirm-then-delete control on a finished or failed recording (#37) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.templ | 16 + internal/web/uploads_templ.go | 678 ++++++++++++++++++---------------- 2 files changed, 383 insertions(+), 311 deletions(-) diff --git a/internal/web/uploads.templ b/internal/web/uploads.templ index 97bcbd2..bc20c1f 100644 --- a/internal/web/uploads.templ +++ b/internal/web/uploads.templ @@ -86,6 +86,7 @@ templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) Transcript
    { u.Content }
    + @deleteRecording(u.ID) case domain.UploadFailed: default:
    + Delete this recording… +
    +

    This permanently deletes the recording and its transcript. Are you sure?

    +
    + +
    +
    + +} + // UploadChatPage is the no-JS full page for chatting with an uploaded transcript: // the same chat panel as a video's (ADR-027), scoped to this recording. templ UploadChatPage(u domain.UploadedTranscript, v chatView) { diff --git a/internal/web/uploads_templ.go b/internal/web/uploads_templ.go index 44cff63..b2d3bf3 100644 --- a/internal/web/uploads_templ.go +++ b/internal/web/uploads_templ.go @@ -386,12 +386,20 @@ 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, 30, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = deleteRecording(u.ID).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case domain.UploadFailed: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -399,25 +407,25 @@ 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, 32, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(uploadFailureMessage(u.ErrorKind)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 93, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 94, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(u.Progress) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
    Where it stopped") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
    Where it stopped") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -425,43 +433,47 @@ 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, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
    ") + templ_7745c5c3_Err = deleteRecording(u.ID).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" hx-swap=\"outerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -476,7 +488,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, 40, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -485,18 +497,18 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

    Transcribing… a long recording takes a few minutes.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

    Transcribing… a long recording takes a few minutes.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

    Queued — waiting for its turn to be transcribed.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

    Queued — waiting for its turn to be transcribed.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -505,6 +517,50 @@ func uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) }) } +// deleteRecording is the confirm-then-delete control, shown only once a recording has +// finished (done or failed): deleting one that is still being processed is refused. +func deleteRecording(id string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
    Delete this recording…

    This permanently deletes the recording and its transcript. Are you sure?

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + // UploadChatPage is the no-JS full page for chatting with an uploaded transcript: // the same chat panel as a video's (ADR-027), scoped to this recording. func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component { @@ -523,12 +579,12 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var21 := templ.GetChildren(ctx) - if templ_7745c5c3_Var21 == nil { - templ_7745c5c3_Var21 = templ.NopComponent + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var22 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var24 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -540,20 +596,20 @@ func UploadChatPage(u domain.UploadedTranscript, v chatView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(u.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 130, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 146, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

    Ask about this recording

    Answers come only from this recording's transcript, which is private to you.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

    Ask about this recording

    Answers come only from this recording's transcript, which is private to you.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -561,26 +617,26 @@ 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, 46, "

    Back to the transcript

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\">Back to the transcript

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = Layout("Tapir — Ask about "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = Layout("Tapir — Ask about "+u.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var24), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -608,13 +664,13 @@ func videoNotice(hadVideo bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if hadVideo { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

    This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

    This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -643,289 +699,289 @@ func reconciliationNote(r *domain.Reconciliation) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var26 := templ.GetChildren(ctx) - if templ_7745c5c3_Var26 == nil { - templ_7745c5c3_Var26 = templ.NopComponent + templ_7745c5c3_Var28 := templ.GetChildren(ctx) + if templ_7745c5c3_Var28 == nil { + templ_7745c5c3_Var28 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if r != nil { switch r.Status { case domain.ReconcileDone: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "

    Reconciled from two transcripts (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 163, Col: 47} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " and ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 163, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) - 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, 52, "

    Reconciled from two transcripts (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var29 string - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes)) + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 164, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " differences, ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " and ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved)) + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(r.Alt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 165, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " settled automatically, ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "): ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated)) + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Disputes)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 166, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 180, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " decided by the model (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " differences, ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler) + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.AutoResolved)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 166, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 181, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) 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, 56, " settled automatically, ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure)) + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Adjudicated)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 167, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 182, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " uncertain.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " decided by the model (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reconciler) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 182, Col: 74} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "), ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(r.Unsure)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 183, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " uncertain.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(shownSpots(r)) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
    Spots to check against the audio
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
      Spots to check against the audio
        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, c := range shownSpots(r) { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
      • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 175, Col: 33} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ": ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if c.Before != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "…") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(c.Before) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 177, Col: 44} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base) + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(minuteMark(c.Seconds)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 191, Col: 33} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, ": ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 179, Col: 30} + if c.Before != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "…") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.Before) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 193, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(c.Base) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 195, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " — ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(c.Alt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 195, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " — ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if c.Unsure { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "uncertain kept ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "uncertain kept ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 181, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "changed used ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "changed used ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(c.Used) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 183, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 199, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
      • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if moreSpots(r) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

    and ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

    and ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r))) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(moreSpots(r))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 189, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 205, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " more not listed

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " more not listed

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case domain.ReconcileSingle: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

    Only one engine produced this transcript (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 195, Col: 88} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "), so there was no second opinion to reconcile it with.

    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - case domain.ReconcileSkipped: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "

    Two transcripts were made but could not be reconciled (") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 101} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "); the text shown is ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "

    Only one engine produced this transcript (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 197, Col: 132} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 211, Col: 88} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "'s, unreconciled.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "), so there was no second opinion to reconcile it with.

    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + case domain.ReconcileSkipped: + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "

    Two transcripts were made but could not be reconciled (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Reason) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 213, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "); the text shown is ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.Base) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 213, Col: 132} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "'s, unreconciled.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -956,196 +1012,196 @@ func progressSteps(steps []domain.ProgressStep, now time.Time, live bool) templ. }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var44 := templ.GetChildren(ctx) - if templ_7745c5c3_Var44 == nil { - templ_7745c5c3_Var44 = templ.NopComponent + templ_7745c5c3_Var46 := templ.GetChildren(ctx) + if templ_7745c5c3_Var46 == nil { + templ_7745c5c3_Var46 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "
        ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range steps { - var templ_7745c5c3_Var45 = []any{"step step-" + string(s.State)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var45...) + var templ_7745c5c3_Var47 = []any{"step step-" + string(s.State)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
      1. ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if s.State == domain.StepRunning && live { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if s.State == domain.StepRunning { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "! ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 221, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
      2. ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if t := stepTime(s, now); t != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if s.State == domain.StepRunning { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " aria-current=\"step\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, ">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if s.State == domain.StepRunning && live { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if s.State == domain.StepRunning { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "! ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(t) + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(stepMark(s)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 225, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 237, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 239, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if t := stepTime(s, now); t != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(t) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 241, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if s.Note != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note) + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(s.Note) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 228, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 244, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " ") 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, 92, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\"> ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Done)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 248, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " of ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(s.Total)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 248, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
      3. ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1171,45 +1227,45 @@ func healthNote(h jobHealth, silent time.Duration) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var55 := templ.GetChildren(ctx) - if templ_7745c5c3_Var55 == nil { - templ_7745c5c3_Var55 = templ.NopComponent + templ_7745c5c3_Var57 := templ.GetChildren(ctx) + if templ_7745c5c3_Var57 == nil { + templ_7745c5c3_Var57 = templ.NopComponent } ctx = templ.ClearChildren(ctx) switch h { case healthLost: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "

    The service stopped reporting ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "

    The service stopped reporting ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var56 string - templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 245, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 261, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ago. It may have restarted or crashed. This page keeps checking; if nothing changes, upload the recording again.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case healthQuiet: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

    Nothing new for ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

    Nothing new for ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var57 string - templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(minutesAgo(silent)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 250, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/uploads.templ`, Line: 266, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, ": the model may be slow. The service is still running, and the job gives up on its own if it takes too long.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From a6691ac46bf01aeb710ae597b47fd504b1ecd385 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:07:11 +0200 Subject: [PATCH 18/27] docs: per-recording delete and the recordings link are built; feature scenarios and coverage map (#37) Co-Authored-By: Claude Sonnet 5.5 --- DECISIONS.md | 8 ++++++-- docs/use-cases/audio_upload.feature | 13 +++++++++++++ test/acceptance/scenario_coverage_test.go | 4 ++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 86a60be..01f0dc4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1551,8 +1551,12 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k disabled by default, a failed iguana job fails honestly and the user retries. *Blocks:* the production default only; the adapter ships either way. - ~~Verify Tapir's ingress `proxy-body-size` against the 200 MB cap (`infra`).~~ **Resolved 2026-10-03:** real unauthenticated bodies of 2, 30, 120 and 210 MiB all pass NPM (TLS, piguard) and Traefik to tapir (a 302 to login); there is no body limit in that range. Uploads were enabled in production the same day, local engines only (infra#520). -- Per-transcript deletion is not built; account deletion cascades. A private-content store - without a delete button is a gap worth a ticket, not a silent omission. +- ~~Per-transcript deletion is not built; account deletion cascades.~~ **Built 2026-10-03 + (#37):** `POST /uploads/{id}/delete`, owner-scoped (a non-owner gets 404 by the same RLS + mechanism), confirm-then-delete on the recording's page. A recording that is **queued or + transcribing is refused (409) until it ends**, because the worker would otherwise write to a + row that is gone; a job whose service was lost stays undeletable until the next start's sweep + marks it failed. The home list now links to `/uploads` when uploads are mounted. **Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos` diff --git a/docs/use-cases/audio_upload.feature b/docs/use-cases/audio_upload.feature index cd3ed30..d591ed7 100644 --- a/docs/use-cases/audio_upload.feature +++ b/docs/use-cases/audio_upload.feature @@ -245,6 +245,19 @@ Feature: Upload an audio recording, get a private Swedish transcript, discuss it Then its page says in plain words that the audio left the homelab and names the provider And a locally transcribed recording carries no such note + Scenario: I can delete a finished recording + When I confirm deleting a done or failed recording + Then the recording and its transcript are gone and I am back on my list + And another user's recording is a 404 and is untouched + + Scenario: A recording still being transcribed cannot be deleted + When I try to delete a recording that is queued or transcribing + Then I am told it is still being transcribed and nothing is removed + + Scenario: The home list links to my recordings when uploads are on + Given audio upload is enabled + Then the home list links to the recordings page + Scenario: A full queue is a retry, not an error When too many recordings are already waiting Then the upload is answered as busy, its row is failed honestly and its audio is deleted diff --git a/test/acceptance/scenario_coverage_test.go b/test/acceptance/scenario_coverage_test.go index 0dfcbba..bc16562 100644 --- a/test/acceptance/scenario_coverage_test.go +++ b/test/acceptance/scenario_coverage_test.go @@ -124,6 +124,10 @@ var scenarioCoverage = map[string]string{ "A transcript made by the cloud fallback says the audio left the homelab": "TestUploadStatus_ACloudServedTranscriptSaysTheAudioLeftTheHomelab", + "I can delete a finished recording": "TestUploadDelete_AnOwnerRemovesAFinishedOrFailedRecordingAndLandsOnTheList", + "A recording still being transcribed cannot be deleted": "TestUploadDelete_ARecordingStillBeingTranscribedIsRefusedWith409AndKept", + "The home list links to my recordings when uploads are on": "TestHomeList_LinksToRecordingsOnlyWhenUploadsAreMounted", + // audio_upload.feature, ADR-034 "Two engines transcribe the audio and their disagreements are reconciled": "TestEnsemble_BothEnginesRunInOrderAndTheirDisagreementIsReconciled", "Trivial disagreements never reach the model": "TestReconcile_TrivialDisputesAreResolvedWithoutAskingTheModel", From 25628a999499845527bcd9ca6a3045686b595fc0 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:22:24 +0200 Subject: [PATCH 19/27] test(web): the per-recording delete route is covered by the cross-origin gate (#37, #48) Co-Authored-By: Claude Sonnet 5.5 --- internal/web/crossorigin_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/web/crossorigin_test.go b/internal/web/crossorigin_test.go index 0d5e915..b75d832 100644 --- a/internal/web/crossorigin_test.go +++ b/internal/web/crossorigin_test.go @@ -113,6 +113,7 @@ func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testi "/v/abc/summarize", "/v/abc/retry-now", "/v/abc/chat", + "/uploads/00000000-0000-0000-0000-000000000000/delete", } for _, path := range routes { t.Run(path, func(t *testing.T) { From 9aa01468cef3948c35721b04cacc8d7b009111b6 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:30:44 +0200 Subject: [PATCH 20/27] fix(web): judge each alias of a reconciled endpoint, so an all-local transcript is not called cloud-served (#52) The two-engine path writes ' + , reconciled by '. IsLocalModel is a single-alias match, so the composite was 'not local' and the note claimed the audio left the homelab, naming a local host as the provider. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.go | 24 +++++++++++++++++--- internal/web/uploads_cloud_note_test.go | 29 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/internal/web/uploads.go b/internal/web/uploads.go index f4638f6..8ef6a43 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -273,12 +273,30 @@ func titleFromFilename(name string) string { // cloudProvider names the cloud service that produced a transcript, or false when it was // served locally (or by an unknown endpoint). "Local" is config.IsLocalModel: the same // definition the start-up validation uses, not a second string match here (#52). +// +// The endpoint is one alias, except for the two-engine path (ADR-034), which writes +// " + , reconciled by ". Every alias in it is judged on its own, so a +// composite of local models is local and one naming a cloud alias names that provider. func cloudProvider(endpoint string) (string, bool) { - if endpoint == "" || config.IsLocalModel(endpoint) { + if strings.TrimSpace(endpoint) == "" { return "", false } - provider, _, _ := strings.Cut(endpoint, "/") - return provider, true + aliases := []string{} + for _, tok := range strings.Fields(endpoint) { + if tok = strings.Trim(tok, ","); strings.Contains(tok, "/") { + aliases = append(aliases, tok) + } + } + if len(aliases) == 0 { + aliases = append(aliases, strings.TrimSpace(endpoint)) + } + for _, a := range aliases { + if !config.IsLocalModel(a) { + provider, _, _ := strings.Cut(a, "/") + return provider, true + } + } + return "", false } // hasNote reports whether any phase carries a note. A note is the only warning for a diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go index f6ebf76..7e68187 100644 --- a/internal/web/uploads_cloud_note_test.go +++ b/internal/web/uploads_cloud_note_test.go @@ -53,3 +53,32 @@ func TestUploadStatus_AnEndpointThatOnlyLooksLocalIsStillNamedAsCloud(t *testing assert.Contains(t, b, "left the homelab") } + +// The two-engine path (ADR-034) writes one composite endpoint string. Every name in it is +// a local model, so the audio never left the homelab and the note must not say it did. +func TestUploadStatus_AReconciledAllLocalEndpointCarriesNoCloudNote(t *testing.T) { + for _, endpoint := range []string{ + "iguana/kb-whisper + koala/whisper, reconciled by iguana/gemma4-26b", + "iguana/kb-whisper + koala/whisper, reconciled by iguana/gemma4-26b\n", + } { + t.Run(endpoint, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, endpoint) + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.NotContains(t, b, "left the homelab") + }) + } +} + +func TestUploadStatus_AReconciledEndpointNamingACloudEngineStillNamesTheCloudProvider(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, "iguana/kb-whisper + berget/kb-whisper, reconciled by iguana/gemma4-26b") + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.Contains(t, b, "cloud service (berget)") +} From 01c1606caa7387cd5df15f4ffcd2d872e1a18198 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:30:58 +0200 Subject: [PATCH 21/27] test(progress): the default save and heartbeat deadline is pinned, not only the override (#51) Every earlier test passed WithSaveTimeout; the worker passes nothing, so the 5 s default was the only production value and a flip to 0, 1ms or 1h stayed green. Found by the PR #58 review; each flip is now red. Co-Authored-By: Claude Sonnet 5.5 --- internal/progress/tracker_stall_test.go | 56 +++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/internal/progress/tracker_stall_test.go b/internal/progress/tracker_stall_test.go index f31f425..6aec38b 100644 --- a/internal/progress/tracker_stall_test.go +++ b/internal/progress/tracker_stall_test.go @@ -96,3 +96,59 @@ func TestTracker_TheStillRunningLineIsNotHeldUpBehindAHungSave(t *testing.T) { require.Eventually(t, func() bool { return strings.Contains(buf.String(), "still running") }, 3*time.Second, 5*time.Millisecond) } + +// deadlineSink records how long each write was given, and answers at once. +type deadlineSink struct { + mu sync.Mutex + saves []time.Duration + beats []time.Duration + noDLOK bool +} + +func (d *deadlineSink) record(ctx context.Context, into *[]time.Duration) { + d.mu.Lock() + defer d.mu.Unlock() + dl, ok := ctx.Deadline() + if !ok { + d.noDLOK = true + return + } + *into = append(*into, time.Until(dl)) +} + +func (d *deadlineSink) SaveProgress(ctx context.Context, _ []domain.ProgressStep, _ time.Time) error { + d.record(ctx, &d.saves) + return nil +} + +func (d *deadlineSink) Beat(ctx context.Context, _ time.Time) error { + d.record(ctx, &d.beats) + return nil +} + +// The production value is the one the worker gets: it builds a Tracker without options. +// A zero or tiny default would drop every write, a huge one would bound nothing. +func TestTracker_TheDefaultSaveAndBeatDeadlineIsAFewSecondsAway(t *testing.T) { + sink := &deadlineSink{} + tr := progress.NewTracker(context.Background(), sink, slog.New(slog.NewTextHandler(&lockedBuf{}, nil)), + []progress.Planned{{Key: "a", Label: "A"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go tr.Heartbeat(ctx, 5*time.Millisecond, time.Hour) + + tr.Begin("a", 0) + require.Eventually(t, func() bool { + sink.mu.Lock() + defer sink.mu.Unlock() + return len(sink.beats) > 0 + }, 3*time.Second, 5*time.Millisecond) + + sink.mu.Lock() + defer sink.mu.Unlock() + require.False(t, sink.noDLOK, "a write was given no deadline at all") + require.NotEmpty(t, sink.saves) + for _, d := range append(append([]time.Duration{}, sink.saves...), sink.beats...) { + require.Greater(t, d, time.Second, "the deadline is already (nearly) spent when the write starts") + require.LessOrEqual(t, d, 5*time.Second, "the default bound is 5 s") + } +} From 2e54d80a7817b3ee5cd7edd22cc1eb50c2f2a95c Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:31:14 +0200 Subject: [PATCH 22/27] test(uploads): jobSink forwards the tracker's timestamp to the store (#51) The worker fake ignored the heartbeat time, so the store-boundary half of the app-clock fix had no assertion. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- internal/uploads/jobsink_test.go | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 internal/uploads/jobsink_test.go diff --git a/internal/uploads/jobsink_test.go b/internal/uploads/jobsink_test.go new file mode 100644 index 0000000..79da468 --- /dev/null +++ b/internal/uploads/jobsink_test.go @@ -0,0 +1,48 @@ +package uploads + +// Observes what jobSink hands the store: the heartbeat's timestamp is the one the tracker +// stamped (its application clock), not a second reading taken here (#51). + +import ( + "context" + "testing" + "time" + + "git.d-ma.be/mathias/tapir/internal/domain" +) + +type atStore struct{ beatAt, saveAt time.Time } + +func (*atStore) StartTranscribing(context.Context, string, string) error { return nil } +func (*atStore) CompleteUpload(context.Context, string, string, domain.Transcription) error { + return nil +} +func (*atStore) FailUpload(context.Context, string, string, domain.UploadErrorKind) error { return nil } +func (s *atStore) SaveProgress(_ context.Context, _, _ string, _ []domain.ProgressStep, at time.Time) error { + s.saveAt = at + return nil +} +func (s *atStore) Heartbeat(_ context.Context, _, _ string, at time.Time) error { + s.beatAt = at + return nil +} + +func TestJobSink_ForwardsTheTrackersTimestampToTheStoreUnchanged(t *testing.T) { + st := &atStore{} + sink := jobSink{st: st, userID: "u", id: "i"} + stamp := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + + if err := sink.Beat(context.Background(), stamp); err != nil { + t.Fatal(err) + } + if err := sink.SaveProgress(context.Background(), nil, stamp); err != nil { + t.Fatal(err) + } + + if !st.beatAt.Equal(stamp) { + t.Errorf("heartbeat reached the store stamped %v, want the tracker's %v", st.beatAt, stamp) + } + if !st.saveAt.Equal(stamp) { + t.Errorf("progress save reached the store stamped %v, want the tracker's %v", st.saveAt, stamp) + } +} From 4a0226d8bd61847dd465ad4f5eed5e102329e148 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:31:30 +0200 Subject: [PATCH 23/27] test(llm): the response cap is pinned at its boundary and as the production default (#53) Only the 8-byte override was exercised; >= vs >, a huge default and a non-positive option all stayed green. Also says why the error-body read ignores its error. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- internal/adapters/llm/bodycap_test.go | 75 +++++++++++++++++++++++++++ internal/adapters/llm/client.go | 1 + 2 files changed, 76 insertions(+) diff --git a/internal/adapters/llm/bodycap_test.go b/internal/adapters/llm/bodycap_test.go index 293381f..d56e3d7 100644 --- a/internal/adapters/llm/bodycap_test.go +++ b/internal/adapters/llm/bodycap_test.go @@ -79,3 +79,78 @@ func TestClient_AFailingBodyIsReadOnlyUpToTheErrorBound(t *testing.T) { t.Fatalf("the client kept reading an endless error body: took %s", time.Since(start)) } } + +// sizedGateway answers 200 with a valid completion padded with trailing whitespace to +// exactly size bytes, so the boundary of the cap can be probed to the byte. +func sizedGateway(t *testing.T, size int) *httptest.Server { + t.Helper() + const base = `{"choices":[{"message":{"content":"ok"}}]}` + payload := base + strings.Repeat(" ", size-len(base)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestClient_ABodyOfExactlyTheCapIsReadAndOneByteMoreIsRefused(t *testing.T) { + const capBytes = 4096 + for _, tt := range []struct { + name string + size int + wantErr bool + }{ + {"one under", capBytes - 1, false}, + {"exactly the cap", capBytes, false}, + {"one over", capBytes + 1, true}, + } { + t.Run(tt.name, func(t *testing.T) { + c := New(sizedGateway(t, tt.size).URL, "", "m", 5*time.Second, WithMaxResponseBytes(capBytes)) + + got, err := c.Complete(context.Background(), "s", "u") + + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "exceeds") { + t.Fatalf("want a response-too-large error, got %q, %v", got, err) + } + return + } + if err != nil || got != "ok" { + t.Fatalf("want ok, got %q, %v", got, err) + } + }) + } +} + +// New is what production calls: no option. The default is therefore the live bound. +func TestClient_TheDefaultCapIsEightMiBWhenNoOptionIsGiven(t *testing.T) { + for _, tt := range []struct { + name string + size int + wantErr bool + }{ + {"exactly 8 MiB", 8 << 20, false}, + {"8 MiB plus one", 8<<20 + 1, true}, + } { + t.Run(tt.name, func(t *testing.T) { + c := New(sizedGateway(t, tt.size).URL, "", "m", 10*time.Second) + + _, err := c.Complete(context.Background(), "s", "u") + + if (err != nil) != tt.wantErr { + t.Fatalf("size %d: err = %v, wantErr %v", tt.size, err, tt.wantErr) + } + }) + } +} + +func TestClient_ANonPositiveCapOptionKeepsTheDefault(t *testing.T) { + for _, n := range []int64{0, -1} { + c := New(sizedGateway(t, 1024).URL, "", "m", 5*time.Second, WithMaxResponseBytes(n)) + + if got, err := c.Complete(context.Background(), "s", "u"); err != nil || got != "ok" { + t.Fatalf("WithMaxResponseBytes(%d): want ok from the default cap, got %q, %v", n, got, err) + } + } +} diff --git a/internal/adapters/llm/client.go b/internal/adapters/llm/client.go index 1c5a92f..b9d5cca 100644 --- a/internal/adapters/llm/client.go +++ b/internal/adapters/llm/client.go @@ -202,6 +202,7 @@ func (c *Client) Complete(ctx context.Context, system, user string) (string, err if resp.StatusCode != http.StatusOK { // Only the bounded head is ever used (HTTPError.Error truncates to it), so read no more. + // A read error here only shortens a diagnostic; the status code below is what callers act on. head, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1)) return "", &HTTPError{Code: resp.StatusCode, Body: string(head)} } From fc1e8e7ba8e9e9c0fd11954522ff4a1e9ca93282 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:32:14 +0200 Subject: [PATCH 24/27] test(web): a foreign post to the recording delete route leaves the recording, on an app that mounts uploads (#37, #48) The earlier entry posted to an app without uploads, where the route does not exist, and stayed green with the route removed. This one seeds a finished row and goes red without the gate. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/crossorigin_test.go | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/web/crossorigin_test.go b/internal/web/crossorigin_test.go index b75d832..df39745 100644 --- a/internal/web/crossorigin_test.go +++ b/internal/web/crossorigin_test.go @@ -113,7 +113,6 @@ func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testi "/v/abc/summarize", "/v/abc/retry-now", "/v/abc/chat", - "/uploads/00000000-0000-0000-0000-000000000000/delete", } for _, path := range routes { t.Run(path, func(t *testing.T) { @@ -126,6 +125,20 @@ func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testi } } +func TestCrossOrigin_ARefusedRecordingDeletePostLeavesTheRecordingInPlace(t *testing.T) { + for name, h := range foreign { + t.Run(name, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + + rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/uploads/"+id+"/delete", nil), h)) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.True(t, uploadExists(t, id), "a refused post must not delete the recording") + }) + } +} + // A login redirect comes back to /auth/callback as a cross-site GET navigation, and // health probes carry no headers: safe methods must never be refused on origin grounds. func TestCrossOrigin_ACrossSiteGETIsNotRefused(t *testing.T) { From c24573bd9f2f2d7bac4621e1d49e4634c69df600 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 21:32:14 +0200 Subject: [PATCH 25/27] docs: state what the save timeout and a racing delete actually guarantee (#51, #37) The tracker comment said 'never a stalled job'; each transition can wait up to the timeout. The delete refusal is judged at the moment of the attempt. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- DECISIONS.md | 4 +++- internal/progress/tracker.go | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 01f0dc4..69786f3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1556,7 +1556,9 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k mechanism), confirm-then-delete on the recording's page. A recording that is **queued or transcribing is refused (409) until it ends**, because the worker would otherwise write to a row that is gone; a job whose service was lost stays undeletable until the next start's sweep - marks it failed. The home list now links to `/uploads` when uploads are mounted. + marks it failed. The refusal reflects the row's state when the delete was attempted: a + recording that finishes in the same instant still gets a 409 once, and a retry deletes it. + The home list now links to `/uploads` when uploads are mounted. **Reversibility.** Additive: one table (down-migration drops it), one adapter, one upload surface, one image change. Removing the upload route removes the feature; `transcripts`, `videos` diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go index c365d58..deaca50 100644 --- a/internal/progress/tracker.go +++ b/internal/progress/tracker.go @@ -57,8 +57,11 @@ func WithClock(now func() time.Time) Option { return func(t *Tracker) { t.now = func WithMinSave(d time.Duration) Option { return func(t *Tracker) { t.minSave = d } } // WithSaveTimeout bounds one save or heartbeat write. Progress is cosmetic, so a database -// that stops answering costs a dropped write, never a stalled job: saves run under the -// tracker's lock, which Begin and Advance (called by the engines) need (#51). +// that stops answering costs a dropped write rather than an unbounded stall. It is still a +// delay: saves run under the tracker's lock, which Begin and Advance (called by the engines) +// need, so each transition can wait up to this long, and a hung database slows a job by +// that much per call, out of the job's own time budget (#51). A dropped final save is not +// retried; the row keeps its last saved step. func WithSaveTimeout(d time.Duration) Option { return func(t *Tracker) { t.saveTimeout = d } } // Tracker implements Reporter for one job. From 67bd0350a3b06d404d6bf504a48e8518cc586215 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 4 Oct 2026 00:22:41 +0200 Subject: [PATCH 26/27] fix(web): judge every word of a reconciled endpoint that is not a connector, so an unprefixed alias is not called local (#52) The previous version kept only words containing '/' and dropped the rest, so a bare TAPIR_TRANSCRIBE_MODEL alias in the composite produced a false 'all local' result. Found by the PR #58 re-review; red tests for a bare alias, an alias with a space and a split 'berget / x' first. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.go | 27 +++++++++++-------------- internal/web/uploads_cloud_note_test.go | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/internal/web/uploads.go b/internal/web/uploads.go index 8ef6a43..836b493 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -275,24 +275,21 @@ func titleFromFilename(name string) string { // definition the start-up validation uses, not a second string match here (#52). // // The endpoint is one alias, except for the two-engine path (ADR-034), which writes -// " + , reconciled by ". Every alias in it is judged on its own, so a -// composite of local models is local and one naming a cloud alias names that provider. +// " + , reconciled by ". Every word of it that is not that format's own +// connector is judged as an alias, so a composite of local models is local, and anything +// else (a cloud alias, one with no provider prefix, one with a space in it) is not. func cloudProvider(endpoint string) (string, bool) { - if strings.TrimSpace(endpoint) == "" { - return "", false - } - aliases := []string{} for _, tok := range strings.Fields(endpoint) { - if tok = strings.Trim(tok, ","); strings.Contains(tok, "/") { - aliases = append(aliases, tok) + tok = strings.Trim(tok, ",") + switch tok { + case "", "+", "reconciled", "by": + continue } - } - if len(aliases) == 0 { - aliases = append(aliases, strings.TrimSpace(endpoint)) - } - for _, a := range aliases { - if !config.IsLocalModel(a) { - provider, _, _ := strings.Cut(a, "/") + if !config.IsLocalModel(tok) { + provider, _, _ := strings.Cut(tok, "/") + if provider == "" { + provider = tok + } return provider, true } } diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go index 7e68187..f703e76 100644 --- a/internal/web/uploads_cloud_note_test.go +++ b/internal/web/uploads_cloud_note_test.go @@ -82,3 +82,24 @@ func TestUploadStatus_AReconciledEndpointNamingACloudEngineStillNamesTheCloudPro assert.Contains(t, b, "cloud service (berget)") } + +// An alias without a provider prefix is not a local model, wherever it sits in the +// composite: it must never be dropped from the judgement (PR #58 re-review). +func TestUploadStatus_AReconciledEndpointWithAnUnprefixedAliasIsNotCalledLocal(t *testing.T) { + for _, endpoint := range []string{ + "iguana/kb-whisper + whisper-1, reconciled by iguana/gemma4-26b", + "iguana/kb-whisper + whisper 1, reconciled by iguana/gemma4-26b", + "iguana/kb-whisper + berget / x, reconciled by iguana/gemma4-26b", + } { + t.Run(endpoint, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, endpoint) + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.Contains(t, b, "left the homelab") + assert.NotContains(t, b, "cloud service ()", "the note names a provider") + }) + } +} From 41ab1f5d501ca45b05634c0f6ede2ac3b3ff8d92 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 4 Oct 2026 00:28:09 +0200 Subject: [PATCH 27/27] fix(web): take the reconciled endpoint apart exactly, so a connector word used as a model name is still judged (#52) Skipping 'by', 'reconciled' and '+' as words everywhere let an alias with one of those names pass as local. The two-engine shape is now split on its own separators and each alias judged whole; a reconciler that is not local is pinned too. Hardening suggested by the third PR #58 review. Co-Authored-By: Claude Sonnet 5.5 --- internal/web/uploads.go | 30 ++++++++++++--------- internal/web/uploads_cloud_note_test.go | 35 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/internal/web/uploads.go b/internal/web/uploads.go index 836b493..921a42f 100644 --- a/internal/web/uploads.go +++ b/internal/web/uploads.go @@ -275,20 +275,24 @@ func titleFromFilename(name string) string { // definition the start-up validation uses, not a second string match here (#52). // // The endpoint is one alias, except for the two-engine path (ADR-034), which writes -// " + , reconciled by ". Every word of it that is not that format's own -// connector is judged as an alias, so a composite of local models is local, and anything -// else (a cloud alias, one with no provider prefix, one with a space in it) is not. +// " + , reconciled by " (ensemble.go). That shape is taken apart exactly +// and each alias is judged whole, so a composite of local models is local, and anything +// else (a cloud alias, one with no provider prefix, one with a space or a connector word in +// it) is not. A string that is not that shape is one alias. func cloudProvider(endpoint string) (string, bool) { - for _, tok := range strings.Fields(endpoint) { - tok = strings.Trim(tok, ",") - switch tok { - case "", "+", "reconciled", "by": - continue - } - if !config.IsLocalModel(tok) { - provider, _, _ := strings.Cut(tok, "/") - if provider == "" { - provider = tok + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "", false + } + aliases := []string{endpoint} + if engines, reconciler, ok := strings.Cut(endpoint, ", reconciled by "); ok { + aliases = append(strings.Split(engines, " + "), reconciler) + } + for _, a := range aliases { + if a = strings.TrimSpace(a); !config.IsLocalModel(a) { + provider, _, _ := strings.Cut(a, "/") + if provider = strings.TrimSpace(provider); provider == "" { + provider = a } return provider, true } diff --git a/internal/web/uploads_cloud_note_test.go b/internal/web/uploads_cloud_note_test.go index f703e76..82fcd42 100644 --- a/internal/web/uploads_cloud_note_test.go +++ b/internal/web/uploads_cloud_note_test.go @@ -103,3 +103,38 @@ func TestUploadStatus_AReconciledEndpointWithAnUnprefixedAliasIsNotCalledLocal(t }) } } + +// "by", "reconciled" and "+" are connectors only inside the composite format. As a whole +// endpoint, or as an alias within the composite, they are model names and are judged. +func TestUploadStatus_AConnectorWordUsedAsAModelNameIsStillJudged(t *testing.T) { + for _, endpoint := range []string{ + "by", + "reconciled", + "+", + "iguana/kb-whisper + by, reconciled by iguana/gemma4-26b", + "iguana/kb-whisper + reconciled, reconciled by iguana/gemma4-26b", + "iguana/kb-whisper + +, reconciled by iguana/gemma4-26b", + "/x", // no provider at all: the note must still name something + } { + t.Run(endpoint, func(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, endpoint) + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.Contains(t, b, "left the homelab") + assert.NotContains(t, b, "cloud service ()") + }) + } +} + +func TestUploadStatus_AReconcilerThatIsNotLocalNamesTheCloudProvider(t *testing.T) { + app, _ := newUploadApp(t, audioOK, 200<<20) + id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.") + setEndpoint(t, id, "iguana/kb-whisper + koala/whisper, reconciled by berget/mistral-large") + + b := body(t, do(t, app, get("/uploads/"+id+"/status"))) + + assert.Contains(t, b, "cloud service (berget)") +}