Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #39.
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
)
|
||||
@@ -43,6 +45,28 @@ type chatView struct {
|
||||
History []chat.Turn
|
||||
Truncated bool
|
||||
Error string
|
||||
// PostURL and Subject let the same chat panel serve an UPLOADED recording
|
||||
// (ADR-033): where the form posts, and what the transcript is called. Empty
|
||||
// means a YouTube video (/v/{VideoID}/chat, "video"), so existing callers are
|
||||
// unchanged.
|
||||
PostURL templ.SafeURL
|
||||
Subject string
|
||||
}
|
||||
|
||||
// postURL is where the chat form posts.
|
||||
func (v chatView) postURL() templ.SafeURL {
|
||||
if v.PostURL != "" {
|
||||
return v.PostURL
|
||||
}
|
||||
return chatURL(v.VideoID)
|
||||
}
|
||||
|
||||
// subject is what the transcript is called in the panel's wording.
|
||||
func (v chatView) subject() string {
|
||||
if v.Subject != "" {
|
||||
return v.Subject
|
||||
}
|
||||
return "video"
|
||||
}
|
||||
|
||||
// handleChat renders the chat page for a summarized video (GET). Entry is scoped
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
)
|
||||
|
||||
// Store is the read/write surface the web handlers depend on — a narrow port over
|
||||
@@ -53,6 +54,12 @@ type Store interface {
|
||||
// the most recent discovery pass, shown on the account page as a warning.
|
||||
ListChannelErrors(ctx context.Context, userID string) ([]store.ChannelError, error)
|
||||
|
||||
// Uploaded-audio transcripts (ADR-033): the caller's own PRIVATE uploads,
|
||||
// stored apart from the shared public captions. Both are user-scoped; a
|
||||
// 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)
|
||||
|
||||
// SetTranscriptStatus clears or updates a video's transcript backoff state.
|
||||
// Used by handleRetryNow to clear rate_limited_at before immediate processing.
|
||||
SetTranscriptStatus(ctx context.Context, userID, videoID, status string) error
|
||||
@@ -100,6 +107,26 @@ type App struct {
|
||||
// summary view shows no "dig deeper" affordance. It holds no caption-fetch
|
||||
// dependency, so an enabled chat cannot reach YouTube or the rate gate.
|
||||
Chat Chatter
|
||||
// Uploads, when non-nil, accepts audio recordings for private transcription
|
||||
// (ADR-033). Nil = the /uploads routes are not mounted. UploadLimits are the
|
||||
// same limits the Uploader enforces, shown on the form so they are stated up
|
||||
// front rather than discovered by failing.
|
||||
Uploads Uploader
|
||||
UploadLimits uploads.Limits
|
||||
// UploadChatMaxChars is the transcript budget for chat over an uploaded
|
||||
// recording. The caption default (18,000 chars, ~22 minutes of speech) would
|
||||
// keep only the first third of an hour-long meeting.
|
||||
UploadChatMaxChars int
|
||||
// UploadChatModel is the model chat over an uploaded recording opens with. Empty
|
||||
// means the Chatter's own default. It exists because that default is the
|
||||
// summarizer's (koala/phi4-mini, 8,192-token context) and cannot hold an hour of
|
||||
// speech.
|
||||
UploadChatModel string
|
||||
// UploadChatModels is the set of models chat over an uploaded recording offers and
|
||||
// accepts. The whole private transcript goes to the one chosen, so it must hold
|
||||
// LOCAL models only, and the caller decides that. Nil or empty fails closed: no
|
||||
// model is offered and none is called (ADR-035 review).
|
||||
UploadChatModels []string
|
||||
// Processing tracks in-flight immediate summarizations so the status endpoint
|
||||
// shows the animation until the summary lands. The zero value is ready to use.
|
||||
Processing ProcessingSet
|
||||
@@ -166,6 +193,20 @@ func (a *App) Router() http.Handler {
|
||||
app.HandleFunc("GET /v/{videoId}/chat", a.handleChat)
|
||||
app.HandleFunc("POST /v/{videoId}/chat", a.handleChatMessage)
|
||||
}
|
||||
// Audio upload → private transcript (ADR-033). Mounted only when an Uploader is
|
||||
// wired; every handler is user-scoped, so another user's upload is a 404.
|
||||
if a.Uploads != nil {
|
||||
app.HandleFunc("GET /uploads", a.handleUploadsPage)
|
||||
app.HandleFunc("POST /uploads", a.handleUploadPost)
|
||||
app.HandleFunc("GET /uploads/{id}", a.handleUploadDetail)
|
||||
app.HandleFunc("GET /uploads/{id}/status", a.handleUploadStatus)
|
||||
// Chat over the uploaded transcript reuses the ADR-027 Chatter; mounted only
|
||||
// when one is wired.
|
||||
if a.Chat != nil {
|
||||
app.HandleFunc("GET /uploads/{id}/chat", a.handleUploadChat)
|
||||
app.HandleFunc("POST /uploads/{id}/chat", a.handleUploadChatMessage)
|
||||
}
|
||||
}
|
||||
app.HandleFunc("GET /register", a.handleRegisterForm)
|
||||
app.HandleFunc("POST /register", a.handleRegister)
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
|
||||
"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/domain"
|
||||
"git.d-ma.be/mathias/tapir/internal/errkind"
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
)
|
||||
|
||||
// Uploader accepts an uploaded recording for transcription (ADR-033).
|
||||
// *uploads.Intake satisfies it. It is the whole of what the handlers know about
|
||||
// validation, spooling and queueing.
|
||||
type Uploader interface {
|
||||
Accept(ctx context.Context, userID, title string, src io.Reader) (id string, err error)
|
||||
}
|
||||
|
||||
const (
|
||||
// uploadOverhead is headroom above the file-size cap for the multipart framing
|
||||
// and the title field; the cap itself is enforced on the file part by Intake.
|
||||
uploadOverhead = 1 << 20
|
||||
maxTitleRunes = 200
|
||||
// uploadPollEvery is how often an unfinished upload re-asks for its status.
|
||||
uploadPollEvery = "2s"
|
||||
// serviceSilentAfter is how long a job may go without any signal (heartbeat or
|
||||
// progress) before the page calls the service lost. The heartbeat is every 10 s, so
|
||||
// 45 s is four missed beats (ADR-035).
|
||||
serviceSilentAfter = 45 * time.Second
|
||||
// countedQuietAfter is how long a COUNTED phase (the deciding one) may go without
|
||||
// advancing before the page says it is quiet. Each decision has a two-minute timeout,
|
||||
// so five minutes is more than two in a row. A phase with no count is never quiet.
|
||||
countedQuietAfter = 5 * time.Minute
|
||||
)
|
||||
|
||||
// uploadsView is what the /uploads page renders.
|
||||
type uploadsView struct {
|
||||
Limits uploads.Limits
|
||||
Rows []domain.UploadedTranscript
|
||||
Error string // a synchronous rejection message, empty when none
|
||||
}
|
||||
|
||||
// handleUploadsPage renders the upload form and the caller's own uploads.
|
||||
func (a *App) handleUploadsPage(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, http.StatusOK, "")
|
||||
}
|
||||
|
||||
func (a *App) renderUploadsPage(w http.ResponseWriter, r *http.Request, userID string, status int, msg string) {
|
||||
rows, err := a.Store.ListUploads(r.Context(), userID)
|
||||
if err != nil {
|
||||
a.serverError(w, r, "list uploads", err)
|
||||
return
|
||||
}
|
||||
a.renderStatus(w, r, status, UploadsPage(uploadsView{Limits: a.UploadLimits, Rows: rows, Error: msg}))
|
||||
}
|
||||
|
||||
// handleUploadPost streams the multipart body: the optional title field, then the
|
||||
// file part, which goes straight to Intake without being buffered in memory. A
|
||||
// rejection is synchronous — a status code and a message on the upload page, no
|
||||
// row, no timeout (ADR-033).
|
||||
func (a *App) handleUploadPost(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.UploadLimits.MaxBytes+uploadOverhead)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
|
||||
return
|
||||
}
|
||||
title := ""
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
var tooBig *http.MaxBytesError
|
||||
if a.abandoned(w, r) {
|
||||
return
|
||||
}
|
||||
if !errors.As(err, &tooBig) {
|
||||
// A malformed multipart body (a header with a control byte, a truncated
|
||||
// part) is the sender's to fix: a 400, not a server error, and the error
|
||||
// text is never logged: it quotes the headers, which carry the file name.
|
||||
a.logger().Info("upload rejected", "reason", "malformed")
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
|
||||
return
|
||||
}
|
||||
a.uploadFailure(w, r, userID, err)
|
||||
return
|
||||
}
|
||||
switch part.FormName() {
|
||||
case "title":
|
||||
b, err := io.ReadAll(io.LimitReader(part, maxTitleRunes*utf8.UTFMax))
|
||||
if err != nil {
|
||||
if a.abandoned(w, r) {
|
||||
return
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "That upload could not be read. Please try again.")
|
||||
return
|
||||
}
|
||||
title = cleanTitle(string(b))
|
||||
case "file":
|
||||
if title == "" {
|
||||
title = titleFromFilename(part.FileName())
|
||||
}
|
||||
id, err := a.Uploads.Accept(r.Context(), userID, title, part)
|
||||
if err != nil {
|
||||
a.uploadFailure(w, r, userID, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/uploads/"+id, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "Choose an audio file to upload.")
|
||||
}
|
||||
|
||||
// abandoned reports, logs at INFO and answers 499, because the client has already gone: its request
|
||||
// context is cancelled, so there is nobody to answer and rendering a page would run its
|
||||
// database read on a dead context and log a false ERROR. A hang-up mid-upload is routine.
|
||||
func (a *App) abandoned(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Context().Err() == nil {
|
||||
return false
|
||||
}
|
||||
a.logger().Info("upload abandoned", "reason", "client_gone")
|
||||
// 499 (the "client closed request" convention): a client that half-closed after sending
|
||||
// can still read, and an unwritten status would reach it as a false, empty 200.
|
||||
w.WriteHeader(499)
|
||||
return true
|
||||
}
|
||||
|
||||
// uploadFailure maps an Accept (or multipart) error to a response: a validation
|
||||
// rejection is the user's to fix (422, or 413 for size) and carries its own
|
||||
// message; anything else is ours.
|
||||
func (a *App) uploadFailure(w http.ResponseWriter, r *http.Request, userID string, err error) {
|
||||
if a.abandoned(w, r) {
|
||||
return
|
||||
}
|
||||
var rej *uploads.RejectError
|
||||
var tooBig *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &rej):
|
||||
// Logged by reason only: the filename and title are private content.
|
||||
a.logger().Info("upload rejected", "reason", string(rej.Reason))
|
||||
status := http.StatusUnprocessableEntity
|
||||
switch rej.Reason {
|
||||
case uploads.ReasonTooLarge:
|
||||
status = http.StatusRequestEntityTooLarge
|
||||
case uploads.ReasonBusy:
|
||||
status = http.StatusTooManyRequests
|
||||
}
|
||||
a.renderUploadsPage(w, r, userID, status, rej.Message)
|
||||
case errors.As(err, &tooBig):
|
||||
a.logger().Info("upload rejected", "reason", string(uploads.ReasonTooLarge))
|
||||
a.renderUploadsPage(w, r, userID, http.StatusRequestEntityTooLarge,
|
||||
"This file is larger than the upload limit.")
|
||||
case errors.Is(err, uploads.ErrClientRead):
|
||||
// An error reading the CLIENT's body: theirs to retry, not a server fault. Only that
|
||||
// marker counts: io.ErrUnexpectedEOF alone is also what a database drop looks like.
|
||||
a.logger().Info("upload rejected", "reason", "truncated")
|
||||
a.renderUploadsPage(w, r, userID, http.StatusBadRequest, "The upload was cut off before it finished. Please try again.")
|
||||
default:
|
||||
// Not err: it can carry the file's name, a spool path or a database value.
|
||||
a.logger().Error("upload failed", "kind", errkind.Of(err))
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadDetail renders one upload's page (GET /uploads/{id}). The row is
|
||||
// read through the user-scoped store, so another user's id is a 404 — existence is
|
||||
// not disclosed, and the title never appears in the response.
|
||||
func (a *App) handleUploadDetail(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := a.loadOwnedUpload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.render(w, r, UploadPage(*u, a.Chat != nil, a.now()))
|
||||
}
|
||||
|
||||
// handleUploadStatus renders the status fragment HTMX polls. Unfinished states
|
||||
// keep polling; terminal states render without hx-trigger so the page stops
|
||||
// asking (ADR-025: never a stuck spinner).
|
||||
func (a *App) handleUploadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := a.loadOwnedUpload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.render(w, r, uploadStatus(*u, a.Chat != nil, a.now()))
|
||||
}
|
||||
|
||||
func (a *App) loadOwnedUpload(w http.ResponseWriter, r *http.Request) (*domain.UploadedTranscript, bool) {
|
||||
userID, ok := a.currentUserID(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if !uuidShape.MatchString(id) {
|
||||
// Not an id at all: the same 404 as an unknown or foreign one, never a database
|
||||
// error that surfaces as a 500 (and no oracle about which ids exist).
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
u, err := a.Store.GetUpload(r.Context(), userID, id)
|
||||
if errors.Is(err, store.ErrUploadNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return nil, false
|
||||
}
|
||||
if err != nil {
|
||||
a.serverError(w, r, "get upload", err)
|
||||
return nil, false
|
||||
}
|
||||
return u, true
|
||||
}
|
||||
|
||||
// titleFromFilename is the default title: the file's name without its extension.
|
||||
func titleFromFilename(name string) string {
|
||||
base := filepath.Base(name)
|
||||
t := cleanTitle(strings.TrimSuffix(base, filepath.Ext(base)))
|
||||
if t == "" || t == "." {
|
||||
return "Untitled recording"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// hasNote reports whether any phase carries a note. A note is the only warning for a
|
||||
// recording that is shorter than its file claimed, so a record that has one is shown open.
|
||||
func hasNote(steps []domain.ProgressStep) bool {
|
||||
for _, s := range steps {
|
||||
if s.Note != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// uuidShape is the form of an upload id. Anything else cannot be one, so it is a 404.
|
||||
var uuidShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// isBidiControl reports the invisible characters that change text direction. Emoji joiners
|
||||
// (ZWJ, ZWNJ) and variation selectors are not among them and are kept.
|
||||
func isBidiControl(r rune) bool {
|
||||
return r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 || r == 0x200E || r == 0x200F || r == 0x061C
|
||||
}
|
||||
|
||||
// cleanTitle makes a title safe to store and show: invalid UTF-8 dropped, control
|
||||
// characters (NUL, newlines, escapes) turned into spaces, runs of space collapsed, trimmed
|
||||
// and clipped. A title is cosmetic; bad bytes in it must never fail an upload, and Postgres
|
||||
// refuses NUL in text.
|
||||
func cleanTitle(s string) string {
|
||||
s = strings.ToValidUTF8(s, "")
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case isBidiControl(r):
|
||||
return -1 // dropped: U+202E would reverse the rest of the title on its owner's page
|
||||
case unicode.IsControl(r):
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
return clipRunes(strings.Join(strings.Fields(s), " "), maxTitleRunes)
|
||||
}
|
||||
|
||||
func clipRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n])
|
||||
}
|
||||
|
||||
// uploadFailureMessage turns a typed failure kind into words for the user. It is
|
||||
// keyed on OUR kinds, never on a provider's error string (ADR-033).
|
||||
func uploadFailureMessage(k domain.UploadErrorKind) string {
|
||||
switch k {
|
||||
case domain.UploadErrInterrupted:
|
||||
return "The server restarted before this recording was transcribed. Please upload it again."
|
||||
case domain.UploadErrBusy:
|
||||
return "Too many recordings were waiting to be transcribed when this one was uploaded. Please upload it again in a few minutes."
|
||||
case domain.UploadErrTooLong:
|
||||
return "This recording is longer than the limit once its audio is read (the file's header said otherwise). Please upload a shorter recording."
|
||||
case domain.UploadErrTranscriptionFailed:
|
||||
return "Transcription failed on every available service. Please upload it again later."
|
||||
default:
|
||||
return "This upload failed. Please upload it again."
|
||||
}
|
||||
}
|
||||
|
||||
// handleUploadChat renders the chat page for a FINISHED upload (GET). The row is
|
||||
// read through the user-scoped store, so another user's id is a 404 before any
|
||||
// model is involved. An unfinished upload has no transcript to ask about yet, so
|
||||
// it goes back to its status page.
|
||||
func (a *App) handleUploadChat(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := a.loadOwnedUpload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if u.Status != domain.UploadDone {
|
||||
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
model, _ := a.resolveUploadModel("")
|
||||
a.render(w, r, UploadChatPage(*u, a.uploadChatView(*u, model, nil, false, "")))
|
||||
}
|
||||
|
||||
// handleUploadChatMessage answers one question against the owner's uploaded
|
||||
// transcript (POST), reusing the ADR-027 Chatter. Nothing is persisted: the prior
|
||||
// turns ride in the form. The transcript goes to the model with the UPLOAD budget,
|
||||
// not the caption default. A model failure is inline, not a 500.
|
||||
func (a *App) handleUploadChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := a.loadOwnedUpload(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if u.Status != domain.UploadDone {
|
||||
http.Redirect(w, r, "/uploads/"+u.ID, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
model, ok := a.resolveUploadModel(r.FormValue("model"))
|
||||
if !ok {
|
||||
a.logger().Error("upload chat: no local model is configured; nothing was sent", "upload", u.ID)
|
||||
view := a.uploadChatView(*u, "", nil, false, "No local model is available for chat about a recording.")
|
||||
a.render(w, r, UploadChatPage(*u, view))
|
||||
return
|
||||
}
|
||||
history := parseHistory(r.Form["hq"], r.Form["ha"])
|
||||
question := strings.TrimSpace(r.FormValue("question"))
|
||||
|
||||
view := a.uploadChatView(*u, model, history, false, "")
|
||||
if question != "" {
|
||||
reply, err := a.Chat.Answer(r.Context(), chat.Request{
|
||||
Model: model,
|
||||
Transcript: u.Content,
|
||||
History: history,
|
||||
Question: question,
|
||||
MaxChars: a.UploadChatMaxChars,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger().Error("upload chat answer", "upload", u.ID, "model", model, "kind", errkind.Of(err))
|
||||
view.Error = "That model couldn't answer just now. Try again, or switch models."
|
||||
} else {
|
||||
view.History = appendTurn(history, chat.Turn{Question: question, Answer: reply.Answer})
|
||||
view.Truncated = reply.Truncated
|
||||
}
|
||||
}
|
||||
if isHTMX(r) {
|
||||
a.render(w, r, chatPanel(view))
|
||||
return
|
||||
}
|
||||
a.render(w, r, UploadChatPage(*u, view))
|
||||
}
|
||||
|
||||
func (a *App) uploadChatView(u domain.UploadedTranscript, model string, history []chat.Turn, truncated bool, errMsg string) chatView {
|
||||
return chatView{
|
||||
VideoID: u.ID,
|
||||
Title: u.Title,
|
||||
Available: true,
|
||||
Models: a.UploadChatModels,
|
||||
Selected: model,
|
||||
History: history,
|
||||
Truncated: truncated,
|
||||
Error: errMsg,
|
||||
PostURL: templ.SafeURL("/uploads/" + u.ID + "/chat"),
|
||||
Subject: "recording",
|
||||
}
|
||||
}
|
||||
|
||||
// maxSpotsShown is how many spots to check the page lists. The stored list holds every
|
||||
// judged spot and the counts are exact; a page of two hundred "check this" items is not a
|
||||
// page anyone reads, so the rest are counted, not listed.
|
||||
const maxSpotsShown = 50
|
||||
|
||||
// minuteMark renders where in the recording a spot is, as the nearest minute.
|
||||
func minuteMark(seconds float64) string {
|
||||
return fmt.Sprintf("~%d min", int(math.Round(seconds/60)))
|
||||
}
|
||||
|
||||
// shownSpots is the part of the stored list the page renders: the spots a reader should
|
||||
// check, changed or uncertain, in order, capped. A spot the model confidently kept is
|
||||
// stored (ADR-035) but is not one to check, so it is not listed.
|
||||
func shownSpots(r *domain.Reconciliation) []domain.ContestedSpot {
|
||||
var out []domain.ContestedSpot
|
||||
for _, c := range r.Contested {
|
||||
if c.Used == "alt" || c.Unsure {
|
||||
out = append(out, c)
|
||||
if len(out) == maxSpotsShown {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// moreSpots is how many spots to check are not listed, from the exact counts.
|
||||
func moreSpots(r *domain.Reconciliation) int {
|
||||
return max(0, r.Changed+r.Unsure-len(shownSpots(r)))
|
||||
}
|
||||
|
||||
// jobHealth is what the page can honestly say about a job it is still polling.
|
||||
type jobHealth string
|
||||
|
||||
const (
|
||||
// healthRunning: the service is reporting and work is moving, or there is nothing yet
|
||||
// to say otherwise.
|
||||
healthRunning jobHealth = "running"
|
||||
// healthQuiet: the service is alive but a counted phase has not advanced for a while.
|
||||
healthQuiet jobHealth = "quiet"
|
||||
// healthLost: nothing has been heard from the service for too long. It may have
|
||||
// restarted or crashed; the start-up sweep fails the row once it is back.
|
||||
healthLost jobHealth = "lost"
|
||||
)
|
||||
|
||||
// uploadHealth tells running from quiet from lost for an upload being transcribed (the
|
||||
// page calls it for no other state: a queued or finished job has no heartbeat to judge). The
|
||||
// two timestamps mean different things (ADR-035): alive_at is the heartbeat, which says
|
||||
// the service is still reporting; progress_at moves only when work really moved. A
|
||||
// single blocking engine call has no sub-progress, so only a counted phase can be quiet.
|
||||
// silent is how long since the signal that decided it.
|
||||
func uploadHealth(u domain.UploadedTranscript, now time.Time) (health jobHealth, silent time.Duration) {
|
||||
last := u.AliveAt
|
||||
if u.ProgressAt.After(last) {
|
||||
last = u.ProgressAt
|
||||
}
|
||||
if last.IsZero() {
|
||||
return healthRunning, 0
|
||||
}
|
||||
if d := now.Sub(last); d > serviceSilentAfter {
|
||||
return healthLost, d
|
||||
}
|
||||
for _, s := range u.Progress {
|
||||
if s.State == domain.StepRunning && s.Total > 0 && !u.ProgressAt.IsZero() {
|
||||
if d := now.Sub(u.ProgressAt); d > countedQuietAfter {
|
||||
return healthQuiet, d
|
||||
}
|
||||
}
|
||||
}
|
||||
return healthRunning, 0
|
||||
}
|
||||
|
||||
// stepDuration renders how long a phase took or has run: "<1s", "42s", "3m 12s", "1h 02m".
|
||||
func stepDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Second:
|
||||
return "<1s"
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
default:
|
||||
return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
}
|
||||
|
||||
// stepTime is the time shown beside a phase: how long it has run, or how long it took.
|
||||
func stepTime(s domain.ProgressStep, now time.Time) string {
|
||||
switch s.State {
|
||||
case domain.StepRunning:
|
||||
return stepDuration(now.Sub(s.StartedAt))
|
||||
case domain.StepDone, domain.StepFailed:
|
||||
return stepDuration(s.FinishedAt.Sub(s.StartedAt))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stepMark is the glyph at the head of a phase line.
|
||||
func stepMark(s domain.ProgressStep) string {
|
||||
switch s.State {
|
||||
case domain.StepDone:
|
||||
return "✓"
|
||||
case domain.StepFailed:
|
||||
return "✗"
|
||||
case domain.StepSkipped:
|
||||
return "–"
|
||||
}
|
||||
return "·"
|
||||
}
|
||||
|
||||
// minutesAgo is a whole number of minutes for the health notes: "6 min", or "under a
|
||||
// minute" when it is less.
|
||||
func minutesAgo(d time.Duration) string {
|
||||
if m := int(d.Minutes()); m >= 1 {
|
||||
return fmt.Sprintf("%d min", m)
|
||||
}
|
||||
return "under a minute"
|
||||
}
|
||||
|
||||
// resolveUploadModel picks the model for chat over an uploaded recording from the
|
||||
// offered LOCAL set only: the posted one when it is in the set, else the configured
|
||||
// default when it is, else the first. ok is false when no model is offered, in which
|
||||
// case nothing may be sent anywhere (the whole private transcript is the payload).
|
||||
func (a *App) resolveUploadModel(posted string) (model string, ok bool) {
|
||||
offered := func(m string) bool {
|
||||
for _, o := range a.UploadChatModels {
|
||||
if o == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case posted != "" && offered(posted):
|
||||
return posted, true
|
||||
case a.UploadChatModel != "" && offered(a.UploadChatModel):
|
||||
return a.UploadChatModel, true
|
||||
case len(a.UploadChatModels) > 0:
|
||||
return a.UploadChatModels[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
)
|
||||
|
||||
// UploadsPage is the upload form plus the caller's own uploads (ADR-033).
|
||||
templ UploadsPage(v uploadsView) {
|
||||
@Layout("Tapir — Upload a recording") {
|
||||
<article class="account">
|
||||
<h1>Upload a recording</h1>
|
||||
<p class="muted">
|
||||
Audio only. Up to { fmt.Sprintf("%d MB", v.Limits.MaxBytes>>20) } and
|
||||
{ uploads.FormatDuration(v.Limits.MaxDuration) }. The transcript is private to you;
|
||||
the audio is deleted once it has been transcribed.
|
||||
</p>
|
||||
if v.Error != "" {
|
||||
<div class="flash flash-error" role="alert">{ v.Error }</div>
|
||||
}
|
||||
<form method="post" action="/uploads" enctype="multipart/form-data">
|
||||
<p><label>Title (optional) <input type="text" name="title" maxlength="200"/></label></p>
|
||||
<p><input type="file" name="file" accept="audio/*,.m4a,.mp3,.wav,.opus,.ogg,.mp4" required/></p>
|
||||
<p><button type="submit">Upload and transcribe</button></p>
|
||||
</form>
|
||||
if len(v.Rows) > 0 {
|
||||
<section>
|
||||
<h2>Your recordings</h2>
|
||||
<ul>
|
||||
for _, u := range v.Rows {
|
||||
<li>
|
||||
<a href={ templ.SafeURL("/uploads/" + u.ID) }>{ u.Title }</a>
|
||||
<span class="chip">{ string(u.Status) }</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// UploadPage is one upload's page: its title and the polling status region.
|
||||
templ UploadPage(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
|
||||
@Layout("Tapir — " + u.Title) {
|
||||
<article class="account">
|
||||
<h1>{ u.Title }</h1>
|
||||
@uploadStatus(u, chatEnabled, now)
|
||||
<p><a href="/uploads">All recordings</a></p>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// uploadStatus is the region HTMX polls. Unfinished states carry hx-trigger so the
|
||||
// page re-asks; done and failed render without it, so nothing polls forever.
|
||||
templ uploadStatus(u domain.UploadedTranscript, chatEnabled bool, now time.Time) {
|
||||
switch u.Status {
|
||||
case domain.UploadDone:
|
||||
<div class="upload-status upload-done" role="status">
|
||||
@videoNotice(u.HadVideo)
|
||||
<p>
|
||||
Transcribed { uploads.FormatDuration(time.Duration(u.AudioSeconds * float64(time.Second))) }
|
||||
of audio by <span class="chip">{ u.Endpoint }</span>.
|
||||
</p>
|
||||
@reconciliationNote(u.Reconciliation)
|
||||
if len(u.Progress) > 0 {
|
||||
<details class="what-was-done" if hasNote(u.Progress) { open }>
|
||||
<summary>What was done</summary>
|
||||
@progressSteps(u.Progress, now, false)
|
||||
</details>
|
||||
}
|
||||
if chatEnabled {
|
||||
<p><a class="btn" href={ templ.SafeURL("/uploads/" + u.ID + "/chat") }>Ask about this recording</a></p>
|
||||
}
|
||||
<details open>
|
||||
<summary>Transcript</summary>
|
||||
<pre class="transcript">{ u.Content }</pre>
|
||||
</details>
|
||||
</div>
|
||||
case domain.UploadFailed:
|
||||
<div class="upload-status upload-failed flash flash-error" role="alert">
|
||||
@videoNotice(u.HadVideo)
|
||||
<p>{ uploadFailureMessage(u.ErrorKind) }</p>
|
||||
if len(u.Progress) > 0 {
|
||||
<details class="what-was-done" open>
|
||||
<summary>Where it stopped</summary>
|
||||
@progressSteps(u.Progress, now, false)
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
default:
|
||||
<div
|
||||
class="upload-status upload-working"
|
||||
role="status"
|
||||
hx-get={ "/uploads/" + u.ID + "/status" }
|
||||
hx-trigger={ "every " + uploadPollEvery }
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@videoNotice(u.HadVideo)
|
||||
if u.Status == domain.UploadTranscribing {
|
||||
{{ health, silent := uploadHealth(u, now) }}
|
||||
if len(u.Progress) > 0 {
|
||||
@progressSteps(u.Progress, now, health != healthLost)
|
||||
@healthNote(health, silent)
|
||||
} else {
|
||||
<p>Transcribing… a long recording takes a few minutes.</p>
|
||||
}
|
||||
} else {
|
||||
<p>Queued — waiting for its turn to be transcribed.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@Layout("Tapir — Ask about " + u.Title) {
|
||||
<article class="account">
|
||||
<h1>{ u.Title }</h1>
|
||||
<section id="chat-section" class="chat-dock chat-dock-open">
|
||||
<h2 class="chat-heading">Ask about this recording</h2>
|
||||
<p class="chat-scope muted">Answers come only from this recording's transcript, which is private to you.</p>
|
||||
@chatPanel(v)
|
||||
</section>
|
||||
<p><a href={ templ.SafeURL("/uploads/" + u.ID) }>Back to the transcript</a></p>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
// videoNotice tells the user a video track was present and what became of it. A
|
||||
// Teams meeting with the camera off is still a video file; it is accepted, only the
|
||||
// audio is transcribed, and the picture is thrown away — so say so, in every state,
|
||||
// instead of leaving the user to wonder (ADR-033).
|
||||
templ videoNotice(hadVideo bool) {
|
||||
if hadVideo {
|
||||
<p class="muted video-notice">
|
||||
This file contained a video track. Only its audio was used; the video was discarded and nothing from it was kept.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
// reconciliationNote says what was done when two engines transcribed the audio
|
||||
// (ADR-034), and never claims a second opinion that was not there: nil means a single
|
||||
// engine and renders nothing. Spots the model changed or was unsure about are listed
|
||||
// with a minute mark and both readings so the user can check them against the audio.
|
||||
templ reconciliationNote(r *domain.Reconciliation) {
|
||||
if r != nil {
|
||||
switch r.Status {
|
||||
case domain.ReconcileDone:
|
||||
<div class="reconciliation">
|
||||
<p>
|
||||
Reconciled from two transcripts ({ r.Base } and { r.Alt }):
|
||||
{ strconv.Itoa(r.Disputes) } differences,
|
||||
{ strconv.Itoa(r.AutoResolved) } settled automatically,
|
||||
{ strconv.Itoa(r.Adjudicated) } decided by the model ({ r.Reconciler }),
|
||||
{ strconv.Itoa(r.Unsure) } uncertain.
|
||||
</p>
|
||||
if len(shownSpots(r)) > 0 {
|
||||
<details>
|
||||
<summary>Spots to check against the audio</summary>
|
||||
<ul>
|
||||
for _, c := range shownSpots(r) {
|
||||
<li>
|
||||
{ minuteMark(c.Seconds) }:
|
||||
if c.Before != "" {
|
||||
<span class="muted">…{ c.Before }</span>
|
||||
}
|
||||
{ c.Base } / { c.Alt } —
|
||||
if c.Unsure {
|
||||
<span class="chip chip-warn">uncertain</span> kept { c.Used }
|
||||
} else {
|
||||
<span class="chip">changed</span> used { c.Used }
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
if moreSpots(r) > 0 {
|
||||
<p class="muted">and { strconv.Itoa(moreSpots(r)) } more not listed</p>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
case domain.ReconcileSingle:
|
||||
<p class="muted reconciliation">Only one engine produced this transcript ({ r.Reason }), so there was no second opinion to reconcile it with.</p>
|
||||
case domain.ReconcileSkipped:
|
||||
<p class="muted reconciliation">Two transcripts were made but could not be reconciled ({ r.Reason }); the text shown is { r.Base }'s, unreconciled.</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// progressSteps is the job's phases as a log that fills in as it runs: finished ones
|
||||
// collapse to a line with how long they took, the running one has a spinner and its
|
||||
// elapsed time, and what has not started is dimmed. live is false when the spinner would
|
||||
// be a lie (the service is lost, or the job is over), so the running phase shows a
|
||||
// warning mark instead. Only a counted phase gets a bar (ADR-035).
|
||||
templ progressSteps(steps []domain.ProgressStep, now time.Time, live bool) {
|
||||
<ol class="steps" aria-label="Progress">
|
||||
for _, s := range steps {
|
||||
<li
|
||||
class={ "step step-" + string(s.State) }
|
||||
if s.State == domain.StepRunning {
|
||||
aria-current="step"
|
||||
}
|
||||
>
|
||||
if s.State == domain.StepRunning && live {
|
||||
<span class="step-mark" aria-hidden="true"><span class="spinner"></span></span>
|
||||
} else if s.State == domain.StepRunning {
|
||||
<span class="step-mark step-mark-warn" aria-hidden="true">!</span>
|
||||
} else {
|
||||
<span class="step-mark" aria-hidden="true">{ stepMark(s) }</span>
|
||||
}
|
||||
<span class="step-label">{ s.Label }</span>
|
||||
if t := stepTime(s, now); t != "" {
|
||||
<span class="step-time">{ t }</span>
|
||||
}
|
||||
if s.Note != "" {
|
||||
<span class="step-note">{ s.Note }</span>
|
||||
}
|
||||
if s.State == domain.StepRunning && s.Total > 0 {
|
||||
<progress class="step-bar" value={ strconv.Itoa(s.Done) } max={ strconv.Itoa(s.Total) }></progress>
|
||||
<span class="step-count">{ strconv.Itoa(s.Done) } of { strconv.Itoa(s.Total) }</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
|
||||
// healthNote says so when the job is not simply running: the service stopped reporting,
|
||||
// or a counted phase has gone quiet. Running says nothing: the spinner does.
|
||||
templ healthNote(h jobHealth, silent time.Duration) {
|
||||
switch h {
|
||||
case healthLost:
|
||||
<p class="step-health step-health-lost" role="alert">
|
||||
The service stopped reporting { minutesAgo(silent) } ago. It may have restarted or crashed.
|
||||
This page keeps checking; if nothing changes, upload the recording again.
|
||||
</p>
|
||||
case healthQuiet:
|
||||
<p class="step-health step-health-quiet">
|
||||
Nothing new for { minutesAgo(silent) }: the model may be slow. The service is still running,
|
||||
and the job gives up on its own if it takes too long.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Input that cannot be right is the caller's to fix: a 4xx, never a 500 (adversarial
|
||||
// review F7). No cross-tenant oracle either way: unknown and foreign ids look the same.
|
||||
|
||||
func TestUploadRoutes_ANonUUIDIdIs404NotA500(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
for _, path := range []string{"/uploads/not-a-uuid", "/uploads/not-a-uuid/status", "/uploads/not-a-uuid/chat", "/uploads/%00/status", "/uploads/1/status"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
rec := do(t, app, get(path))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPost_ATitleWithControlCharactersOrInvalidUTF8IsCleanedNotA500(t *testing.T) {
|
||||
tests := []struct{ name, title, filename string }{
|
||||
{"a NUL in the title", "Sprint\x00review", "a.m4a"},
|
||||
{"invalid UTF-8 in the title", "Sprint \xff\xfe review", "a.m4a"},
|
||||
{"a newline in the title", "Sprint\nreview", "a.m4a"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, uploadReq(t, tt.title, tt.filename, []byte("synthetic audio bytes")))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code, "bad bytes in a cosmetic title must not fail the upload: %s", body(t, rec))
|
||||
require.Len(t, sub.jobs, 1)
|
||||
var title string
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
|
||||
assert.NotContains(t, title, "\x00")
|
||||
assert.NotContains(t, title, "\n")
|
||||
assert.True(t, len(title) > 0, "a title is still shown")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPost_AMalformedMultipartHeaderIsA400AndItsTextIsNotLogged(t *testing.T) {
|
||||
// A control byte in the part's file name makes the multipart body malformed. It is the
|
||||
// sender's to fix, and the parser's error quotes the header, which carries the name.
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
buf := &lockedBuf{}
|
||||
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||
|
||||
rec := do(t, app, uploadReq(t, "", "zebrakaka\x00weekly.m4a", []byte("x")))
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Empty(t, sub.jobs)
|
||||
assert.NotContains(t, buf.String(), "zebrakaka", "a file name is private content")
|
||||
}
|
||||
|
||||
func TestUploadPost_EveryBidirectionalControlCharacterIsStrippedFromATitle(t *testing.T) {
|
||||
// U+202E reverses the text that follows it, so a title could render reversed on its
|
||||
// owner's own page (second adversarial review). Every one of the twelve is pinned.
|
||||
controls := []rune{0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069, 0x200E, 0x200F, 0x061C}
|
||||
for _, c := range controls {
|
||||
t.Run(fmt.Sprintf("U+%04X", c), func(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, uploadReq(t, "Sprint"+string(c)+"review", "a.m4a", []byte("x")))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
var title string
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
|
||||
assert.Equal(t, "Sprintreview", title, "dropped, not replaced by a space: a space would leave a visible gap")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPost_SwedishLettersAndEmojiSurviveTheTitleCleaning(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
// Includes a ZWJ sequence (woman + ZWJ + laptop) and a ZWNJ: joiners are not direction controls.
|
||||
rec := do(t, app, uploadReq(t, "Möte åäö 🎙️ 👩\u200d💻 a\u200cb", "a.m4a", []byte("x")))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
var title string
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT title FROM uploaded_transcripts WHERE id = $1`, sub.jobs[0].UploadID).Scan(&title))
|
||||
assert.Equal(t, "Möte åäö 🎙️ 👩\u200d💻 a\u200cb", title, "joiners and variation selectors belong to the text")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
)
|
||||
|
||||
func TestUploadPost_ABusyRejectionIs429WithItsMessage(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
app.Uploads = failingUploader{err: &uploads.RejectError{Reason: uploads.ReasonBusy, Message: "Too many recordings are waiting. Try again in a few minutes."}}
|
||||
|
||||
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")))
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, rec.Code, "the user's to retry: not a 500, not a 422")
|
||||
assert.Contains(t, body(t, rec), "Too many recordings are waiting")
|
||||
}
|
||||
|
||||
func TestUploadStatus_ABusyFailureIsExplainedInWords(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "failed", "queue_full")
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "waiting")
|
||||
assert.NotContains(t, b, "This upload failed. Please upload it again.")
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||
"git.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// Chat over an uploaded transcript REUSES the ADR-027 chat path (the same
|
||||
// Chatter, the same panel) — only the lookup differs: the row comes from the
|
||||
// user-scoped private store, so another user's upload is a 404 and, crucially, no
|
||||
// model is ever called with it. Transcript text is synthetic; this repo is public.
|
||||
|
||||
const meetingText = "Anders ansvarar för virket. Maria undersöker leverantörer."
|
||||
|
||||
func newUploadChatApp(t *testing.T) (*web.App, *fakeChatter) {
|
||||
t.Helper()
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fc := &fakeChatter{
|
||||
models: []string{"koala/phi4-mini", "iguana/gemma4-26b"},
|
||||
reply: chat.Reply{Answer: "Anders owns the timber order."},
|
||||
}
|
||||
app.Chat = fc
|
||||
app.UploadChatMaxChars = 120000
|
||||
app.UploadChatModels = fc.models
|
||||
return app, fc
|
||||
}
|
||||
|
||||
func chatPost(path string, kv url.Values) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(kv.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUploadChatPage_OwnerOfAFinishedUploadSeesThePanelPostingToTheUploadRoute(t *testing.T) {
|
||||
app, _ := newUploadChatApp(t)
|
||||
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
||||
|
||||
rec := do(t, app, get("/uploads/"+id+"/chat"))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
b := body(t, rec)
|
||||
assert.Contains(t, b, `action="/uploads/`+id+`/chat"`, "the form posts to the UPLOAD route, not /v/{id}/chat")
|
||||
assert.Contains(t, b, "Roadmap sync")
|
||||
assert.Contains(t, b, "koala/phi4-mini")
|
||||
assert.Contains(t, b, "recording", "wording says recording, not video")
|
||||
}
|
||||
|
||||
func TestUploadChatPage_AnUnfinishedUploadRedirectsToItsStatusPage(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
|
||||
rec := do(t, app, get("/uploads/"+id+"/chat"))
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
|
||||
assert.Zero(t, fc.callCount)
|
||||
}
|
||||
|
||||
func TestUploadChat_AnotherUsersUploadIs404AndNoModelIsCalled(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
theirs := seedUpload(t, otherUserID, "Their private meeting", "done", meetingText)
|
||||
|
||||
getRec := do(t, app, get("/uploads/"+theirs+"/chat"))
|
||||
postRec := do(t, app, chatPost("/uploads/"+theirs+"/chat", url.Values{"question": {"what did they say?"}}))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, getRec.Code)
|
||||
assert.Equal(t, http.StatusNotFound, postRec.Code)
|
||||
assert.NotContains(t, body(t, getRec), "Their private meeting")
|
||||
assert.Zero(t, fc.callCount, "someone else's transcript must never reach a model")
|
||||
}
|
||||
|
||||
func TestUploadChatPost_SendsTheOwnersTranscriptWithTheUploadBudget(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
app.UploadChatMaxChars = 77777
|
||||
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{
|
||||
"question": {"Who owns the timber order?"},
|
||||
"model": {"iguana/gemma4-26b"},
|
||||
}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, 1, fc.callCount)
|
||||
assert.Equal(t, meetingText, fc.gotReq.Transcript)
|
||||
assert.Equal(t, "Who owns the timber order?", fc.gotReq.Question)
|
||||
assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model)
|
||||
assert.Equal(t, 77777, fc.gotReq.MaxChars,
|
||||
"an uploaded transcript takes its own budget: the 18,000-char caption default would cut an hour to its first third")
|
||||
assert.Contains(t, body(t, rec), "Anders owns the timber order.")
|
||||
}
|
||||
|
||||
func TestUploadChatPost_AnUnofferedModelFallsBackToTheDefault(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
|
||||
do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"evil/model"}}))
|
||||
|
||||
assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model)
|
||||
}
|
||||
|
||||
func TestUploadChatPost_UnfinishedUploadCallsNoModel(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
id := seedUpload(t, userID, "m", "queued", "")
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
|
||||
|
||||
assert.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
assert.Zero(t, fc.callCount)
|
||||
}
|
||||
|
||||
func TestUploadChatPost_PriorTurnsRideAlongAndATruncatedReplyIsSaidSo(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
fc.reply = chat.Reply{Answer: "second answer", Truncated: true}
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{
|
||||
"question": {"and who else?"},
|
||||
"hq": {"first question"},
|
||||
"ha": {"first answer"},
|
||||
}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, []chat.Turn{{Question: "first question", Answer: "first answer"}}, fc.gotReq.History)
|
||||
b := body(t, rec)
|
||||
assert.Contains(t, b, "first answer")
|
||||
assert.Contains(t, b, "second answer")
|
||||
assert.Contains(t, b, "end of the recording", "a long recording cut to a budget must say so, in its own words")
|
||||
}
|
||||
|
||||
func TestUploadChatPost_AModelFailureIsShownInlineNotAs500(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
fc.err = errors.New("gateway down")
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "hq": {"earlier"}, "ha": {"kept"}}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
b := body(t, rec)
|
||||
assert.Contains(t, b, "answer just now", "the failure is inline (the apostrophe is HTML-escaped, so match the tail)")
|
||||
assert.Contains(t, b, "kept", "the conversation survives a failed turn")
|
||||
}
|
||||
|
||||
func TestUploadChatRoutes_AreNotMountedWithoutAChatBackend(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads/"+id+"/chat")).Code)
|
||||
}
|
||||
|
||||
func TestUploadStatus_DoneOffersTheChatLinkOnlyWhenChatIsWired(t *testing.T) {
|
||||
appWith, _ := newUploadChatApp(t)
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
assert.Contains(t, body(t, do(t, appWith, get("/uploads/"+id+"/status"))), `href="/uploads/`+id+`/chat"`)
|
||||
|
||||
appWithout, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id2 := seedUpload(t, userID, "m2", "done", meetingText)
|
||||
assert.NotContains(t, body(t, do(t, appWithout, get("/uploads/"+id2+"/status"))), "/chat")
|
||||
}
|
||||
|
||||
func TestUploadChat_OpensWithTheUploadChatModelNotTheSummarizerDefault(t *testing.T) {
|
||||
// koala/phi4-mini is first in the offered list (it is the summarizer default) and
|
||||
// has an 8,192-token context: an hour of speech returns HTTP 400 there. Chat over
|
||||
// an upload must open with the model configured for it.
|
||||
app, fc := newUploadChatApp(t)
|
||||
app.UploadChatModel = "iguana/gemma4-26b"
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
|
||||
page := body(t, do(t, app, get("/uploads/"+id+"/chat")))
|
||||
do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
|
||||
|
||||
assert.Contains(t, page, `<option value="iguana/gemma4-26b" selected`, "the page opens on the upload chat model")
|
||||
assert.Equal(t, "iguana/gemma4-26b", fc.gotReq.Model, "a post that names no model uses it too")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/chat"
|
||||
"git.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// The whole private transcript goes to the model chosen in the form, so the upload chat
|
||||
// offers and accepts local models only, even when the shared chat service knows a cloud
|
||||
// one (ADR-035 review). The cloud model here is only a name; no network is involved.
|
||||
|
||||
func newUploadChatAppWithCloud(t *testing.T) (*web.App, *fakeChatter) {
|
||||
t.Helper()
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fc := &fakeChatter{
|
||||
models: []string{"koala/phi4-mini", "iguana/gemma4-26b", "berget/mistral-small"},
|
||||
reply: chat.Reply{Answer: "Anders owns the timber order."},
|
||||
}
|
||||
app.Chat = fc
|
||||
app.UploadChatMaxChars = 120000
|
||||
app.UploadChatModels = []string{"koala/phi4-mini", "iguana/gemma4-26b"}
|
||||
return app, fc
|
||||
}
|
||||
|
||||
func TestUploadChatPage_DoesNotOfferACloudModelEvenThoughTheServiceKnowsOne(t *testing.T) {
|
||||
app, _ := newUploadChatAppWithCloud(t)
|
||||
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/chat")))
|
||||
|
||||
assert.Contains(t, b, "iguana/gemma4-26b")
|
||||
assert.NotContains(t, b, "berget/mistral-small", "the switcher must not list a model the transcript must not be sent to")
|
||||
}
|
||||
|
||||
func TestUploadChatPost_AForgedCloudModelIsReplacedByALocalOneBeforeTheTranscriptIsSent(t *testing.T) {
|
||||
app, fc := newUploadChatAppWithCloud(t)
|
||||
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"Who owns timber?"}, "model": {"berget/mistral-small"}}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, 1, fc.callCount)
|
||||
assert.NotEqual(t, "berget/mistral-small", fc.gotReq.Model, "a cloud model reached the chat service with the private transcript")
|
||||
assert.Contains(t, []string{"koala/phi4-mini", "iguana/gemma4-26b"}, fc.gotReq.Model)
|
||||
}
|
||||
|
||||
func TestUploadChatPost_ALocalModelThatIsOfferedIsHonoured(t *testing.T) {
|
||||
app, fc := newUploadChatAppWithCloud(t)
|
||||
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"koala/phi4-mini"}}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "koala/phi4-mini", fc.gotReq.Model)
|
||||
}
|
||||
|
||||
func TestUploadChat_WithNoLocalModelConfiguredNothingIsSentAnywhere(t *testing.T) {
|
||||
app, fc := newUploadChatAppWithCloud(t)
|
||||
app.UploadChatModels = nil
|
||||
id := seedUpload(t, userID, "Roadmap sync", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}, "model": {"berget/mistral-small"}}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Zero(t, fc.callCount, "failing closed: no local model offered means no model is called")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A client that hangs up in the middle of an upload is routine, not a server fault. The
|
||||
// in-process recorder cannot see it (its context never cancels), so this talks to a real
|
||||
// listener (third adversarial review: over a socket every such case logged an ERROR and
|
||||
// half-close returned a 500, because the error page was rendered on a cancelled context).
|
||||
|
||||
// body positions at which the client can hang up: inside the title field, inside the file
|
||||
// part's own headers, and mid-file. Every one must be handled alike (fourth adversarial
|
||||
// review: the title branch had no guard and still logged an ERROR).
|
||||
const (
|
||||
inTitle = "in the title"
|
||||
inHeaders = "in the file part's headers"
|
||||
midFile = "mid-file"
|
||||
)
|
||||
|
||||
func partialUpload(t *testing.T, addr, where string, hangUp func(*net.TCPConn)) {
|
||||
t.Helper()
|
||||
c, err := net.Dial("tcp", addr)
|
||||
require.NoError(t, err)
|
||||
tc := c.(*net.TCPConn)
|
||||
const boundary = "xBOUNDARYx"
|
||||
var body string
|
||||
switch where {
|
||||
case inTitle:
|
||||
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nhalf a tit"
|
||||
case inHeaders:
|
||||
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filena"
|
||||
default:
|
||||
body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.m4a\"\r\nContent-Type: audio/mp4\r\n\r\n" + strings.Repeat("a", 4000)
|
||||
}
|
||||
_, err = fmt.Fprintf(tc, "POST /uploads HTTP/1.1\r\nHost: x\r\nContent-Type: multipart/form-data; boundary=%s\r\nContent-Length: 200000\r\n\r\n%s", boundary, body)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(50 * time.Millisecond) // let the server start reading
|
||||
hangUp(tc)
|
||||
}
|
||||
|
||||
func TestUploadPost_AClientThatHangsUpAnywhereInTheBodyIsLoggedAtInfoAndNeverAsAnError(t *testing.T) {
|
||||
hangUps := map[string]func(*net.TCPConn){
|
||||
"a half close": func(c *net.TCPConn) { _ = c.CloseWrite(); time.Sleep(100 * time.Millisecond); _ = c.Close() },
|
||||
"a full close": func(c *net.TCPConn) { _ = c.Close() },
|
||||
"a reset (RST)": func(c *net.TCPConn) { _ = c.SetLinger(0); _ = c.Close() },
|
||||
}
|
||||
for _, where := range []string{inTitle, inHeaders, midFile} {
|
||||
for name, hangUp := range hangUps {
|
||||
t.Run(where+", "+name, func(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
buf := &lockedBuf{}
|
||||
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||
srv := httptest.NewServer(app.Router())
|
||||
defer srv.Close()
|
||||
|
||||
partialUpload(t, strings.TrimPrefix(srv.URL, "http://"), where, hangUp)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
out := buf.String()
|
||||
return strings.Contains(out, "upload abandoned") || strings.Contains(out, "upload rejected") || strings.Contains(out, "level=ERROR")
|
||||
}, 3*time.Second, 20*time.Millisecond, "the server never finished handling the aborted upload")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
out := buf.String()
|
||||
assert.NotContains(t, out, "level=ERROR", "a client hanging up is not a server fault:\n%s", out)
|
||||
assert.Empty(t, sub.jobs)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// goneMidAccept is an Uploader during whose Accept the client disappears: the request's
|
||||
// context is cancelled, then Accept fails the way a read from a dead connection does.
|
||||
type goneMidAccept struct{ cancel context.CancelFunc }
|
||||
|
||||
func (g goneMidAccept) Accept(context.Context, string, string, io.Reader) (string, error) {
|
||||
g.cancel()
|
||||
return "", fmt.Errorf("receive: %w", io.ErrClosedPipe)
|
||||
}
|
||||
|
||||
func TestUploadPost_AnAbandonedRequestGetsA499NotAnImplicitEmpty200(t *testing.T) {
|
||||
// If the context is cancelled but the connection can still be written to (a client that
|
||||
// half-closed after sending), a missing status becomes 200 with an empty body: a false
|
||||
// success (fourth adversarial review).
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
buf := &lockedBuf{}
|
||||
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
app.Uploads = goneMidAccept{cancel: cancel}
|
||||
|
||||
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")).WithContext(ctx))
|
||||
|
||||
assert.Equal(t, 499, rec.Code, "never an implicit 200 for an upload that was not accepted")
|
||||
assert.Contains(t, buf.String(), "upload abandoned")
|
||||
assert.NotContains(t, buf.String(), "level=ERROR")
|
||||
}
|
||||
|
||||
func TestUploadPost_ADatabaseDropThatLooksLikeAnEOFIsStillAServerError(t *testing.T) {
|
||||
// pgx turns a connection that drops mid-message into io.ErrUnexpectedEOF. Mapping that
|
||||
// sentinel to "your upload was cut off" (a 400) would hide a real fault: only an error
|
||||
// from reading the CLIENT's body is theirs (third adversarial review).
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
buf := &lockedBuf{}
|
||||
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||
app.Uploads = failingUploader{err: fmt.Errorf("failed to receive message: %w", io.ErrUnexpectedEOF)}
|
||||
|
||||
rec := do(t, app, uploadReq(t, "t", "a.m4a", []byte("x")))
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Contains(t, buf.String(), "level=ERROR")
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/uploads"
|
||||
"git.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// Upload route tests drive the real Router, the real Intake and the real Postgres
|
||||
// store; only the process boundary (ffprobe) and the transcription worker are
|
||||
// faked. All audio bytes and transcript text are synthetic — this repo is public
|
||||
// and no real recording or transcript may appear in it (#33).
|
||||
|
||||
const otherUserID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
type uploadProber struct{ probe uploads.Probe }
|
||||
|
||||
func (p uploadProber) Probe(context.Context, string) (uploads.Probe, error) { return p.probe, nil }
|
||||
|
||||
type uploadSubmitter struct{ jobs []uploads.Job }
|
||||
|
||||
func (s *uploadSubmitter) Submit(j uploads.Job) error { s.jobs = append(s.jobs, j); return nil }
|
||||
|
||||
func newUploadApp(t *testing.T, probe uploads.Probe, maxBytes int64) (*web.App, *uploadSubmitter) {
|
||||
t.Helper()
|
||||
app := newApp(t)
|
||||
resetDB(t, rawPool(t))
|
||||
sub := &uploadSubmitter{}
|
||||
limits := uploads.Limits{MaxBytes: maxBytes, MaxDuration: 90 * time.Minute}
|
||||
s := newStore(t)
|
||||
app.Uploads = uploads.NewIntake(s, uploadProber{probe: probe}, sub, limits, t.TempDir())
|
||||
app.UploadLimits = limits
|
||||
return app, sub
|
||||
}
|
||||
|
||||
var audioOK = uploads.Probe{Duration: 45 * time.Minute, HasAudio: true}
|
||||
|
||||
func uploadReq(t *testing.T, title, filename string, content []byte) *http.Request {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
if title != "" {
|
||||
require.NoError(t, mw.WriteField("title", title))
|
||||
}
|
||||
if filename != "" {
|
||||
fw, err := mw.CreateFormFile("file", filename)
|
||||
require.NoError(t, err)
|
||||
_, err = fw.Write(content)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, mw.Close())
|
||||
req := httptest.NewRequest(http.MethodPost, "/uploads", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func seedUpload(t *testing.T, owner, title, status, content string) string {
|
||||
t.Helper()
|
||||
p := rawPool(t)
|
||||
_, err := p.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, owner)
|
||||
require.NoError(t, err)
|
||||
var id string
|
||||
switch status {
|
||||
case "done":
|
||||
require.NoError(t, p.QueryRow(context.Background(),
|
||||
`INSERT INTO uploaded_transcripts (user_id, title, status, content, endpoint, audio_seconds, finished_at)
|
||||
VALUES ($1, $2, 'done', $3, 'berget/kb-whisper', 2700, NOW()) RETURNING id`,
|
||||
owner, title, content).Scan(&id))
|
||||
case "failed":
|
||||
require.NoError(t, p.QueryRow(context.Background(),
|
||||
`INSERT INTO uploaded_transcripts (user_id, title, status, error_kind, finished_at)
|
||||
VALUES ($1, $2, 'failed', $3, NOW()) RETURNING id`,
|
||||
owner, title, content).Scan(&id)) // content carries the error_kind here
|
||||
default:
|
||||
require.NoError(t, p.QueryRow(context.Background(),
|
||||
`INSERT INTO uploaded_transcripts (user_id, title, status) VALUES ($1, $2, $3) RETURNING id`,
|
||||
owner, title, status).Scan(&id))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func get(path string) *http.Request { return httptest.NewRequest(http.MethodGet, path, nil) }
|
||||
|
||||
func TestUploadsPage_ShowsAMultipartFormWithAFileField(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, get("/uploads"))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
b := body(t, rec)
|
||||
assert.Contains(t, b, `enctype="multipart/form-data"`)
|
||||
assert.Contains(t, b, `name="file"`, "the multipart field is `file`")
|
||||
assert.Contains(t, b, "200 MB", "the size limit is stated up front, not discovered by failing")
|
||||
assert.Contains(t, b, "1 h 30 min", "the length limit is stated up front")
|
||||
}
|
||||
|
||||
func TestUploadsPage_ListsOnlyTheCallersUploads(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
seedUpload(t, userID, "My weekly sync", "queued", "")
|
||||
seedUpload(t, otherUserID, "Someone else's board meeting", "queued", "")
|
||||
|
||||
b := body(t, do(t, app, get("/uploads")))
|
||||
|
||||
assert.Contains(t, b, "My weekly sync")
|
||||
assert.NotContains(t, b, "Someone else's board meeting")
|
||||
}
|
||||
|
||||
func TestUploadPost_AcceptedAudioQueuesARowAndRedirectsToIt(t *testing.T) {
|
||||
app, sub := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, uploadReq(t, "Sprint review", "recording.m4a", []byte("synthetic audio bytes")))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
var id, status, title string
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||
`SELECT id, status, title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&id, &status, &title))
|
||||
assert.Equal(t, "queued", status)
|
||||
assert.Equal(t, "Sprint review", title)
|
||||
assert.Equal(t, "/uploads/"+id, rec.Header().Get("Location"))
|
||||
require.Len(t, sub.jobs, 1)
|
||||
assert.Equal(t, uploads.Job{UserID: userID, UploadID: id, Path: sub.jobs[0].Path, Duration: audioOK.Duration}, sub.jobs[0])
|
||||
spooled, err := os.ReadFile(sub.jobs[0].Path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "synthetic audio bytes", string(spooled))
|
||||
}
|
||||
|
||||
func TestUploadPost_WithoutATitleUsesTheFilenameMinusItsExtension(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, uploadReq(t, "", "Teams meeting 2026-09-30.mp4", []byte("x")))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code)
|
||||
var title string
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||
`SELECT title FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&title))
|
||||
assert.Equal(t, "Teams meeting 2026-09-30", title)
|
||||
}
|
||||
|
||||
func TestUploadPost_RejectionsAreSynchronousWithAMessageAndNoRow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
probe uploads.Probe
|
||||
maxBytes int64
|
||||
content string
|
||||
wantStatus int
|
||||
wantInBody []string
|
||||
}{
|
||||
{
|
||||
name: "video with no audio is rejected for having no audio",
|
||||
probe: uploads.Probe{Duration: time.Minute, HasVideo: true},
|
||||
maxBytes: 1 << 20,
|
||||
content: "x",
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
wantInBody: []string{"no audio"},
|
||||
},
|
||||
{
|
||||
name: "too long states both lengths",
|
||||
probe: uploads.Probe{Duration: 2 * time.Hour, HasAudio: true},
|
||||
maxBytes: 1 << 20,
|
||||
content: "x",
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
wantInBody: []string{"2 h", "1 h 30 min"},
|
||||
},
|
||||
{
|
||||
name: "too large is 413",
|
||||
probe: audioOK,
|
||||
maxBytes: 4,
|
||||
content: "more than four bytes",
|
||||
wantStatus: http.StatusRequestEntityTooLarge,
|
||||
wantInBody: []string{"limit"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app, sub := newUploadApp(t, tt.probe, tt.maxBytes)
|
||||
|
||||
rec := do(t, app, uploadReq(t, "t", "r.m4a", []byte(tt.content)))
|
||||
|
||||
assert.Equal(t, tt.wantStatus, rec.Code)
|
||||
b := body(t, rec)
|
||||
for _, s := range tt.wantInBody {
|
||||
assert.Contains(t, b, s)
|
||||
}
|
||||
var n int
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM uploaded_transcripts`).Scan(&n))
|
||||
assert.Zero(t, n, "a rejected upload leaves no row")
|
||||
assert.Empty(t, sub.jobs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPost_WithNoFilePartIs400(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
rec := do(t, app, uploadReq(t, "only a title", "", nil))
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
assert.Contains(t, body(t, rec), "Choose an audio file")
|
||||
}
|
||||
|
||||
func TestUploadRoutes_AreNotMountedWithoutAnUploader(t *testing.T) {
|
||||
app := newApp(t)
|
||||
resetDB(t, rawPool(t))
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, do(t, app, get("/uploads")).Code)
|
||||
}
|
||||
|
||||
func TestUploadPage_OwnerSeesTitleStatusAndNonOwnerGetsNotFound(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
mine := seedUpload(t, userID, "Roadmap sync", "queued", "")
|
||||
theirs := seedUpload(t, otherUserID, "Their private meeting", "queued", "")
|
||||
|
||||
own := do(t, app, get("/uploads/"+mine))
|
||||
require.Equal(t, http.StatusOK, own.Code)
|
||||
assert.Contains(t, body(t, own), "Roadmap sync")
|
||||
|
||||
for _, path := range []string{"/uploads/" + theirs, "/uploads/" + theirs + "/status"} {
|
||||
rec := do(t, app, get(path))
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, path)
|
||||
assert.NotContains(t, body(t, rec), "Their private meeting", "a 404 must not leak the title")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadStatus_UnfinishedStatesKeepPollingTerminalStatesStop(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
tests := []struct {
|
||||
name, status, content string
|
||||
polls bool
|
||||
wantInBody string
|
||||
}{
|
||||
{"queued", "queued", "", true, "Queued"},
|
||||
{"transcribing", "transcribing", "", true, "ranscrib"},
|
||||
{"done", "done", "Synthetic transcript text.", false, "Synthetic transcript text."},
|
||||
{"failed interrupted", "failed", "interrupted", false, "upload it again"},
|
||||
{"failed transcription", "failed", "transcription_failed", false, "Transcription failed"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id := seedUpload(t, userID, "m-"+tt.name, tt.status, tt.content)
|
||||
|
||||
rec := do(t, app, get("/uploads/"+id+"/status"))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
b := body(t, rec)
|
||||
assert.Contains(t, b, tt.wantInBody)
|
||||
if tt.polls {
|
||||
assert.Contains(t, b, `hx-trigger="every`, "an unfinished upload must keep polling")
|
||||
} else {
|
||||
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever: no stuck spinner")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadStatus_DoneNamesTheEndpointThatServedIt(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "berget/kb-whisper",
|
||||
"which backend served a transcript is a quality fact, and berget means the audio left the homelab")
|
||||
assert.True(t, strings.Contains(b, "45 min"), "the audio length is shown")
|
||||
}
|
||||
|
||||
func markHadVideo(t *testing.T, id string) {
|
||||
t.Helper()
|
||||
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET had_video = TRUE WHERE id = $1`, id)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUploadPost_AFileWithAVideoTrackIsAcceptedAndTheRowRemembersIt(t *testing.T) {
|
||||
// A Teams meeting with the camera off is still an .mp4 with a video track.
|
||||
app, sub := newUploadApp(t, uploads.Probe{Duration: 62 * time.Minute, HasAudio: true, HasVideo: true}, 200<<20)
|
||||
|
||||
rec := do(t, app, uploadReq(t, "Project notes", "Teams meeting.mp4", []byte("synthetic bytes")))
|
||||
|
||||
require.Equal(t, http.StatusSeeOther, rec.Code, "it must be accepted, not rejected as video")
|
||||
var hadVideo bool
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
|
||||
assert.True(t, hadVideo)
|
||||
assert.Len(t, sub.jobs, 1)
|
||||
}
|
||||
|
||||
func TestUploadPost_AudioOnlyIsNotMarkedAsHavingHadVideo(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
|
||||
do(t, app, uploadReq(t, "Voice memo", "memo.m4a", []byte("x")))
|
||||
|
||||
var hadVideo bool
|
||||
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
||||
`SELECT had_video FROM uploaded_transcripts WHERE user_id = $1`, userID).Scan(&hadVideo))
|
||||
assert.False(t, hadVideo)
|
||||
}
|
||||
|
||||
func TestUploadStatus_TellsTheUserTheVideoWasIgnoredInEveryStateAndOnlyWhenItWas(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
states := []struct{ status, content string }{
|
||||
{"queued", ""}, {"transcribing", ""}, {"done", "Synthetic transcript."}, {"failed", "interrupted"},
|
||||
}
|
||||
for _, st := range states {
|
||||
t.Run(st.status, func(t *testing.T) {
|
||||
withVideo := seedUpload(t, userID, "camera off "+st.status, st.status, st.content)
|
||||
markHadVideo(t, withVideo)
|
||||
audioOnly := seedUpload(t, userID, "voice only "+st.status, st.status, st.content)
|
||||
|
||||
notice := body(t, do(t, app, get("/uploads/"+withVideo+"/status")))
|
||||
plain := body(t, do(t, app, get("/uploads/"+audioOnly+"/status")))
|
||||
|
||||
assert.Contains(t, notice, "video track")
|
||||
assert.Contains(t, notice, "Only its audio was used", "say what happened, not just that something did")
|
||||
assert.Contains(t, notice, "discarded")
|
||||
assert.NotContains(t, plain, "video track", "no notice when there was no video")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPage_AlsoCarriesTheVideoNotice(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "camera off", "done", "Synthetic transcript.")
|
||||
markHadVideo(t, id)
|
||||
|
||||
assert.Contains(t, body(t, do(t, app, get("/uploads/"+id))), "Only its audio was used")
|
||||
}
|
||||
|
||||
func TestUploadPost_ARejectionIsLoggedWithItsReasonAndNeverTheFilenameOrTitle(t *testing.T) {
|
||||
// Rejections are synchronous and leave no row, so without a log line an operator
|
||||
// cannot tell "the user never retried" from "the page refused it". The reason is
|
||||
// operational; the filename and title are the user's private content.
|
||||
var buf bytes.Buffer
|
||||
app, _ := newUploadApp(t, uploads.Probe{Duration: time.Minute, HasVideo: true}, 1<<20)
|
||||
app.Log = slog.New(slog.NewTextHandler(&buf, nil))
|
||||
|
||||
rec := do(t, app, uploadReq(t, "Secret project notes", "client-acme-roadmap.mp4", []byte("x")))
|
||||
|
||||
require.Equal(t, http.StatusUnprocessableEntity, rec.Code)
|
||||
logged := buf.String()
|
||||
assert.Contains(t, logged, "upload rejected")
|
||||
assert.Contains(t, logged, "reason=no_audio")
|
||||
assert.NotContains(t, logged, "Secret project notes", "the title is private content")
|
||||
assert.NotContains(t, logged, "client-acme-roadmap", "the filename is private content")
|
||||
}
|
||||
|
||||
func setReconciliation(t *testing.T, id, json string) {
|
||||
t.Helper()
|
||||
_, err := rawPool(t).Exec(context.Background(), `UPDATE uploaded_transcripts SET reconciliation = $2::jsonb WHERE id = $1`, id, json)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
const reconciledJSON = `{"status":"reconciled","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b",
|
||||
"disputes":380,"auto_resolved":90,"adjudicated":285,"changed":120,"unsure":5,"errors":0,
|
||||
"contested":[
|
||||
{"seconds":2210.5,"base":"Axel","alt":"Alex","used":"alt","unsure":false},
|
||||
{"seconds":3400,"base":"A+2","alt":"en A","used":"base","unsure":true}]}`
|
||||
|
||||
func TestUploadStatus_ReconciledSaysWhatWasDoneInPlainWords(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, reconciledJSON)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "Reconciled from two transcripts")
|
||||
assert.Contains(t, b, "iguana/pianissimo")
|
||||
assert.Contains(t, b, "iguana/kb-whisper")
|
||||
assert.Contains(t, b, "iguana/gemma4-26b", "who decided is part of the account")
|
||||
assert.Contains(t, b, "380 differences")
|
||||
assert.Contains(t, b, "90 settled automatically")
|
||||
assert.Contains(t, b, "285 decided by the model")
|
||||
assert.Contains(t, b, "5 uncertain")
|
||||
}
|
||||
|
||||
func TestUploadStatus_ListsTheContestedSpotsWithAMinuteMarkBothReadingsAndWhichWasUsed(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, reconciledJSON)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "~37 min", "2210.5 s")
|
||||
assert.Contains(t, b, "Axel")
|
||||
assert.Contains(t, b, "Alex")
|
||||
assert.Contains(t, b, "~57 min", "3400 s")
|
||||
assert.Contains(t, b, "A+2")
|
||||
assert.Contains(t, b, "en A")
|
||||
assert.Contains(t, b, "uncertain", "an unsure spot is marked so the reader knows to check it")
|
||||
}
|
||||
|
||||
func TestUploadStatus_ASingleEngineTranscriptDoesNotClaimASecondOpinion(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
|
||||
assert.NotContains(t, body(t, do(t, app, get("/uploads/"+id+"/status"))), "Reconciled", "no reconciliation, no claim")
|
||||
}
|
||||
|
||||
func TestUploadStatus_WhenOnlyOneEngineWorkedItSaysWhyTheSecondOpinionIsMissing(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, `{"status":"single_engine","reason":"iguana/pianissimo failed: wedged","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b"}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "Only one engine")
|
||||
assert.Contains(t, b, "iguana/pianissimo failed")
|
||||
assert.NotContains(t, b, "Reconciled from two")
|
||||
}
|
||||
|
||||
func TestUploadStatus_WhenTheModelWasUnavailableItSaysTheTextIsUnreconciled(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, `{"status":"skipped","reason":"the model failed 3 times in a row: gateway down","base":"iguana/pianissimo","alt":"iguana/kb-whisper","reconciler":"iguana/gemma4-26b","disputes":380,"errors":3}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "could not be reconciled")
|
||||
assert.Contains(t, b, "iguana/pianissimo", "which engine's text is being shown")
|
||||
assert.Contains(t, b, "gateway down")
|
||||
}
|
||||
|
||||
func TestUploadStatus_ContestedReadingsAreEscaped(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"unsure":1,
|
||||
"contested":[{"seconds":60,"base":"<script>alert(1)</script>","alt":"ok","used":"base","unsure":true}]}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.NotContains(t, b, "<script>alert(1)</script>", "a reading is words from a recording, never markup")
|
||||
assert.Contains(t, b, "<script>")
|
||||
}
|
||||
|
||||
func TestUploadStatus_AVeryLongListOfContestedSpotsIsShortenedWithItsTrueCount(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
var spots []string
|
||||
for i := 0; i < 120; i++ {
|
||||
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"base","unsure":true}`, i*10, i, i))
|
||||
}
|
||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"unsure":120,"contested":[`+strings.Join(spots, ",")+`]}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "b0", "the first spots are shown")
|
||||
assert.NotContains(t, b, ">b119<", "the page does not render every one")
|
||||
assert.Contains(t, b, "70 more", "120 stored, 50 shown")
|
||||
}
|
||||
|
||||
func TestUploadStatus_ALengthFailureIsExplainedInWordsAndNotAsAGenericError(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
tests := []struct{ kind, want string }{
|
||||
{"audio_too_long", "longer than the limit"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.kind, func(t *testing.T) {
|
||||
id := seedUpload(t, userID, "m-"+tt.kind, "failed", tt.kind)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, tt.want)
|
||||
assert.NotContains(t, b, "This upload failed. Please upload it again.")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Error paths of the upload routes log a kind, never the error's text, which can carry
|
||||
// a file name, a path, a database value or a provider's output (adversarial review F5).
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
type failingUploader struct{ err error }
|
||||
|
||||
func (f failingUploader) Accept(context.Context, string, string, io.Reader) (string, error) {
|
||||
return "", f.err
|
||||
}
|
||||
|
||||
func TestUploadPost_AnUnexpectedFailureLogsAKindAndNotItsText(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
buf := &lockedBuf{}
|
||||
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||
app.Uploads = failingUploader{err: errors.New(`ffprobe: Error opening input file /spool/upload-555 for "zebrakaka-meeting.m4a"`)}
|
||||
|
||||
rec := do(t, app, uploadReq(t, "t", "zebrakaka-meeting.m4a", []byte("x")))
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
out := buf.String()
|
||||
assert.NotContains(t, out, "zebrakaka", "a file name is private content")
|
||||
assert.NotContains(t, out, "/spool/", "a spool path carries the upload's identity")
|
||||
assert.Contains(t, out, "kind=error")
|
||||
}
|
||||
|
||||
func TestUploadChatPost_AModelFailureLogsAKindAndNotItsText(t *testing.T) {
|
||||
app, fc := newUploadChatApp(t)
|
||||
buf := &lockedBuf{}
|
||||
app.Log = slog.New(slog.NewTextHandler(buf, nil))
|
||||
fc.err = errors.New("LLM returned 400: the prompt was zebrakaka secret words")
|
||||
id := seedUpload(t, userID, "m", "done", meetingText)
|
||||
|
||||
rec := do(t, app, chatPost("/uploads/"+id+"/chat", url.Values{"question": {"q"}}))
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
out := buf.String()
|
||||
assert.NotContains(t, out, "zebrakaka")
|
||||
assert.Contains(t, out, "kind=error")
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
"git.d-ma.be/mathias/tapir/internal/web"
|
||||
)
|
||||
|
||||
// The page's progress display (ADR-035, #42): real phases from the row, and the three
|
||||
// honest states the page can tell apart -- running, quiet (counted phase not advancing)
|
||||
// and lost (the service stopped reporting). Real router, real Postgres, a fixed clock.
|
||||
|
||||
var progressNow = time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
func fixedClock(app *web.App) { app.Now = func() time.Time { return progressNow } }
|
||||
|
||||
// seedProgress writes the job's phases and the two timestamps onto an existing row.
|
||||
func seedProgress(t *testing.T, id string, steps []domain.ProgressStep, progressAt, aliveAt time.Time) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(steps)
|
||||
require.NoError(t, err)
|
||||
var alive any
|
||||
if !aliveAt.IsZero() {
|
||||
alive = aliveAt
|
||||
}
|
||||
_, err = rawPool(t).Exec(context.Background(),
|
||||
`UPDATE uploaded_transcripts SET progress = $2, progress_at = $3, alive_at = $4 WHERE id = $1`,
|
||||
id, b, progressAt, alive)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func step(key, label string, state domain.StepState, startedAgo, ranFor time.Duration, done, total int) domain.ProgressStep {
|
||||
s := domain.ProgressStep{Key: key, Label: label, State: state, Done: done, Total: total}
|
||||
if state != domain.StepPending {
|
||||
s.StartedAt = progressNow.Add(-startedAgo)
|
||||
}
|
||||
if state == domain.StepDone || state == domain.StepFailed {
|
||||
s.FinishedAt = s.StartedAt.Add(ranFor)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// twoEngineRun is a job in its second engine: three steps done, one running, three to come.
|
||||
func twoEngineRun() []domain.ProgressStep {
|
||||
return []domain.ProgressStep{
|
||||
step("received", "Received", domain.StepDone, 5*time.Minute, 0, 0, 0),
|
||||
step("audio", "Preparing the audio", domain.StepDone, 5*time.Minute, 40*time.Second, 0, 0),
|
||||
step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 4*time.Minute, 38*time.Second, 0, 0),
|
||||
step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 3*time.Minute+12*time.Second, 0, 0, 0),
|
||||
step("compare", "Comparing the two transcripts", domain.StepPending, 0, 0, 0, 0),
|
||||
step("decide", "Deciding the differences", domain.StepPending, 0, 0, 0, 0),
|
||||
step("save", "Saving the transcript", domain.StepPending, 0, 0, 0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func statusBody(t *testing.T, app *web.App, id string) string {
|
||||
t.Helper()
|
||||
rec := do(t, app, get("/uploads/"+id+"/status"))
|
||||
require.Equal(t, 200, rec.Code)
|
||||
return body(t, rec)
|
||||
}
|
||||
|
||||
func TestUploadProgress_ARunningJobShowsEveryPhaseInOrderWithTheRunningOneMarked(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
last := -1
|
||||
for _, label := range []string{
|
||||
"Received", "Preparing the audio", "Transcribing with iguana/pianissimo", "Transcribing with iguana/kb-whisper",
|
||||
"Comparing the two transcripts", "Deciding the differences", "Saving the transcript",
|
||||
} {
|
||||
i := strings.Index(b, label)
|
||||
require.Greater(t, i, last, "phase %q missing or out of order:\n%s", label, b)
|
||||
last = i
|
||||
}
|
||||
assert.Equal(t, 3, strings.Count(b, "step-done"), "three finished phases collapse to a done line")
|
||||
assert.Equal(t, 1, strings.Count(b, `aria-current="step"`), "exactly one phase is the current one")
|
||||
assert.Contains(t, b, "3m 12s", "the running phase shows how long it has run")
|
||||
assert.Contains(t, b, "38s", "a finished phase shows how long it took")
|
||||
assert.Contains(t, b, `hx-trigger="every 2s"`, "an unfinished job re-asks every two seconds")
|
||||
}
|
||||
|
||||
func TestUploadProgress_OnlyACountedPhaseGetsABarAndItShowsRealCounts(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
steps := twoEngineRun()
|
||||
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 3*time.Minute, 2*time.Minute, 0, 0)
|
||||
steps[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 1*time.Minute, time.Second, 0, 0)
|
||||
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 50*time.Second, 0, 142, 282)
|
||||
seedProgress(t, id, steps, progressNow.Add(-5*time.Second), progressNow.Add(-3*time.Second))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, `<progress`)
|
||||
assert.Contains(t, b, `value="142"`)
|
||||
assert.Contains(t, b, `max="282"`)
|
||||
assert.Contains(t, b, "142 of 282")
|
||||
|
||||
// ...and a phase with no honest count never gets one: no percentage out of thin air.
|
||||
app2, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app2)
|
||||
id2 := seedUpload(t, userID, "m2", "transcribing", "")
|
||||
seedProgress(t, id2, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
|
||||
assert.NotContains(t, statusBody(t, app2, id2), "<progress", "an engine call is one request: there is nothing to count")
|
||||
}
|
||||
|
||||
func TestUploadProgress_ALongEngineCallWithAFreshHeartbeatIsStillJustRunning(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
steps := twoEngineRun()
|
||||
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepRunning, 25*time.Minute, 0, 0, 0)
|
||||
seedProgress(t, id, steps, progressNow.Add(-25*time.Minute), progressNow.Add(-3*time.Second))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, "25m 00s")
|
||||
assert.NotContains(t, b, "step-health-lost")
|
||||
assert.NotContains(t, b, "step-health-quiet",
|
||||
"a single blocking request has no sub-progress to go quiet about; the heartbeat says the service is alive")
|
||||
}
|
||||
|
||||
func TestUploadProgress_AHeartbeatThatStoppedMeansTheServiceIsLostAndTheJobIsNotCalledRunning(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
seedProgress(t, id, twoEngineRun(), progressNow.Add(-3*time.Minute), progressNow.Add(-3*time.Minute))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, "step-health-lost")
|
||||
assert.Contains(t, b, "stopped reporting")
|
||||
assert.Contains(t, b, "3 min", "how long ago it was last heard from")
|
||||
assert.NotContains(t, b, "spinner", "a lost job must not wear the running spinner")
|
||||
assert.Contains(t, b, "step-mark-warn", "the running phase of a lost job carries a warning mark, in the error colour, not the accent")
|
||||
assert.Contains(t, b, `hx-trigger="every`, "it keeps asking: a restarted service comes back, or the start-up sweep fails the row")
|
||||
}
|
||||
|
||||
func TestUploadProgress_ACountedPhaseThatStopsAdvancingGoesQuietNotLost(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
steps := twoEngineRun()
|
||||
steps[5] = step("decide", "Deciding the differences", domain.StepRunning, 20*time.Minute, 0, 142, 282)
|
||||
seedProgress(t, id, steps, progressNow.Add(-6*time.Minute), progressNow.Add(-3*time.Second))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, "step-health-quiet")
|
||||
assert.Contains(t, b, "Nothing new for 6 min")
|
||||
assert.NotContains(t, b, "stopped reporting", "the service is alive; it is the model that is slow")
|
||||
}
|
||||
|
||||
func TestUploadProgress_BeforeTheFirstHeartbeatAJobIsRunningUntilItHasBeenSilentTooLong(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
fresh := seedUpload(t, userID, "fresh", "transcribing", "")
|
||||
seedProgress(t, fresh, twoEngineRun(), progressNow.Add(-5*time.Second), time.Time{})
|
||||
silent := seedUpload(t, userID, "silent", "transcribing", "")
|
||||
seedProgress(t, silent, twoEngineRun(), progressNow.Add(-2*time.Minute), time.Time{})
|
||||
|
||||
assert.NotContains(t, statusBody(t, app, fresh), "step-health-lost", "the first beat has not been due yet")
|
||||
assert.Contains(t, statusBody(t, app, silent), "step-health-lost", "no beat and no progress for two minutes is a lost service")
|
||||
}
|
||||
|
||||
func TestUploadProgress_AQueuedJobShowsNoPhasesAndNoLostWarning(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "queued", "")
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, "Queued")
|
||||
assert.NotContains(t, b, `class="steps"`)
|
||||
assert.NotContains(t, b, "step-health")
|
||||
assert.Contains(t, b, `hx-trigger="every 2s"`)
|
||||
}
|
||||
|
||||
func TestUploadProgress_AFailedJobShowsWhichPhaseFailedAndWhatNeverStarted(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "failed", "transcription_failed")
|
||||
steps := twoEngineRun()
|
||||
steps[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
|
||||
seedProgress(t, id, steps, progressNow.Add(-2*time.Minute), progressNow.Add(-2*time.Minute))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, "Transcription failed")
|
||||
assert.Contains(t, b, "step-failed")
|
||||
assert.Contains(t, b, "step-pending", "what never started says where the job stopped")
|
||||
assert.NotContains(t, b, "hx-trigger", "a failed job stops polling")
|
||||
assert.NotContains(t, b, "step-health", "a finished job is not 'lost'")
|
||||
}
|
||||
|
||||
func TestUploadProgress_ADoneJobKeepsItsRecordOfWhatWasDoneAndHowLongEachTook(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
||||
steps := []domain.ProgressStep{
|
||||
step("received", "Received", domain.StepDone, 20*time.Minute, 0, 0, 0),
|
||||
step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0),
|
||||
step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 9*time.Minute+30*time.Second, 282, 282),
|
||||
step("save", "Saving the transcript", domain.StepSkipped, 0, 0, 0, 0),
|
||||
}
|
||||
seedProgress(t, id, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
assert.Contains(t, b, "Synthetic transcript text.")
|
||||
assert.Contains(t, b, "What was done")
|
||||
assert.Contains(t, b, "9m 30s")
|
||||
assert.Contains(t, b, "step-skipped")
|
||||
assert.NotContains(t, b, "hx-trigger", "a finished upload must not poll forever")
|
||||
assert.NotContains(t, b, "step-health", "a finished job is never 'lost' however old its heartbeat")
|
||||
assert.NotContains(t, b, "spinner")
|
||||
}
|
||||
|
||||
// stepsBlock is the progress list of a page: from its opening tag to its closing one.
|
||||
func stepsBlock(t *testing.T, html string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(html, `<ol class="steps"`)
|
||||
require.GreaterOrEqual(t, start, 0, "no progress list in:\n%s", html)
|
||||
end := strings.Index(html[start:], "</ol>")
|
||||
require.GreaterOrEqual(t, end, 0)
|
||||
return html[start : start+end+len("</ol>")]
|
||||
}
|
||||
|
||||
func TestUploadProgress_TheFullPageCarriesTheVerySameProgressListAsTheFragment(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
seedProgress(t, id, twoEngineRun(), progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
|
||||
|
||||
full := body(t, do(t, app, get("/uploads/"+id)))
|
||||
fragment := statusBody(t, app, id)
|
||||
|
||||
assert.Equal(t, stepsBlock(t, fragment), stepsBlock(t, full),
|
||||
"the page a user opens and the fragment HTMX swaps in must show the same list, or the display jumps on the first poll")
|
||||
}
|
||||
|
||||
func TestUploadProgress_AStepNoteIsShownDirectlyUnderItsStepAndEscaped(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
id := seedUpload(t, userID, "m", "transcribing", "")
|
||||
steps := twoEngineRun()
|
||||
steps[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. <b>cut off</b>?"
|
||||
seedProgress(t, id, steps, progressNow.Add(-10*time.Second), progressNow.Add(-4*time.Second))
|
||||
|
||||
b := statusBody(t, app, id)
|
||||
|
||||
audio := strings.Index(b, "Preparing the audio")
|
||||
note := strings.Index(b, "The audio is 20 min long")
|
||||
next := strings.Index(b, "Transcribing with iguana/pianissimo")
|
||||
require.True(t, audio >= 0 && note > audio && next > note, "the note must sit between its own step and the next one:\n%s", b)
|
||||
assert.Contains(t, b, "step-note")
|
||||
assert.NotContains(t, b, "<b>cut off</b>", "a note is words, never markup")
|
||||
}
|
||||
|
||||
func TestUploadProgress_ADonePageShowsItsRecordOpenWhenAStepHasANote(t *testing.T) {
|
||||
// A truncated recording must not look like a clean one: the note that replaced the
|
||||
// refusal is the only warning, so it cannot sit inside a collapsed list (third review).
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
noted := seedUpload(t, userID, "noted", "done", "Synthetic transcript text.")
|
||||
steps := []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}
|
||||
steps[0].Note = "The audio is 20 min long; the file claimed 1 h 19 min."
|
||||
seedProgress(t, noted, steps, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
|
||||
clean := seedUpload(t, userID, "clean", "done", "Synthetic transcript text.")
|
||||
seedProgress(t, clean, []domain.ProgressStep{step("audio", "Preparing the audio", domain.StepDone, 20*time.Minute, 40*time.Second, 0, 0)}, progressNow.Add(-time.Minute), progressNow.Add(-time.Minute))
|
||||
|
||||
assert.Contains(t, statusBody(t, app, noted), `<details class="what-was-done" open>`)
|
||||
assert.NotContains(t, statusBody(t, app, clean), `<details class="what-was-done" open>`, "a clean record stays collapsed")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/domain"
|
||||
)
|
||||
|
||||
// TestRenderUploadProgressStatesForReview writes the upload page in each progress state
|
||||
// to $TAPIR_SHOT_DIR as standalone HTML (light and dark), for a screenshot review of the
|
||||
// visual claims tests cannot assert: that it looks like tapir and reads like a log of
|
||||
// phases (ADR-035, #42). Opt-in: it does nothing unless the variable is set. The text is
|
||||
// synthetic. Screenshot with any browser, for example:
|
||||
//
|
||||
// TAPIR_SHOT_DIR=/tmp/shots go test -run TestRenderUploadProgressStatesForReview ./internal/web
|
||||
// chromium --headless --screenshot=/tmp/shots/running.png --window-size=900,900 /tmp/shots/running-light.html
|
||||
func TestRenderUploadProgressStatesForReview(t *testing.T) {
|
||||
dir := os.Getenv("TAPIR_SHOT_DIR")
|
||||
if dir == "" {
|
||||
t.Skip("set TAPIR_SHOT_DIR to render the progress states for a screenshot review")
|
||||
}
|
||||
require.NoError(t, os.MkdirAll(dir, 0o750))
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
fixedClock(app)
|
||||
|
||||
decide := twoEngineRun()
|
||||
decide[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 9*time.Minute, 4*time.Minute, 0, 0)
|
||||
decide[4] = step("compare", "Comparing the two transcripts", domain.StepDone, 5*time.Minute, time.Second, 0, 0)
|
||||
decide[5] = step("decide", "Deciding the differences", domain.StepRunning, 4*time.Minute+30*time.Second, 0, 142, 282)
|
||||
|
||||
failed := twoEngineRun()
|
||||
failed[3] = step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepFailed, 3*time.Minute, time.Minute, 0, 0)
|
||||
|
||||
done := []domain.ProgressStep{
|
||||
step("received", "Received", domain.StepDone, 22*time.Minute, 0, 0, 0),
|
||||
step("audio", "Preparing the audio", domain.StepDone, 22*time.Minute, 41*time.Second, 0, 0),
|
||||
step("engine_a", "Transcribing with iguana/pianissimo", domain.StepDone, 21*time.Minute, 2*time.Minute+10*time.Second, 0, 0),
|
||||
step("engine_b", "Transcribing with iguana/kb-whisper", domain.StepDone, 19*time.Minute, 4*time.Minute+5*time.Second, 0, 0),
|
||||
step("compare", "Comparing the two transcripts", domain.StepDone, 15*time.Minute, 2*time.Second, 0, 0),
|
||||
step("decide", "Deciding the differences", domain.StepDone, 15*time.Minute, 14*time.Minute, 282, 282),
|
||||
step("save", "Saving the transcript", domain.StepDone, time.Minute, time.Second, 0, 0),
|
||||
}
|
||||
|
||||
noted := twoEngineRun()
|
||||
noted[1].Note = "The audio is 20 min long; the file claimed 1 h 19 min. If the recording was cut off, upload the complete one."
|
||||
|
||||
cases := []struct {
|
||||
name, status, content string
|
||||
steps []domain.ProgressStep
|
||||
progressAgo, aliveAgo time.Duration
|
||||
}{
|
||||
{"running", "transcribing", "", twoEngineRun(), 10 * time.Second, 4 * time.Second},
|
||||
{"deciding", "transcribing", "", decide, 3 * time.Second, 3 * time.Second},
|
||||
{"quiet", "transcribing", "", decide, 6 * time.Minute, 4 * time.Second},
|
||||
{"lost", "transcribing", "", twoEngineRun(), 3 * time.Minute, 3 * time.Minute},
|
||||
{"failed", "failed", "transcription_failed", failed, 2 * time.Minute, 2 * time.Minute},
|
||||
{"done", "done", "Synthetic transcript text for the review.", done, time.Minute, time.Minute},
|
||||
{"noted", "transcribing", "", noted, 10 * time.Second, 4 * time.Second},
|
||||
{"queued", "queued", "", nil, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
id := seedUpload(t, userID, "Project notes (synthetic)", c.status, c.content)
|
||||
if c.steps != nil {
|
||||
seedProgress(t, id, c.steps, progressNow.Add(-c.progressAgo), progressNow.Add(-c.aliveAgo))
|
||||
}
|
||||
page := body(t, do(t, app, get("/uploads/"+id)))
|
||||
for _, theme := range []string{"light", "dark"} {
|
||||
html := page
|
||||
if theme == "dark" {
|
||||
html = strings.Replace(html, "<html", `<html data-theme="dark"`, 1)
|
||||
}
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, c.name+"-"+theme+".html"), []byte(html), 0o600))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The stored judged spots include the ones the model kept as the base (ADR-035), for a
|
||||
// later evaluation. The page lists only the ones a reader should check: changed and
|
||||
// uncertain.
|
||||
|
||||
func TestUploadStatus_OnlyChangedAndUncertainSpotsAreListedNotTheKeptOnes(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":3,"adjudicated":3,"changed":1,"unsure":1,
|
||||
"contested":[
|
||||
{"seconds":60,"base":"CHANGEDBASE","alt":"CHANGEDALT","used":"alt","unsure":false},
|
||||
{"seconds":120,"base":"KEPTBASE","alt":"KEPTALT","used":"base","unsure":false},
|
||||
{"seconds":180,"base":"UNSUREBASE","alt":"UNSUREALT","used":"base","unsure":true}]}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "CHANGEDBASE")
|
||||
assert.Contains(t, b, "UNSUREALT")
|
||||
assert.NotContains(t, b, "KEPTBASE", "a spot the model confidently kept is not one for the reader to check")
|
||||
assert.NotContains(t, b, "KEPTALT")
|
||||
}
|
||||
|
||||
func TestUploadStatus_TheMoreCountIgnoresKeptSpotsAndMatchesTheListedKind(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
var spots []string
|
||||
for i := 0; i < 60; i++ {
|
||||
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"b%d","alt":"a%d","used":"alt","unsure":false}`, i*10, i, i))
|
||||
spots = append(spots, fmt.Sprintf(`{"seconds":%d,"base":"k%d","alt":"j%d","used":"base","unsure":false}`, i*10+5, i, i))
|
||||
}
|
||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":400,"changed":60,"contested":[`+strings.Join(spots, ",")+`]}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "10 more", "60 changed, 50 shown: the 60 kept ones do not count")
|
||||
assert.NotContains(t, b, "k0 / j0", "a kept spot is not rendered even when it is among the first stored")
|
||||
}
|
||||
|
||||
func TestUploadStatus_WhenNothingNeedsCheckingThereIsNoSpotsSection(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"adjudicated":1,
|
||||
"contested":[{"seconds":60,"base":"KEPTBASE","alt":"KEPTALT","used":"base","unsure":false}]}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
require.Contains(t, b, "Reconciled from two transcripts")
|
||||
assert.NotContains(t, b, "Spots to check", "an empty list would invite checking nothing")
|
||||
}
|
||||
|
||||
func TestUploadStatus_ASpotShowsTheWordsJustBeforeIt(t *testing.T) {
|
||||
app, _ := newUploadApp(t, audioOK, 200<<20)
|
||||
id := seedUpload(t, userID, "m", "done", "Synthetic transcript.")
|
||||
setReconciliation(t, id, `{"status":"reconciled","base":"a","alt":"b","reconciler":"c","disputes":1,"changed":1,
|
||||
"contested":[{"seconds":2640,"base":"vanligt","alt":"KORT","used":"alt","unsure":false,"before":"grannen målar ett"}]}`)
|
||||
|
||||
b := body(t, do(t, app, get("/uploads/"+id+"/status")))
|
||||
|
||||
assert.Contains(t, b, "grannen målar ett", "the words before the spot are how a reader finds it in the audio")
|
||||
assert.Contains(t, b, "vanligt")
|
||||
assert.Contains(t, b, "KORT")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -765,6 +765,32 @@ a.btn, a.btn:visited { color: var(--accent-fg); }
|
||||
.flash-success { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
|
||||
.flash-error { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
|
||||
|
||||
/* upload progress (ADR-035): a log of phases that fills in as the job runs */
|
||||
.steps { list-style: none; margin: var(--s3) 0; padding: 0; font-family: var(--mono); font-size: .86rem; }
|
||||
.step { display: flex; align-items: baseline; gap: var(--s2); padding: .2rem 0; color: var(--muted); flex-wrap: wrap; }
|
||||
.step-mark { flex: none; width: 1.1rem; text-align: center; }
|
||||
.step-label { flex: 1 1 auto; }
|
||||
.step-time { margin-left: auto; color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; }
|
||||
.step-done { color: var(--fg); }
|
||||
.step-done .step-mark { color: var(--mint); }
|
||||
.step-running { color: var(--fg); font-weight: 600; }
|
||||
.step-running .step-mark { color: var(--accent); }
|
||||
.step-failed { color: var(--err-fg); }
|
||||
.step-skipped, .step-pending { color: var(--muted); }
|
||||
.step-skipped { opacity: .7; }
|
||||
.step-pending { opacity: .75; }
|
||||
.step-bar { flex: none; width: calc(100% - 1.6rem); margin: 0 0 0 1.6rem; height: .5rem; accent-color: var(--accent); }
|
||||
.step-note { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); font-family: system-ui, -apple-system, sans-serif; font-size: .82rem; }
|
||||
.step-count { flex: none; width: calc(100% - 1.6rem); margin-left: 1.6rem; font-weight: 400; color: var(--muted); }
|
||||
.step-running .step-mark-warn { color: var(--err-fg); }
|
||||
.spinner { display: inline-block; vertical-align: -.1em; width: .8rem; height: .8rem; box-sizing: border-box; border: 2px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: tapir-spin 1s linear infinite; }
|
||||
@keyframes tapir-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-color: var(--accent); } }
|
||||
.step-health { margin: var(--s2) 0; padding: var(--s2) var(--s3); border-radius: var(--radius); font-size: .9rem; border: 1px solid var(--line); }
|
||||
.step-health-lost { background: var(--err-bg); color: var(--err-fg); border-color: var(--err-line); }
|
||||
.step-health-quiet { background: var(--accent-weak); color: var(--fg); border-color: var(--accent); }
|
||||
.what-was-done summary { cursor: pointer; color: var(--muted); }
|
||||
|
||||
/* htmx loading feedback */
|
||||
.htmx-indicator { opacity: 0; transition: opacity .2s; color: var(--muted); font-size: .8rem; }
|
||||
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }
|
||||
|
||||
@@ -583,7 +583,7 @@ templ chatPanel(v chatView) {
|
||||
</div>
|
||||
}
|
||||
if v.Truncated {
|
||||
<p class="chat-note muted">Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.</p>
|
||||
<p class="chat-note muted">Working from a bounded portion of a long transcript — answers about the end of the { v.subject() } may be incomplete.</p>
|
||||
}
|
||||
if v.Error != "" {
|
||||
<p class="chat-error" role="alert">{ v.Error }</p>
|
||||
@@ -591,8 +591,8 @@ templ chatPanel(v chatView) {
|
||||
<form
|
||||
class="chat-form"
|
||||
method="post"
|
||||
action={ chatURL(v.VideoID) }
|
||||
hx-post={ string(chatURL(v.VideoID)) }
|
||||
action={ v.postURL() }
|
||||
hx-post={ string(v.postURL()) }
|
||||
hx-target="#chat-panel"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@@ -609,7 +609,7 @@ templ chatPanel(v chatView) {
|
||||
</select>
|
||||
<span class="chat-model-hint muted">Switch models to compare answers on the same transcript.</span>
|
||||
</label>
|
||||
<textarea name="question" rows="3" placeholder="Ask a question about this video…" required aria-label="Your question"></textarea>
|
||||
<textarea name="question" rows="3" placeholder={ "Ask a question about this " + v.subject() + "…" } required aria-label="Your question"></textarea>
|
||||
<button type="submit" class="btn">Ask</button>
|
||||
<span class="htmx-indicator chat-thinking">thinking…</span>
|
||||
</form>
|
||||
|
||||
+325
-297
@@ -5,8 +5,10 @@ package web
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
import (
|
||||
"github.com/a-h/templ"
|
||||
templruntime "github.com/a-h/templ/runtime"
|
||||
)
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -292,7 +294,7 @@ func flashBanner(code string) templ.Component {
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if f, ok := flashFor(code); ok {
|
||||
var templ_7745c5c3_Var9 = []any{"flash", "flash-" + f.Kind}
|
||||
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
|
||||
@@ -824,7 +826,7 @@ func VideoCard(r store.SummaryRow) templ.Component {
|
||||
templ_7745c5c3_Var25 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var26 = []any{"card", templ.KV("card-pending", !r.Summarized)}
|
||||
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
|
||||
@@ -2152,148 +2154,174 @@ func chatPanel(v chatView) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if v.Truncated {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<p class=\"chat-note muted\">Working from a bounded portion of a long transcript — answers about the end of the video may be incomplete.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if v.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "<p class=\"chat-error\" role=\"alert\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<p class=\"chat-note muted\">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.Error)
|
||||
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: 589, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 586, 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, 176, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, " may be incomplete.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, " <form class=\"chat-form\" method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var89 templ.SafeURL
|
||||
templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinURLErrs(chatURL(v.VideoID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 594, Col: 31}
|
||||
if v.Error != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<p class=\"chat-error\" role=\"alert\">")
|
||||
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}
|
||||
}
|
||||
_, 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, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, " <form class=\"chat-form\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "\" hx-post=\"")
|
||||
var templ_7745c5c3_Var90 templ.SafeURL
|
||||
templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinURLErrs(v.postURL())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 594, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var90 string
|
||||
templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(chatURL(v.VideoID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 595, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "\" hx-target=\"#chat-panel\" hx-swap=\"outerHTML\">")
|
||||
var templ_7745c5c3_Var91 string
|
||||
templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(v.postURL()))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 595, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\" 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, 180, "<input type=\"hidden\" name=\"hq\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var91 string
|
||||
templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Question)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 600, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "\"> <input type=\"hidden\" name=\"ha\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<input type=\"hidden\" name=\"hq\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var92 string
|
||||
templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Answer)
|
||||
templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Question)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 601, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 600, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\"> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "<label class=\"chat-model\">Model <select name=\"model\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, m := range v.Models {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\"> <input type=\"hidden\" name=\"ha\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var93 string
|
||||
templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(m)
|
||||
templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Answer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 607, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 601, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\"> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if m == v.Selected {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, ">")
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "<label class=\"chat-model\">Model <select name=\"model\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, m := range v.Models {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var94 string
|
||||
templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(m)
|
||||
templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.ResolveAttributeValue(m)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 607, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 607, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var94)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if m == v.Selected {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var95 string
|
||||
templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(m)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 607, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "</select> <span class=\"chat-model-hint muted\">Switch models to compare answers on the same transcript.</span></label> <textarea name=\"question\" rows=\"3\" placeholder=\"Ask a question about this video…\" required aria-label=\"Your question\"></textarea> <button type=\"submit\" class=\"btn\">Ask</button> <span class=\"htmx-indicator chat-thinking\">thinking…</span></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</select> <span class=\"chat-model-hint muted\">Switch models to compare answers on the same transcript.</span></label> <textarea name=\"question\" rows=\"3\" placeholder=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var96 string
|
||||
templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.ResolveAttributeValue("Ask a question about this " + v.subject() + "…")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 612, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var96)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "\" required aria-label=\"Your question\"></textarea> <button type=\"submit\" class=\"btn\">Ask</button> <span class=\"htmx-indicator chat-thinking\">thinking…</span></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2320,12 +2348,12 @@ func RegisterPage(email, errMsg string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var95 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var95 == nil {
|
||||
templ_7745c5c3_Var95 = templ.NopComponent
|
||||
templ_7745c5c3_Var97 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var97 == nil {
|
||||
templ_7745c5c3_Var97 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var96 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Var98 := 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 {
|
||||
@@ -2337,59 +2365,59 @@ func RegisterPage(email, errMsg string) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "<article class=\"register\"><h1>Complete your registration</h1>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "<article class=\"register\"><h1>Complete your registration</h1>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if email != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "<p class=\"meta\">Signed in as ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<p class=\"meta\">Signed in as ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var97 string
|
||||
templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(email)
|
||||
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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
|
||||
_, 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, 193, ".</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, ".</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "<p>Choose a display name to finish setting up your Tapir account.</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<p>Choose a display name to finish setting up your Tapir account.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if errMsg != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "<p class=\"error\" role=\"alert\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "<p class=\"error\" role=\"alert\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var98 string
|
||||
templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg)
|
||||
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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
|
||||
_, 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, 196, "</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "<form method=\"post\" action=\"/register\" class=\"register-form\"><label>Display name <input type=\"text\" name=\"display_name\" required autofocus></label> <button type=\"submit\" class=\"btn\">Register</button></form></article>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "<form method=\"post\" action=\"/register\" class=\"register-form\"><label>Display name <input type=\"text\" name=\"display_name\" required autofocus></label> <button type=\"submit\" class=\"btn\">Register</button></form></article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var96), templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Layout("Tapir — Register").Render(templ.WithChildren(ctx, templ_7745c5c3_Var98), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2417,12 +2445,12 @@ func AccountPage(displayName, email string, conns []store.Connection, autoSummar
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var99 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var99 == nil {
|
||||
templ_7745c5c3_Var99 = templ.NopComponent
|
||||
templ_7745c5c3_Var101 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var101 == nil {
|
||||
templ_7745c5c3_Var101 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Var100 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_Var102 := 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 {
|
||||
@@ -2438,43 +2466,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, 198, " <article class=\"account\"><h1>Account</h1><dl class=\"account-meta\"><dt>Display name</dt><dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, " <article class=\"account\"><h1>Account</h1><dl class=\"account-meta\"><dt>Display name</dt><dd>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var101 string
|
||||
templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(displayNameOr(displayName))
|
||||
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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
|
||||
_, 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, 199, "</dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</dd>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if email != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "<dt>Signed in as</dt><dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "<dt>Signed in as</dt><dd>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var102 string
|
||||
templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(email)
|
||||
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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
|
||||
_, 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, 201, "</dd>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "</dd>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</dl><section><h2>Summarization</h2><p class=\"muted\">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.</p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "</dl><section><h2>Summarization</h2><p class=\"muted\">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.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2482,178 +2510,178 @@ 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, 203, "</section>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(channelErrors) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<section class=\"channel-errors\"><h2>Unavailable channels</h2><p class=\"muted\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<section class=\"channel-errors\"><h2>Unavailable channels</h2><p class=\"muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var103 string
|
||||
templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d channel(s) returned errors on the last discovery pass.", len(channelErrors)))
|
||||
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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
|
||||
_, 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, 205, " These may have been deleted or made private on YouTube.</p><ul class=\"channel-error-list\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, " These may have been deleted or made private on YouTube.</p><ul class=\"channel-error-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, ce := range channelErrors {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<li><span class=\"channel-error-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var104 string
|
||||
templ_7745c5c3_Var104, 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}
|
||||
}
|
||||
_, 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, 207, "</span> <span class=\"chip chip-warn\">unavailable</span> <span class=\"muted channel-error-since\">since ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var105 string
|
||||
templ_7745c5c3_Var105, 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}
|
||||
}
|
||||
_, 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, 208, "</span></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</ul></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "<section><h2>Connected accounts</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(conns) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<p class=\"muted\">No connected video accounts yet.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "<ul class=\"conn-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, c := range conns {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "<li><span class=\"channel-error-name\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var106 string
|
||||
templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(providerLabel(c.Provider))
|
||||
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: 699, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 682, 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, 214, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span> <span class=\"chip chip-warn\">unavailable</span> <span class=\"muted channel-error-since\">since ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if c.ProviderAccount != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<span class=\"muted\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var107 string
|
||||
templ_7745c5c3_Var107, 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}
|
||||
}
|
||||
_, 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, 216, "</span> ")
|
||||
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}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<span class=\"chip\">")
|
||||
_, 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, "</span></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "</ul></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "<section><h2>Connected accounts</h2>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(conns) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "<p class=\"muted\">No connected video accounts yet.</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "<ul class=\"conn-list\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, c := range conns {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<li class=\"conn\"><div class=\"conn-main\"><span class=\"conn-provider\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var108 string
|
||||
templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status)
|
||||
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: 703, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 699, 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, 218, "</span></div><div class=\"conn-meta muted\">connected ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var109 string
|
||||
templ_7745c5c3_Var109, 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}
|
||||
if c.ProviderAccount != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "<span class=\"muted\">")
|
||||
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}
|
||||
}
|
||||
_, 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, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "<span class=\"chip\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "</div><form method=\"post\" action=\"")
|
||||
var templ_7745c5c3_Var110 string
|
||||
templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(c.Status)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var110 templ.SafeURL
|
||||
templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 706, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 703, 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, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "</span></div><div class=\"conn-meta muted\">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}
|
||||
}
|
||||
_, 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, "</div><form method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var112 templ.SafeURL
|
||||
templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinURLErrs(disconnectURL(c.Provider))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 706, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "\"><button type=\"submit\" class=\"btn-secondary\">Disconnect</button></form></li>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "</ul>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</ul>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if !hasYouTube(conns) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<p><a class=\"btn\" href=\"/oauth/youtube/connect\">Connect YouTube</a></p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</section><section class=\"danger-zone\"><h2>Delete account</h2><p class=\"muted\">Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.</p><details class=\"confirm-delete\"><summary class=\"btn-danger\">Delete account…</summary><div class=\"confirm-body\"><p>This permanently deletes your account and all data. Are you sure?</p><form method=\"post\" action=\"/account/delete\"><button type=\"submit\" class=\"btn-danger\">Yes, permanently delete my account</button></form></div></details></section></article>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</section><section class=\"danger-zone\"><h2>Delete account</h2><p class=\"muted\">Permanently remove your Tapir account and all of its data — summaries, watch/skip/save actions, and connected accounts. This cannot be undone.</p><details class=\"confirm-delete\"><summary class=\"btn-danger\">Delete account…</summary><div class=\"confirm-body\"><p>This permanently deletes your account and all data. Are you sure?</p><form method=\"post\" action=\"/account/delete\"><button type=\"submit\" class=\"btn-danger\">Yes, permanently delete my account</button></form></div></details></section></article>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var100), templ_7745c5c3_Buffer)
|
||||
templ_7745c5c3_Err = Layout("Tapir — Account").Render(templ.WithChildren(ctx, templ_7745c5c3_Var102), templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2681,51 +2709,51 @@ func summarizeModeControl(auto bool) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var111 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var111 == nil {
|
||||
templ_7745c5c3_Var111 = templ.NopComponent
|
||||
templ_7745c5c3_Var113 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var113 == nil {
|
||||
templ_7745c5c3_Var113 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "<div id=\"summarize-mode\" class=\"summarize-mode\"><p>Current mode: <strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var112 string
|
||||
templ_7745c5c3_Var112, 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}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</strong></p><form method=\"post\" action=\"/account/summarize-mode\" hx-post=\"/account/summarize-mode\" hx-target=\"#summarize-mode\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"enabled\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var113 string
|
||||
templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 751, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var113)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\"> <button type=\"submit\" class=\"btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<div id=\"summarize-mode\" class=\"summarize-mode\"><p>Current mode: <strong>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var114 string
|
||||
templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
|
||||
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: 752, Col: 79}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 743, 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, "</button></form></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "</strong></p><form method=\"post\" action=\"/account/summarize-mode\" hx-post=\"/account/summarize-mode\" hx-target=\"#summarize-mode\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"enabled\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var115 string
|
||||
templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolStr(!auto))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 751, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var115)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\"> <button type=\"submit\" class=\"btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var116 string
|
||||
templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(summarizeModeToggleLabel(auto))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 752, Col: 79}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "</button></form></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2753,38 +2781,38 @@ func ActionButtons(videoID string, active map[string]bool) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var115 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var115 == nil {
|
||||
templ_7745c5c3_Var115 = templ.NopComponent
|
||||
templ_7745c5c3_Var117 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var117 == nil {
|
||||
templ_7745c5c3_Var117 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<form id=\"action-buttons\" class=\"actions\" method=\"post\" action=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var116 templ.SafeURL
|
||||
templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
|
||||
var templ_7745c5c3_Var118 templ.SafeURL
|
||||
templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(actionURL(videoID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 766, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "\" hx-post=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "\" hx-post=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var117 string
|
||||
templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
|
||||
var templ_7745c5c3_Var119 string
|
||||
templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(string(actionURL(videoID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 767, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var117)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\"><span class=\"segmented\" role=\"group\" aria-label=\"Watched or skipped\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "\" hx-target=\"#action-buttons\" hx-swap=\"outerHTML\"><span class=\"segmented\" role=\"group\" aria-label=\"Watched or skipped\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2796,7 +2824,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, 231, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2804,7 +2832,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, 232, "</form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -2830,81 +2858,81 @@ func actionButton(verb string, isActive bool) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var118 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var118 == nil {
|
||||
templ_7745c5c3_Var118 = templ.NopComponent
|
||||
templ_7745c5c3_Var120 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var120 == nil {
|
||||
templ_7745c5c3_Var120 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var119 = []any{"action", templ.KV("active", isActive)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var119...)
|
||||
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, 233, "<button type=\"submit\" name=\"action\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var120 string
|
||||
templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(verb)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 788, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var121 string
|
||||
templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var119).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var121)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "\" aria-pressed=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<button type=\"submit\" name=\"action\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var122 string
|
||||
templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(isActive))
|
||||
templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(verb)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 790, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 788, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var123 string
|
||||
templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var121).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "\" aria-pressed=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var124 string
|
||||
templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(ariaPressed(isActive))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 790, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if isActive {
|
||||
var templ_7745c5c3_Var123 string
|
||||
templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(verb))
|
||||
var templ_7745c5c3_Var125 string
|
||||
templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs("✓ " + actionLabel(verb))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 793, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var124 string
|
||||
templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(verb))
|
||||
var templ_7745c5c3_Var126 string
|
||||
templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(actionLabel(verb))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/views.templ`, Line: 795, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user